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,51 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { listFiles, readServerFile, writeServerFile } from '../ops/files.js';
|
|
4
|
+
import { serverPath } from '../ops/world.js';
|
|
5
|
+
import { confirm, serverRelPath } from '../schemas.js';
|
|
6
|
+
import { tool } from './define.js';
|
|
7
|
+
export const fileTools = [
|
|
8
|
+
tool({
|
|
9
|
+
name: 'list_files',
|
|
10
|
+
title: 'List files in the server folder',
|
|
11
|
+
description: 'Lists files and folders inside the test server folder, for example world/datapacks, config or mods. Paths are relative to the server folder; nothing outside it can be listed.',
|
|
12
|
+
input: {
|
|
13
|
+
path: z.string().max(512).default('.').describe('Folder inside the server folder. Default the server folder itself.'),
|
|
14
|
+
depth: z.number().int().min(1).max(5).default(1).describe('How many levels deep. Default 1.'),
|
|
15
|
+
},
|
|
16
|
+
readOnly: true,
|
|
17
|
+
destructive: false,
|
|
18
|
+
idempotent: true,
|
|
19
|
+
run: async (a, ctx) => listFiles(ctx.server(), a.path, a.depth),
|
|
20
|
+
}),
|
|
21
|
+
tool({
|
|
22
|
+
name: 'read_file',
|
|
23
|
+
title: 'Read a file in the server folder',
|
|
24
|
+
description: 'Reads a file inside the test server folder: configs, datapack files, logs. Use encoding base64 for binary files. The RCON password in server.properties is hidden. Limited to maxFileBytes.',
|
|
25
|
+
input: {
|
|
26
|
+
path: serverRelPath,
|
|
27
|
+
encoding: z.enum(['text', 'base64']).default('text'),
|
|
28
|
+
},
|
|
29
|
+
readOnly: true,
|
|
30
|
+
destructive: false,
|
|
31
|
+
idempotent: true,
|
|
32
|
+
run: async (a, ctx) => readServerFile(ctx.server(), ctx.config, a.path, a.encoding),
|
|
33
|
+
}),
|
|
34
|
+
tool({
|
|
35
|
+
name: 'write_file',
|
|
36
|
+
title: 'Write a file in the server folder',
|
|
37
|
+
description: "Writes a file inside the test server folder, making folders as needed: datapacks (world/datapacks/...), configs, server.properties. Mods (.jar files and the mods folder) only if the person allowed it (allowModInstall). The world's chunk files, level.dat and eula.txt cannot be written. After writing server.properties the tool keeps RCON on 127.0.0.1. Use encoding base64 for binary files. Limited to maxFileBytes.",
|
|
38
|
+
input: {
|
|
39
|
+
path: serverRelPath,
|
|
40
|
+
content: z.string().describe('File content, as text or base64'),
|
|
41
|
+
encoding: z.enum(['text', 'base64']).default('text'),
|
|
42
|
+
overwrite: z.boolean().optional().describe('Replace a file that exists. Default false.'),
|
|
43
|
+
confirm,
|
|
44
|
+
},
|
|
45
|
+
readOnly: false,
|
|
46
|
+
destructive: true,
|
|
47
|
+
idempotent: true,
|
|
48
|
+
needsOk: (a, ctx) => (a.overwrite && existsSync(serverPath(ctx.server(), a.path)) ? `replace the existing file ${a.path}` : null),
|
|
49
|
+
run: async (a, ctx) => writeServerFile(ctx.server(), ctx.config, a.path, a.content, a.encoding, a.overwrite ?? false),
|
|
50
|
+
}),
|
|
51
|
+
];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { makeBox } from '../mc/box.js';
|
|
4
|
+
import { serverPath } from '../ops/world.js';
|
|
5
|
+
import { confirm, dimension, includeEntities, schemPath, skipBackup, vec3 } from '../schemas.js';
|
|
6
|
+
import { tool } from './define.js';
|
|
7
|
+
export const schematicTools = [
|
|
8
|
+
tool({
|
|
9
|
+
name: 'import_schematic',
|
|
10
|
+
title: 'Import a .schem into the world',
|
|
11
|
+
description: 'Pastes a Sponge schematic (.schem, version 2 or 3, as WorldEdit and most editors save) from the test server folder into the world with its lowest corner at origin, including chests, signs and other block data. include_air (default true) clears the area first so the result matches the file exactly. Snapshots the area first for undo. Entities in the file (item frames, paintings, armor stands, mobs, minecarts, boats) are summoned after the blocks, with fresh UUIDs; leads stay tied and riders stay seated. include_air also removes the entities already in the area. Limited to maxImportBlocks and maxEntities.',
|
|
12
|
+
input: {
|
|
13
|
+
file: schemPath,
|
|
14
|
+
origin: vec3.describe('Where the lowest corner goes [x, y, z]'),
|
|
15
|
+
include_air: z.boolean().default(true).describe('Clear the area first so air in the file replaces what is there. Default true.'),
|
|
16
|
+
include_entities: includeEntities,
|
|
17
|
+
dimension,
|
|
18
|
+
confirm,
|
|
19
|
+
skip_backup: skipBackup,
|
|
20
|
+
},
|
|
21
|
+
readOnly: false,
|
|
22
|
+
destructive: true,
|
|
23
|
+
idempotent: true,
|
|
24
|
+
needsOk: (a) => `paste ${a.file} at ${a.origin.join(' ')}, replacing what is in that area`,
|
|
25
|
+
run: async (a, ctx) => {
|
|
26
|
+
const file = serverPath(ctx.server(), a.file);
|
|
27
|
+
if (!existsSync(file))
|
|
28
|
+
throw new Error(`No file at ${a.file}. Put the .schem inside the test server folder, for example in schematics/.`);
|
|
29
|
+
return ctx.world().importFile(file, a.origin, a.include_air, { dimension: a.dimension, ...(a.skip_backup ? { skipBackup: true } : {}) }, a.include_entities ?? ctx.config.includeEntities);
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
tool({
|
|
33
|
+
name: 'export_schematic',
|
|
34
|
+
title: 'Export an area to .schem',
|
|
35
|
+
description: 'Saves the box from..to as a Sponge schematic (.schem) inside the test server folder, with block states and block data (chest contents, sign text). Version 3 (default) suits WorldEdit 7.3 and newer; use version 2 for older tools. The file can be pasted on any server with WorldEdit or a similar plugin. Entities whose position is inside the box (item frames, paintings, armor stands, mobs, minecarts, boats) are saved with all their data. Limited to maxImportBlocks and maxEntities.',
|
|
36
|
+
input: {
|
|
37
|
+
from: vec3,
|
|
38
|
+
to: vec3,
|
|
39
|
+
file: schemPath,
|
|
40
|
+
version: z.union([z.literal(2), z.literal(3)]).default(3).describe('Sponge schematic version, 3 (default) or 2'),
|
|
41
|
+
overwrite: z.boolean().optional().describe('Replace a file that exists. Default false.'),
|
|
42
|
+
include_entities: includeEntities,
|
|
43
|
+
dimension,
|
|
44
|
+
confirm,
|
|
45
|
+
},
|
|
46
|
+
readOnly: false,
|
|
47
|
+
destructive: true,
|
|
48
|
+
idempotent: true,
|
|
49
|
+
needsOk: (a, ctx) => (a.overwrite && existsSync(serverPath(ctx.server(), a.file)) ? `replace the existing file ${a.file}` : null),
|
|
50
|
+
run: async (a, ctx) => {
|
|
51
|
+
const file = serverPath(ctx.server(), a.file);
|
|
52
|
+
if (existsSync(file) && !a.overwrite)
|
|
53
|
+
throw new Error(`${a.file} exists. Call again with overwrite: true to replace it.`);
|
|
54
|
+
if (!ctx.config.allowFileWrites)
|
|
55
|
+
throw new Error('File writes are turned off (allowFileWrites is false in the config).');
|
|
56
|
+
return ctx.world().exportFile(makeBox(a.from, a.to), file, a.version, a.dimension, a.include_entities ?? ctx.config.includeEntities);
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { makeBox } from '../mc/box.js';
|
|
3
|
+
import { classifyReply } from '../rcon/rcon.js';
|
|
4
|
+
import { inDimension } from '../ops/world.js';
|
|
5
|
+
import { command, dimension, vec3 } from '../schemas.js';
|
|
6
|
+
import { tool } from './define.js';
|
|
7
|
+
export const serverTools = [
|
|
8
|
+
tool({
|
|
9
|
+
name: 'start_server',
|
|
10
|
+
title: 'Start the test server',
|
|
11
|
+
description: 'Starts the active test server in the background and waits until RCON answers (the first start makes the world and can take a minute or two). Before starting it puts back the settings the tool depends on: listen on 127.0.0.1 only, RCON on, no query port. The person then joins in Minecraft at localhost:<game port>.',
|
|
12
|
+
input: {},
|
|
13
|
+
readOnly: false,
|
|
14
|
+
destructive: false,
|
|
15
|
+
idempotent: true,
|
|
16
|
+
run: async (_a, ctx) => {
|
|
17
|
+
const s = ctx.server();
|
|
18
|
+
const r = await s.start();
|
|
19
|
+
return {
|
|
20
|
+
started: true,
|
|
21
|
+
seconds: r.seconds,
|
|
22
|
+
join_address: `localhost:${s.gamePort}`,
|
|
23
|
+
...(r.fixed.length ? { settings_put_back: r.fixed } : {}),
|
|
24
|
+
tip: 'To let the person use commands in game, run_command "op <their Minecraft name>".',
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
}),
|
|
28
|
+
tool({
|
|
29
|
+
name: 'stop_server',
|
|
30
|
+
title: 'Stop the test server',
|
|
31
|
+
description: 'Stops the test server cleanly through RCON (the world is saved) and waits for it to exit. force: true ends the process if it does not answer; unsaved changes may then be lost.',
|
|
32
|
+
input: {
|
|
33
|
+
force: z.boolean().optional().describe('End the process if it does not answer on RCON. Default false.'),
|
|
34
|
+
},
|
|
35
|
+
readOnly: false,
|
|
36
|
+
destructive: false,
|
|
37
|
+
idempotent: true,
|
|
38
|
+
run: async (a, ctx) => {
|
|
39
|
+
const r = await ctx.server().stop(a.force ?? false);
|
|
40
|
+
return { stopped: true, ...r };
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
tool({
|
|
44
|
+
name: 'restart_server',
|
|
45
|
+
title: 'Restart the test server',
|
|
46
|
+
description: 'Stops the server cleanly and starts it again, for example after changing server.properties, datapacks that need a restart, or mods.',
|
|
47
|
+
input: {},
|
|
48
|
+
readOnly: false,
|
|
49
|
+
destructive: false,
|
|
50
|
+
idempotent: false,
|
|
51
|
+
run: async (_a, ctx) => {
|
|
52
|
+
const s = ctx.server();
|
|
53
|
+
const st = await s.status();
|
|
54
|
+
const stopped = st.running ? await s.stop(false) : null;
|
|
55
|
+
const started = await s.start();
|
|
56
|
+
return { stop_seconds: stopped?.seconds ?? 0, start_seconds: started.seconds, join_address: `localhost:${s.gamePort}` };
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
tool({
|
|
60
|
+
name: 'server_logs',
|
|
61
|
+
title: 'Read the server log',
|
|
62
|
+
description: 'The last lines of the server log (logs/latest.log), or of the console output if the server failed to start. Optional filter keeps only lines containing that text. The RCON password is hidden.',
|
|
63
|
+
input: {
|
|
64
|
+
lines: z.number().int().min(1).max(500).default(80).describe('How many lines. Default 80.'),
|
|
65
|
+
filter: z.string().max(200).optional().describe('Only lines containing this text (not case sensitive)'),
|
|
66
|
+
},
|
|
67
|
+
readOnly: true,
|
|
68
|
+
destructive: false,
|
|
69
|
+
idempotent: true,
|
|
70
|
+
run: async (a, ctx) => ({ lines: ctx.server().logs(a.lines, a.filter) }),
|
|
71
|
+
}),
|
|
72
|
+
tool({
|
|
73
|
+
name: 'run_command',
|
|
74
|
+
title: 'Run a server command',
|
|
75
|
+
description: 'Runs any Minecraft server command through RCON and returns the reply, for example gamerule, time, weather, summon, give, op, reload, function, scoreboard, team, tp, data. Commands from mods and datapacks work too. Give backup_area to snapshot an area first so undo can put it back; otherwise this cannot be undone.',
|
|
76
|
+
input: {
|
|
77
|
+
command,
|
|
78
|
+
backup_area: z.object({ from: vec3, to: vec3 }).optional().describe('Area this command may change; it is saved first so undo can restore it'),
|
|
79
|
+
dimension: dimension.describe('Dimension to run the command in (and of backup_area). Default overworld.'),
|
|
80
|
+
},
|
|
81
|
+
readOnly: false,
|
|
82
|
+
destructive: true,
|
|
83
|
+
idempotent: false,
|
|
84
|
+
run: async (a, ctx) => {
|
|
85
|
+
const w = ctx.world();
|
|
86
|
+
const opts = { dimension: a.dimension };
|
|
87
|
+
let undo = null;
|
|
88
|
+
const exec = async () => {
|
|
89
|
+
if (a.backup_area)
|
|
90
|
+
undo = (await w.snapshot(makeBox(a.backup_area.from, a.backup_area.to), a.dimension, `command: ${a.command.slice(0, 60)}`, opts))?.id ?? null;
|
|
91
|
+
return (await ctx.server().connection()).command(inDimension(a.dimension, a.command.replace(/^\//, '')));
|
|
92
|
+
};
|
|
93
|
+
const reply = a.backup_area ? await w.withLoaded([makeBox(a.backup_area.from, a.backup_area.to)], a.dimension, exec) : await exec();
|
|
94
|
+
return { reply, looks_like: classifyReply(reply), undo };
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
];
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { findJava } from '../mc/java.js';
|
|
3
|
+
import { createServer, planSetup } from '../mc/setup.js';
|
|
4
|
+
import { eulaAccepted, writeEula } from '../mc/testserver.js';
|
|
5
|
+
import { EULA_URL } from '../mc/versions.js';
|
|
6
|
+
import { listBackups } from '../ops/backup.js';
|
|
7
|
+
import { tool } from './define.js';
|
|
8
|
+
function showPlan(p) {
|
|
9
|
+
return {
|
|
10
|
+
plan_id: p.planId,
|
|
11
|
+
ready: p.ready,
|
|
12
|
+
folder: p.folder,
|
|
13
|
+
minecraft: p.minecraft,
|
|
14
|
+
loader: p.loader,
|
|
15
|
+
...(p.loaderVersion ? { neoforge_version: p.loaderVersion, neoforge_is_beta: p.loaderIsBeta } : {}),
|
|
16
|
+
java: p.java
|
|
17
|
+
? { needed: p.javaNeeded, using: p.java.path, version: p.java.version }
|
|
18
|
+
: { needed: p.javaNeeded, found: p.javaFound.map((j) => `${j.path} (Java ${j.major})`), missing: true, where_to_get_it: p.javaMissing },
|
|
19
|
+
downloads: p.downloads.map((d) => ({ what: d.what, from: d.source, url: d.url, checked_with: `${d.hash.algo} ${d.hash.value}`, ...(d.size ? { bytes: d.size } : {}) })),
|
|
20
|
+
game_port: p.gamePort,
|
|
21
|
+
rcon_port: p.rconPort,
|
|
22
|
+
memory_mb: p.memoryMb,
|
|
23
|
+
world_type: p.worldType,
|
|
24
|
+
eula: `The person must read and accept the Minecraft EULA themselves before the server can start: ${p.eulaUrl}`,
|
|
25
|
+
next: p.ready
|
|
26
|
+
? 'Show this plan to the person: what gets downloaded, from where, the folder and the ports. Only if they say yes, call create_server with this plan_id and person_confirmed: true. The plan expires in 30 minutes.'
|
|
27
|
+
: `Java ${p.javaNeeded} or newer is missing. Tell the person where to get it (where_to_get_it), wait until they have installed it, then call plan_setup again. Do not install Java yourself unless they ask you to.`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export const setupTools = [
|
|
31
|
+
tool({
|
|
32
|
+
name: 'status',
|
|
33
|
+
title: 'Status and settings',
|
|
34
|
+
description: 'Call this first. Shows the person\'s safety settings (config file, allowed server folders, size limits, file and mod rules), the active test server (version, folder, running or not, ports, EULA), Java found on this computer, the undo history and world backups.',
|
|
35
|
+
input: {},
|
|
36
|
+
readOnly: true,
|
|
37
|
+
destructive: false,
|
|
38
|
+
idempotent: true,
|
|
39
|
+
run: async (_args, ctx) => {
|
|
40
|
+
const c = ctx.config;
|
|
41
|
+
const s = ctx.current();
|
|
42
|
+
const settings = {
|
|
43
|
+
config_file: c.file,
|
|
44
|
+
config_file_exists: c.fileExists,
|
|
45
|
+
server_folders: c.serverFolders,
|
|
46
|
+
maxFillBlocks: c.maxFillBlocks,
|
|
47
|
+
maxReadBlocks: c.maxReadBlocks,
|
|
48
|
+
maxImportBlocks: c.maxImportBlocks,
|
|
49
|
+
maxFileBytes: c.maxFileBytes,
|
|
50
|
+
maxEntities: c.maxEntities,
|
|
51
|
+
includeEntities: c.includeEntities,
|
|
52
|
+
allowFileWrites: c.allowFileWrites,
|
|
53
|
+
allowModInstall: c.allowModInstall,
|
|
54
|
+
confirmDestructive: c.confirmDestructive,
|
|
55
|
+
autoBackup: c.autoBackup,
|
|
56
|
+
undoHistory: c.undoHistory,
|
|
57
|
+
rconAddress: c.rconAddress,
|
|
58
|
+
};
|
|
59
|
+
if (!s) {
|
|
60
|
+
const java = await findJava(c.javaPath ? [c.javaPath] : []);
|
|
61
|
+
return {
|
|
62
|
+
settings,
|
|
63
|
+
server: null,
|
|
64
|
+
java_found: java.map((j) => `${j.path} (Java ${j.major})`),
|
|
65
|
+
next: 'No test server yet. Ask the person which Minecraft version and which modpack (or plain vanilla) they use, recommend a separate test server, then call plan_setup.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const st = await s.status();
|
|
69
|
+
return {
|
|
70
|
+
settings,
|
|
71
|
+
server: {
|
|
72
|
+
folder: s.dir,
|
|
73
|
+
minecraft: s.meta.minecraft,
|
|
74
|
+
loader: s.meta.loader,
|
|
75
|
+
...(s.meta.loaderVersion ? { loader_version: s.meta.loaderVersion } : {}),
|
|
76
|
+
running: st.running,
|
|
77
|
+
rcon_connected: st.rcon,
|
|
78
|
+
join_address: `localhost:${s.gamePort}`,
|
|
79
|
+
rcon_port: s.meta.rconPort,
|
|
80
|
+
eula_accepted: eulaAccepted(s.dir),
|
|
81
|
+
java: s.meta.javaPath,
|
|
82
|
+
},
|
|
83
|
+
known_servers: ctx.knownServers(),
|
|
84
|
+
undo: ctx.world().undo.list().map((u) => ({ id: u.id, label: u.label, from: u.box.min, to: u.box.max, dimension: u.dimension, time: u.time })),
|
|
85
|
+
world_backups: listBackups(s),
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
tool({
|
|
90
|
+
name: 'plan_setup',
|
|
91
|
+
title: 'Plan a test server (changes nothing)',
|
|
92
|
+
description: 'Looks up everything needed for a new local test server and changes nothing: the exact Minecraft (and NeoForge) version, which Java it needs and whether it is installed, every file that would be downloaded with its official source and hash, the folder and the ports. Show the result to the person and ask before create_server. Minecraft Java Edition 1.20.1 or newer.',
|
|
93
|
+
input: {
|
|
94
|
+
minecraft: z.string().min(1).max(20).describe('Minecraft version such as "1.21.11", or "latest" for the newest release'),
|
|
95
|
+
loader: z.enum(['vanilla', 'neoforge']).describe('vanilla, or neoforge for modpacks'),
|
|
96
|
+
name: z.string().min(1).max(40).default('test-server').describe('Folder name for the server. Default "test-server".'),
|
|
97
|
+
server_folder: z.string().optional().describe('Which allowed folder to put it in (see status). Default the first one.'),
|
|
98
|
+
game_port: z.number().int().min(1024).max(65535).optional().describe('Port to join on. Default: the first free port from 25565.'),
|
|
99
|
+
rcon_port: z.number().int().min(1024).max(65535).optional().describe('RCON port on 127.0.0.1. Default: the first free port from 25575.'),
|
|
100
|
+
memory_mb: z.number().int().min(1024).max(32768).optional().describe('Memory for the server. Default 2048 (vanilla) or 4096 (NeoForge).'),
|
|
101
|
+
world_type: z.enum(['flat', 'normal']).optional().describe('flat (default, good for building) or normal terrain'),
|
|
102
|
+
java_path: z.string().optional().describe('A specific java executable to use'),
|
|
103
|
+
},
|
|
104
|
+
readOnly: true,
|
|
105
|
+
destructive: false,
|
|
106
|
+
idempotent: true,
|
|
107
|
+
openWorld: true,
|
|
108
|
+
run: async (a, ctx) => {
|
|
109
|
+
const plan = await planSetup(ctx.config, {
|
|
110
|
+
minecraft: a.minecraft,
|
|
111
|
+
loader: a.loader,
|
|
112
|
+
name: a.name,
|
|
113
|
+
...(a.server_folder ? { serverFolder: a.server_folder } : {}),
|
|
114
|
+
...(a.game_port ? { gamePort: a.game_port } : {}),
|
|
115
|
+
...(a.rcon_port ? { rconPort: a.rcon_port } : {}),
|
|
116
|
+
...(a.memory_mb ? { memoryMb: a.memory_mb } : {}),
|
|
117
|
+
...(a.world_type ? { worldType: a.world_type } : {}),
|
|
118
|
+
...(a.java_path ? { javaPath: a.java_path } : {}),
|
|
119
|
+
});
|
|
120
|
+
for (const [id, p] of ctx.plans)
|
|
121
|
+
if (p.expires < Date.now())
|
|
122
|
+
ctx.plans.delete(id);
|
|
123
|
+
ctx.plans.set(plan.planId, plan);
|
|
124
|
+
return showPlan(plan);
|
|
125
|
+
},
|
|
126
|
+
}),
|
|
127
|
+
tool({
|
|
128
|
+
name: 'create_server',
|
|
129
|
+
title: 'Create the planned test server',
|
|
130
|
+
description: 'Carries out a plan from plan_setup: makes the folder, downloads the server from the official source, checks every hash (nothing is kept if a hash is wrong), runs the NeoForge installer if chosen, and writes server.properties with RCON on 127.0.0.1 and a random password. Does not accept the EULA and does not start the server. Only call this after the person said yes to the plan.',
|
|
131
|
+
input: {
|
|
132
|
+
plan_id: z.string().uuid().describe('plan_id from plan_setup'),
|
|
133
|
+
person_confirmed: z.literal(true).describe('true only after you showed the person the plan (downloads, sources, folder, ports) and they said yes'),
|
|
134
|
+
},
|
|
135
|
+
readOnly: false,
|
|
136
|
+
destructive: false,
|
|
137
|
+
idempotent: false,
|
|
138
|
+
openWorld: true,
|
|
139
|
+
run: async (a, ctx) => {
|
|
140
|
+
const plan = ctx.plans.get(a.plan_id);
|
|
141
|
+
if (!plan)
|
|
142
|
+
throw new Error('Unknown or expired plan_id. Call plan_setup, show the person the plan, and use the new plan_id.');
|
|
143
|
+
const created = await createServer(plan);
|
|
144
|
+
ctx.plans.delete(a.plan_id);
|
|
145
|
+
ctx.setActive(created.folder);
|
|
146
|
+
return {
|
|
147
|
+
folder: created.folder,
|
|
148
|
+
minecraft: created.meta.minecraft,
|
|
149
|
+
loader: created.meta.loader,
|
|
150
|
+
downloaded: created.downloaded,
|
|
151
|
+
seconds: created.seconds,
|
|
152
|
+
next: `Show the person the Minecraft EULA (${EULA_URL}) and ask whether they accept it. Only if they say yes, call accept_eula with person_accepted: true. Then start_server.`,
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
tool({
|
|
157
|
+
name: 'accept_eula',
|
|
158
|
+
title: 'Record that the person accepted the EULA',
|
|
159
|
+
description: `Writes eula=true for the active test server. Minecraft needs this to start. Only call it after you showed the person the Minecraft EULA (${EULA_URL}) and they said they accept it. Never accept it for them.`,
|
|
160
|
+
input: {
|
|
161
|
+
person_accepted: z.literal(true).describe('true only if the person read the EULA and said they accept it'),
|
|
162
|
+
},
|
|
163
|
+
readOnly: false,
|
|
164
|
+
destructive: false,
|
|
165
|
+
idempotent: true,
|
|
166
|
+
run: async (_a, ctx) => {
|
|
167
|
+
const s = ctx.server();
|
|
168
|
+
writeEula(s.dir, true);
|
|
169
|
+
return { folder: s.dir, eula_accepted: true, next: 'Call start_server.' };
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
tool({
|
|
173
|
+
name: 'use_server',
|
|
174
|
+
title: 'Switch to another test server folder',
|
|
175
|
+
description: 'Makes another server folder the active one. It must be inside one of the allowed server folders in the person\'s config (see status). A server this tool did not make is adopted: the tool works out how to start it and keeps its settings, except that it always keeps RCON on 127.0.0.1.',
|
|
176
|
+
input: {
|
|
177
|
+
folder: z.string().min(1).describe('Full path of the server folder'),
|
|
178
|
+
},
|
|
179
|
+
readOnly: false,
|
|
180
|
+
destructive: false,
|
|
181
|
+
idempotent: true,
|
|
182
|
+
run: async (a, ctx) => {
|
|
183
|
+
const { server, adopted } = await ctx.adopt(a.folder);
|
|
184
|
+
return { folder: server.dir, adopted, loader: server.meta.loader, minecraft: server.meta.minecraft, eula_accepted: eulaAccepted(server.dir) };
|
|
185
|
+
},
|
|
186
|
+
}),
|
|
187
|
+
];
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { get, nbt, num } from '../nbt/nbt.js';
|
|
2
|
+
import { readChunk, RegionError } from './region.js';
|
|
3
|
+
export function entityPos(c) {
|
|
4
|
+
const p = c.value.get('Pos');
|
|
5
|
+
if (!p || p.type !== 'list' || p.value.length !== 3)
|
|
6
|
+
return null;
|
|
7
|
+
const v = p.value.map((t) => (t.type === 'double' || t.type === 'float' ? t.value : NaN));
|
|
8
|
+
return v.every(Number.isFinite) ? [v[0], v[1], v[2]] : null;
|
|
9
|
+
}
|
|
10
|
+
export function entityUuid(c) {
|
|
11
|
+
const u = get(c, 'UUID', 'intArray');
|
|
12
|
+
return u && u.value.length === 4 ? Array.from(u.value) : null;
|
|
13
|
+
}
|
|
14
|
+
/** A mob in its death animation is still saved for a moment; it is not part of the build. */
|
|
15
|
+
function isDead(c) {
|
|
16
|
+
const health = num(c, 'Health');
|
|
17
|
+
return health !== undefined && health <= 0;
|
|
18
|
+
}
|
|
19
|
+
/** True when a position is inside the box, counting the whole of each edge block. */
|
|
20
|
+
export function insideBox(p, box) {
|
|
21
|
+
return [0, 1, 2].every((i) => p[i] >= box.min[i] && p[i] < box.max[i] + 1);
|
|
22
|
+
}
|
|
23
|
+
export async function readEntities(dir, box) {
|
|
24
|
+
const out = [];
|
|
25
|
+
const regions = new Map();
|
|
26
|
+
for (let cx = Math.floor(box.min[0] / 16); cx <= Math.floor(box.max[0] / 16); cx++) {
|
|
27
|
+
for (let cz = Math.floor(box.min[2] / 16); cz <= Math.floor(box.max[2] / 16); cz++) {
|
|
28
|
+
const chunk = await readChunk(dir, regions, cx, cz);
|
|
29
|
+
if (!chunk)
|
|
30
|
+
continue;
|
|
31
|
+
const list = get(chunk, 'Entities', 'list');
|
|
32
|
+
if (!list)
|
|
33
|
+
continue;
|
|
34
|
+
for (const e of list.value) {
|
|
35
|
+
if (e.type !== 'compound')
|
|
36
|
+
continue;
|
|
37
|
+
const id = get(e, 'id', 'string')?.value;
|
|
38
|
+
const pos = entityPos(e);
|
|
39
|
+
if (!id || !pos)
|
|
40
|
+
throw new RegionError(`An entity in chunk ${cx},${cz} has no id or position`);
|
|
41
|
+
if (id === 'minecraft:player' || isDead(e) || !insideBox(pos, box))
|
|
42
|
+
continue;
|
|
43
|
+
out.push({ id, pos, uuid: entityUuid(e), data: e });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/** Entities as schematic entries, with positions relative to the box's lowest corner. */
|
|
50
|
+
export function toSchematicEntities(list, box) {
|
|
51
|
+
return list.map((e) => {
|
|
52
|
+
const data = nbt.compound(new Map(e.data.value));
|
|
53
|
+
data.value.delete('id');
|
|
54
|
+
return { id: e.id, pos: [e.pos[0] - box.min[0], e.pos[1] - box.min[1], e.pos[2] - box.min[2]], data };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { gunzipSync, inflateSync } from 'node:zlib';
|
|
5
|
+
import { get, nbt, num, readNbt } from '../nbt/nbt.js';
|
|
6
|
+
import { boxSize } from '../mc/box.js';
|
|
7
|
+
// Reads blocks from the world's saved region files (Anvil format, Minecraft 1.18 and newer).
|
|
8
|
+
// The server is asked to save first (save-all flush), so the files match the world. Read only.
|
|
9
|
+
export const AIR = 'minecraft:air';
|
|
10
|
+
const MAX_CHUNK_BYTES = 64 * 1024 * 1024;
|
|
11
|
+
export class RegionError extends Error {
|
|
12
|
+
}
|
|
13
|
+
export function regionDir(worldDir, dimension, kind = 'region') {
|
|
14
|
+
const legacy = { overworld: kind, the_nether: join('DIM-1', kind), the_end: join('DIM1', kind) }[dimension];
|
|
15
|
+
const candidates = [join(worldDir, legacy), join(worldDir, 'dimensions', 'minecraft', dimension, kind)];
|
|
16
|
+
return candidates.find((c) => existsSync(c)) ?? candidates[0];
|
|
17
|
+
}
|
|
18
|
+
export function stateString(entry) {
|
|
19
|
+
const name = get(entry, 'Name', 'string')?.value;
|
|
20
|
+
if (!name)
|
|
21
|
+
throw new RegionError('Palette entry without a name');
|
|
22
|
+
const props = get(entry, 'Properties', 'compound');
|
|
23
|
+
if (!props || props.value.size === 0)
|
|
24
|
+
return name;
|
|
25
|
+
const parts = [...props.value.entries()]
|
|
26
|
+
.map(([k, v]) => `${k}=${v.type === 'string' ? v.value : String(v.value)}`)
|
|
27
|
+
.sort();
|
|
28
|
+
return `${name}[${parts.join(',')}]`;
|
|
29
|
+
}
|
|
30
|
+
/** Unpacks a section's block indices: 4096 entries, at least 4 bits each, entries never span two longs. */
|
|
31
|
+
export function unpackIndices(data, paletteSize) {
|
|
32
|
+
const out = new Uint16Array(4096);
|
|
33
|
+
if (paletteSize <= 1 || !data)
|
|
34
|
+
return out;
|
|
35
|
+
const bits = Math.max(4, Math.ceil(Math.log2(paletteSize)));
|
|
36
|
+
const perLong = Math.floor(64 / bits);
|
|
37
|
+
const mask = (1n << BigInt(bits)) - 1n;
|
|
38
|
+
const need = Math.ceil(4096 / perLong);
|
|
39
|
+
if (data.length < need)
|
|
40
|
+
throw new RegionError(`Section data too short (${data.length} longs, need ${need})`);
|
|
41
|
+
for (let i = 0; i < 4096; i++) {
|
|
42
|
+
const word = BigInt.asUintN(64, data[Math.floor(i / perLong)]);
|
|
43
|
+
const v = Number((word >> BigInt((i % perLong) * bits)) & mask);
|
|
44
|
+
if (v >= paletteSize)
|
|
45
|
+
throw new RegionError('Section uses a palette index that does not exist');
|
|
46
|
+
out[i] = v;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/** The inverse of unpackIndices, used by tests to build region files. */
|
|
51
|
+
export function packIndices(indices, paletteSize) {
|
|
52
|
+
const bits = Math.max(4, Math.ceil(Math.log2(paletteSize)));
|
|
53
|
+
const perLong = Math.floor(64 / bits);
|
|
54
|
+
const out = new BigInt64Array(Math.ceil(4096 / perLong));
|
|
55
|
+
const words = new Array(out.length).fill(0n);
|
|
56
|
+
for (let i = 0; i < 4096; i++) {
|
|
57
|
+
const w = Math.floor(i / perLong);
|
|
58
|
+
words[w] = words[w] | (BigInt(indices[i]) << BigInt((i % perLong) * bits));
|
|
59
|
+
}
|
|
60
|
+
words.forEach((w, i) => (out[i] = BigInt.asIntN(64, w)));
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
function decompress(kind, bytes) {
|
|
64
|
+
const opts = { maxOutputLength: MAX_CHUNK_BYTES };
|
|
65
|
+
if (kind === 1)
|
|
66
|
+
return gunzipSync(bytes, opts);
|
|
67
|
+
if (kind === 2)
|
|
68
|
+
return inflateSync(bytes, opts);
|
|
69
|
+
if (kind === 3)
|
|
70
|
+
return bytes;
|
|
71
|
+
if (kind === 4)
|
|
72
|
+
throw new RegionError('This world stores chunks with LZ4 (region-file-compression=lz4); set it back to deflate in server.properties');
|
|
73
|
+
throw new RegionError(`Unknown chunk compression ${kind}`);
|
|
74
|
+
}
|
|
75
|
+
export async function readChunk(dir, regions, cx, cz) {
|
|
76
|
+
const rx = Math.floor(cx / 32);
|
|
77
|
+
const rz = Math.floor(cz / 32);
|
|
78
|
+
const key = `${rx}.${rz}`;
|
|
79
|
+
if (!regions.has(key)) {
|
|
80
|
+
const file = join(dir, `r.${key}.mca`);
|
|
81
|
+
regions.set(key, existsSync(file) ? await readFile(file) : null);
|
|
82
|
+
}
|
|
83
|
+
const region = regions.get(key);
|
|
84
|
+
if (!region || region.length < 8192)
|
|
85
|
+
return null;
|
|
86
|
+
const slot = 4 * ((cx & 31) + (cz & 31) * 32);
|
|
87
|
+
const sector = (region[slot] << 16) | (region[slot + 1] << 8) | region[slot + 2];
|
|
88
|
+
if (sector === 0 || region[slot + 3] === 0)
|
|
89
|
+
return null;
|
|
90
|
+
const at = sector * 4096;
|
|
91
|
+
if (at + 5 > region.length)
|
|
92
|
+
throw new RegionError(`Region ${key} is cut short`);
|
|
93
|
+
const length = region.readUInt32BE(at);
|
|
94
|
+
const kind = region[at + 4];
|
|
95
|
+
let payload;
|
|
96
|
+
if (kind & 0x80) {
|
|
97
|
+
const external = join(dir, `c.${cx}.${cz}.mcc`);
|
|
98
|
+
if (!existsSync(external))
|
|
99
|
+
throw new RegionError(`Chunk ${cx},${cz} points to a missing ${external}`);
|
|
100
|
+
payload = await readFile(external);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
if (length < 1 || at + 4 + length > region.length)
|
|
104
|
+
throw new RegionError(`Chunk ${cx},${cz} is cut short`);
|
|
105
|
+
payload = region.subarray(at + 5, at + 4 + length);
|
|
106
|
+
}
|
|
107
|
+
return readNbt(decompress(kind & 0x7f, payload)).tag;
|
|
108
|
+
}
|
|
109
|
+
function isFull(chunk) {
|
|
110
|
+
const status = get(chunk, 'Status', 'string')?.value;
|
|
111
|
+
return status === undefined || status === 'minecraft:full' || status === 'full';
|
|
112
|
+
}
|
|
113
|
+
export async function readArea(dir, box) {
|
|
114
|
+
const [w, h, l] = boxSize(box);
|
|
115
|
+
const palette = [AIR];
|
|
116
|
+
const paletteIndex = new Map([[AIR, 0]]);
|
|
117
|
+
const blocks = new Uint32Array(w * h * l);
|
|
118
|
+
const blockEntities = [];
|
|
119
|
+
const missingChunks = [];
|
|
120
|
+
const regions = new Map();
|
|
121
|
+
let dataVersion = 0;
|
|
122
|
+
const [x0, y0, z0] = box.min;
|
|
123
|
+
const [x1, y1, z1] = box.max;
|
|
124
|
+
for (let cx = Math.floor(x0 / 16); cx <= Math.floor(x1 / 16); cx++) {
|
|
125
|
+
for (let cz = Math.floor(z0 / 16); cz <= Math.floor(z1 / 16); cz++) {
|
|
126
|
+
const chunk = await readChunk(dir, regions, cx, cz);
|
|
127
|
+
if (!chunk || !isFull(chunk)) {
|
|
128
|
+
missingChunks.push([cx, cz]);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
dataVersion = Math.max(dataVersion, num(chunk, 'DataVersion') ?? 0);
|
|
132
|
+
const sections = get(chunk, 'sections', 'list');
|
|
133
|
+
if (!sections)
|
|
134
|
+
throw new RegionError(`Chunk ${cx},${cz} has no sections (worlds from Minecraft 1.18 or newer are supported)`);
|
|
135
|
+
for (const sec of sections.value) {
|
|
136
|
+
if (sec.type !== 'compound')
|
|
137
|
+
continue;
|
|
138
|
+
const sy = num(sec, 'Y');
|
|
139
|
+
if (sy === undefined || sy * 16 > y1 || sy * 16 + 15 < y0)
|
|
140
|
+
continue;
|
|
141
|
+
const states = get(sec, 'block_states', 'compound');
|
|
142
|
+
if (!states)
|
|
143
|
+
continue;
|
|
144
|
+
const pal = get(states, 'palette', 'list');
|
|
145
|
+
if (!pal || pal.value.length === 0)
|
|
146
|
+
continue;
|
|
147
|
+
const local = pal.value.map((e) => {
|
|
148
|
+
const s = stateString(e);
|
|
149
|
+
let i = paletteIndex.get(s);
|
|
150
|
+
if (i === undefined) {
|
|
151
|
+
i = palette.length;
|
|
152
|
+
palette.push(s);
|
|
153
|
+
paletteIndex.set(s, i);
|
|
154
|
+
}
|
|
155
|
+
return i;
|
|
156
|
+
});
|
|
157
|
+
const indices = unpackIndices(get(states, 'data', 'longArray')?.value, local.length);
|
|
158
|
+
const bx0 = Math.max(x0, cx * 16), bx1 = Math.min(x1, cx * 16 + 15);
|
|
159
|
+
const bz0 = Math.max(z0, cz * 16), bz1 = Math.min(z1, cz * 16 + 15);
|
|
160
|
+
const by0 = Math.max(y0, sy * 16), by1 = Math.min(y1, sy * 16 + 15);
|
|
161
|
+
for (let y = by0; y <= by1; y++) {
|
|
162
|
+
for (let z = bz0; z <= bz1; z++) {
|
|
163
|
+
for (let x = bx0; x <= bx1; x++) {
|
|
164
|
+
const li = (((y & 15) * 16 + (z & 15)) * 16) + (x & 15);
|
|
165
|
+
blocks[(x - x0) + (z - z0) * w + (y - y0) * w * l] = local[indices[li]];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const bes = get(chunk, 'block_entities', 'list');
|
|
171
|
+
for (const be of bes?.value ?? []) {
|
|
172
|
+
if (be.type !== 'compound')
|
|
173
|
+
continue;
|
|
174
|
+
const bx = num(be, 'x'), by = num(be, 'y'), bz = num(be, 'z');
|
|
175
|
+
const id = get(be, 'id', 'string')?.value;
|
|
176
|
+
if (bx === undefined || by === undefined || bz === undefined || !id)
|
|
177
|
+
continue;
|
|
178
|
+
if (bx < x0 || bx > x1 || by < y0 || by > y1 || bz < z0 || bz > z1)
|
|
179
|
+
continue;
|
|
180
|
+
const data = nbt.compound(new Map(be.value));
|
|
181
|
+
for (const k of ['id', 'x', 'y', 'z', 'keepPacked'])
|
|
182
|
+
data.value.delete(k);
|
|
183
|
+
const pos = [bx - x0, by - y0, bz - z0];
|
|
184
|
+
blockEntities.push({ pos, id, data });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
schematic: { width: w, height: h, length: l, offset: [0, 0, 0], dataVersion, palette, blocks, blockEntities },
|
|
190
|
+
missingChunks,
|
|
191
|
+
};
|
|
192
|
+
}
|