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,103 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
// Keeps every file the tools touch inside one folder. Checks the text of the path first
|
|
4
|
+
// (no absolute paths, no "..", no drive letters or Windows device names), then resolves symlinks,
|
|
5
|
+
// junctions and Windows short names (PROGRA~1) of everything that already exists and checks again.
|
|
6
|
+
// The path it returns is that resolved one, so later checks on names see the real names.
|
|
7
|
+
export class PathError extends Error {
|
|
8
|
+
}
|
|
9
|
+
const WINDOWS_DEVICE = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)(\..*)?$/i;
|
|
10
|
+
const caseInsensitive = process.platform === 'win32' || process.platform === 'darwin';
|
|
11
|
+
// Hidden folders ignore letter case on every system: Linux folders can be case-folded too.
|
|
12
|
+
function fold(p) {
|
|
13
|
+
return p.toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
function norm(p) {
|
|
16
|
+
return caseInsensitive ? p.toLowerCase() : p;
|
|
17
|
+
}
|
|
18
|
+
/** True when child is root itself or inside it. Both must be absolute and resolved. */
|
|
19
|
+
export function isInside(root, child) {
|
|
20
|
+
const rel = relative(norm(root), norm(child));
|
|
21
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
22
|
+
}
|
|
23
|
+
function realOrSelf(p) {
|
|
24
|
+
try {
|
|
25
|
+
return realpathSync.native(p);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return p;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Real path of the deepest part of p that exists, with the missing rest appended. */
|
|
32
|
+
function realDeepest(p) {
|
|
33
|
+
let existing = p;
|
|
34
|
+
const rest = [];
|
|
35
|
+
while (!existsSync(existing)) {
|
|
36
|
+
const parent = dirname(existing);
|
|
37
|
+
if (parent === existing)
|
|
38
|
+
break;
|
|
39
|
+
rest.unshift(existing.slice(parent.length).replace(/^[\\/]+/, ''));
|
|
40
|
+
existing = parent;
|
|
41
|
+
}
|
|
42
|
+
return rest.length ? join(realOrSelf(existing), ...rest) : realOrSelf(existing);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolves a path given relative to root. Throws PathError if it is absolute, uses "..",
|
|
46
|
+
* names a Windows device or stream, or ends up outside root after following links. Returns the
|
|
47
|
+
* path with links and short names resolved as far as it exists.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveInside(root, userPath, options = {}) {
|
|
50
|
+
if (typeof userPath !== 'string' || userPath.trim() === '')
|
|
51
|
+
throw new PathError('Path is empty');
|
|
52
|
+
if (userPath.includes('\0'))
|
|
53
|
+
throw new PathError('Path contains a NUL character');
|
|
54
|
+
const cleaned = userPath.replace(/\\/g, '/');
|
|
55
|
+
if (cleaned.startsWith('/') || /^[A-Za-z]:/.test(cleaned) || isAbsolute(userPath)) {
|
|
56
|
+
throw new PathError(`Use a path relative to the server folder, not an absolute path: ${userPath}`);
|
|
57
|
+
}
|
|
58
|
+
const parts = cleaned.split('/').filter((s) => s !== '' && s !== '.');
|
|
59
|
+
if (parts.length === 0)
|
|
60
|
+
return realDeepest(resolve(root));
|
|
61
|
+
for (const part of parts) {
|
|
62
|
+
if (part === '..')
|
|
63
|
+
throw new PathError(`".." is not allowed in paths: ${userPath}`);
|
|
64
|
+
if (part.includes(':'))
|
|
65
|
+
throw new PathError(`":" is not allowed in paths: ${userPath}`);
|
|
66
|
+
if (WINDOWS_DEVICE.test(part))
|
|
67
|
+
throw new PathError(`"${part}" is a reserved name on Windows`);
|
|
68
|
+
if (/[. ]$/.test(part))
|
|
69
|
+
throw new PathError(`Path parts may not end with a dot or space: ${part}`);
|
|
70
|
+
}
|
|
71
|
+
const hidden = (options.hidden ?? []).map(fold);
|
|
72
|
+
if (hidden.includes(fold(parts[0])))
|
|
73
|
+
throw new PathError(`${parts[0]} is the tool's own folder and cannot be read or changed`);
|
|
74
|
+
const rootReal = realDeepest(resolve(root));
|
|
75
|
+
const target = resolve(rootReal, ...parts);
|
|
76
|
+
if (!isInside(rootReal, target))
|
|
77
|
+
throw new PathError(`Path leaves the server folder: ${userPath}`);
|
|
78
|
+
const real = realDeepest(target);
|
|
79
|
+
if (!isInside(rootReal, real))
|
|
80
|
+
throw new PathError(`Path leads outside the server folder through a link: ${userPath}`);
|
|
81
|
+
const first = relative(rootReal, real).split(sep)[0] ?? '';
|
|
82
|
+
if (first !== '' && hidden.includes(fold(first)))
|
|
83
|
+
throw new PathError(`${userPath} leads into the tool's own folder, which cannot be read or changed`);
|
|
84
|
+
return real;
|
|
85
|
+
}
|
|
86
|
+
/** The path relative to root, with forward slashes, for showing to the agent. */
|
|
87
|
+
export function shown(root, p) {
|
|
88
|
+
return relative(realDeepest(resolve(root)), p).split(sep).join('/');
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Checks that an absolute folder is one of the allowed folders or inside one, after following
|
|
92
|
+
* links. Returns the resolved folder.
|
|
93
|
+
*/
|
|
94
|
+
export function checkAllowedFolder(allowed, folder) {
|
|
95
|
+
if (!isAbsolute(folder))
|
|
96
|
+
throw new PathError(`Give the server folder as a full path: ${folder}`);
|
|
97
|
+
const real = realDeepest(resolve(folder));
|
|
98
|
+
for (const root of allowed) {
|
|
99
|
+
if (isInside(realDeepest(resolve(root)), real))
|
|
100
|
+
return real;
|
|
101
|
+
}
|
|
102
|
+
throw new PathError(`${folder} is not inside an allowed server folder. Allowed: ${allowed.join(', ')}. The person can add folders under "serverFolders" in the config file.`);
|
|
103
|
+
}
|
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { BlockError, checkBlockFilter, checkBlockState, checkSnbt, COORD_LIMIT, MAX_COMMAND_BYTES } from './mc/blocks.js';
|
|
3
|
+
// Input shapes shared by the tools. Everything is checked here before any command is built.
|
|
4
|
+
const passes = (fn) => (s) => {
|
|
5
|
+
try {
|
|
6
|
+
fn(s);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch (e) {
|
|
10
|
+
if (e instanceof BlockError)
|
|
11
|
+
return false;
|
|
12
|
+
throw e;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
export const coord = z.number().int('Coordinates are whole numbers').min(-COORD_LIMIT).max(COORD_LIMIT);
|
|
16
|
+
export const vec3 = z.tuple([coord, coord, coord]).describe('[x, y, z]');
|
|
17
|
+
export const blockState = z
|
|
18
|
+
.string()
|
|
19
|
+
.max(300)
|
|
20
|
+
.refine(passes(checkBlockState), 'Not a block state. Use namespace:name[property=value,...], for example minecraft:oak_stairs[facing=north]')
|
|
21
|
+
.describe('Block state, for example "minecraft:stone" or "minecraft:oak_stairs[facing=north,half=top]"');
|
|
22
|
+
export const blockFilter = z
|
|
23
|
+
.string()
|
|
24
|
+
.max(300)
|
|
25
|
+
.refine(passes(checkBlockFilter), 'Use a block state or a block tag such as #minecraft:logs')
|
|
26
|
+
.describe('Only replace this block (state or #tag)');
|
|
27
|
+
export const snbt = z
|
|
28
|
+
.string()
|
|
29
|
+
.max(MAX_COMMAND_BYTES)
|
|
30
|
+
.refine(passes(checkSnbt), 'Block data must be one SNBT compound {...} on one line')
|
|
31
|
+
.describe('Optional block entity data in SNBT, for example {Items:[...]} for a chest');
|
|
32
|
+
export const dimension = z
|
|
33
|
+
.enum(['overworld', 'the_nether', 'the_end'])
|
|
34
|
+
.default('overworld')
|
|
35
|
+
.describe('Dimension. Default overworld.');
|
|
36
|
+
export const skipBackup = z
|
|
37
|
+
.boolean()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe('Only for areas too big for an undo snapshot, after backup_world. Default false.');
|
|
40
|
+
export const confirm = z
|
|
41
|
+
.boolean()
|
|
42
|
+
.optional()
|
|
43
|
+
.describe('Set true only after the person agreed to this change (needed while confirmDestructive is on).');
|
|
44
|
+
export const serverRelPath = z.string().min(1).max(512).describe('Path inside the test server folder, for example "world/datapacks/my_pack/pack.mcmeta"');
|
|
45
|
+
export const schemPath = serverRelPath
|
|
46
|
+
.refine((p) => p.toLowerCase().endsWith('.schem'), 'File name must end with .schem')
|
|
47
|
+
.describe('Path inside the test server folder ending in .schem, for example "schematics/house.schem"');
|
|
48
|
+
export const command = z
|
|
49
|
+
.string()
|
|
50
|
+
.min(1)
|
|
51
|
+
.max(MAX_COMMAND_BYTES)
|
|
52
|
+
.refine((c) => !/[\r\n\0]/.test(c), 'One command, on one line')
|
|
53
|
+
.describe('A server command without the leading slash, for example "time set day"');
|
|
54
|
+
export const includeEntities = z
|
|
55
|
+
.boolean()
|
|
56
|
+
.optional()
|
|
57
|
+
.describe('Include entities (item frames, paintings, armor stands, mobs, minecarts, boats). Default: includeEntities in the config (on).');
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { gzipSync } from 'node:zlib';
|
|
2
|
+
import { get, nbt, NbtError, need, num, unpackNbt, writeNbt } from '../nbt/nbt.js';
|
|
3
|
+
export class SchematicError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export function indexOf(s, x, y, z) {
|
|
6
|
+
return x + z * s.width + y * s.width * s.length;
|
|
7
|
+
}
|
|
8
|
+
export function blockAt(s, x, y, z) {
|
|
9
|
+
return s.palette[s.blocks[indexOf(s, x, y, z)]];
|
|
10
|
+
}
|
|
11
|
+
// ---- varints (unsigned LEB128, as the spec uses) ----
|
|
12
|
+
export function encodeVarints(values) {
|
|
13
|
+
const out = [];
|
|
14
|
+
for (let i = 0; i < values.length; i++) {
|
|
15
|
+
let v = values[i];
|
|
16
|
+
while (v >= 0x80) {
|
|
17
|
+
out.push((v & 0x7f) | 0x80);
|
|
18
|
+
v >>>= 7;
|
|
19
|
+
}
|
|
20
|
+
out.push(v);
|
|
21
|
+
}
|
|
22
|
+
return Int8Array.from(out, (b) => (b > 127 ? b - 256 : b));
|
|
23
|
+
}
|
|
24
|
+
export function decodeVarints(bytes, count) {
|
|
25
|
+
const out = new Uint32Array(count);
|
|
26
|
+
let p = 0;
|
|
27
|
+
for (let i = 0; i < count; i++) {
|
|
28
|
+
let value = 0;
|
|
29
|
+
let shift = 0;
|
|
30
|
+
for (;;) {
|
|
31
|
+
if (p >= bytes.length)
|
|
32
|
+
throw new SchematicError('Block data ends early');
|
|
33
|
+
const b = bytes[p++] & 0xff;
|
|
34
|
+
value |= (b & 0x7f) << shift;
|
|
35
|
+
if ((b & 0x80) === 0)
|
|
36
|
+
break;
|
|
37
|
+
shift += 7;
|
|
38
|
+
if (shift > 28)
|
|
39
|
+
throw new SchematicError('Block data has a varint that is too long');
|
|
40
|
+
}
|
|
41
|
+
out[i] = value >>> 0;
|
|
42
|
+
}
|
|
43
|
+
if (p !== bytes.length)
|
|
44
|
+
throw new SchematicError('Block data has extra bytes after the last block');
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
// ---- reading ----
|
|
48
|
+
function dims(root) {
|
|
49
|
+
const read = (k) => {
|
|
50
|
+
const v = num(root, k);
|
|
51
|
+
if (v === undefined)
|
|
52
|
+
throw new SchematicError(`Schematic has no ${k}`);
|
|
53
|
+
return v & 0xffff;
|
|
54
|
+
};
|
|
55
|
+
return { width: read('Width'), height: read('Height'), length: read('Length') };
|
|
56
|
+
}
|
|
57
|
+
function readPalette(p) {
|
|
58
|
+
const palette = [];
|
|
59
|
+
for (const [state, t] of p.value) {
|
|
60
|
+
if (t.type !== 'int')
|
|
61
|
+
throw new SchematicError('Palette values must be ints');
|
|
62
|
+
if (t.value < 0 || t.value > 1_000_000)
|
|
63
|
+
throw new SchematicError('Palette index out of range');
|
|
64
|
+
if (palette[t.value] !== undefined)
|
|
65
|
+
throw new SchematicError(`Palette index ${t.value} is used twice`);
|
|
66
|
+
palette[t.value] = state;
|
|
67
|
+
}
|
|
68
|
+
return palette;
|
|
69
|
+
}
|
|
70
|
+
function readPos(c, where) {
|
|
71
|
+
const p = get(c, 'Pos', 'intArray');
|
|
72
|
+
if (!p || p.value.length !== 3)
|
|
73
|
+
throw new SchematicError(`${where}: Pos must be 3 ints`);
|
|
74
|
+
return [p.value[0], p.value[1], p.value[2]];
|
|
75
|
+
}
|
|
76
|
+
function readBlockEntities(list, version, size) {
|
|
77
|
+
if (!list || list.type !== 'list')
|
|
78
|
+
return [];
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const t of list.value) {
|
|
81
|
+
if (t.type !== 'compound')
|
|
82
|
+
continue;
|
|
83
|
+
const pos = readPos(t, 'Block entity');
|
|
84
|
+
if (pos.some((v, i) => v < 0 || v >= size[i]))
|
|
85
|
+
throw new SchematicError('Block entity outside the schematic');
|
|
86
|
+
const id = get(t, 'Id', 'string')?.value ?? get(t, 'id', 'string')?.value;
|
|
87
|
+
if (!id)
|
|
88
|
+
throw new SchematicError('Block entity without Id');
|
|
89
|
+
let data;
|
|
90
|
+
const nested = get(t, 'Data', 'compound');
|
|
91
|
+
if (version >= 3 && nested) {
|
|
92
|
+
data = nbt.compound(new Map(nested.value));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
data = nbt.compound(new Map(t.value));
|
|
96
|
+
}
|
|
97
|
+
for (const k of ['Pos', 'Id', 'id', 'x', 'y', 'z'])
|
|
98
|
+
data.value.delete(k);
|
|
99
|
+
out.push({ pos, id, data });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Most bytes a schematic of maxBlocks blocks may take unpacked: room for its block data and plenty of
|
|
105
|
+
* block entities, and far less than a decompression bomb would make.
|
|
106
|
+
*/
|
|
107
|
+
export function schematicByteLimit(maxBlocks) {
|
|
108
|
+
return 64 * 1024 * 1024 + maxBlocks * 16;
|
|
109
|
+
}
|
|
110
|
+
export function readSponge(file, options = {}) {
|
|
111
|
+
let rootTag;
|
|
112
|
+
try {
|
|
113
|
+
rootTag = (options.maxBlocks === undefined ? unpackNbt(file) : unpackNbt(file, schematicByteLimit(options.maxBlocks), 1_000_000 + options.maxBlocks * 4)).tag;
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
throw new SchematicError(`Not a readable schematic: ${e instanceof Error ? e.message : String(e)}`);
|
|
117
|
+
}
|
|
118
|
+
const root = get(rootTag, 'Schematic', 'compound') ?? rootTag;
|
|
119
|
+
const version = num(root, 'Version');
|
|
120
|
+
if (version !== 1 && version !== 2 && version !== 3) {
|
|
121
|
+
throw new SchematicError(`Unsupported schematic version ${version ?? 'missing'} (Sponge versions 2 and 3 are supported)`);
|
|
122
|
+
}
|
|
123
|
+
const { width, height, length } = dims(root);
|
|
124
|
+
const volume = width * height * length;
|
|
125
|
+
if (volume === 0)
|
|
126
|
+
throw new SchematicError('Schematic is empty');
|
|
127
|
+
if (options.maxBlocks !== undefined && volume > options.maxBlocks) {
|
|
128
|
+
throw new SchematicError(`Schematic has ${volume} blocks (${width}x${height}x${length}), over the limit of ${options.maxBlocks}`);
|
|
129
|
+
}
|
|
130
|
+
const off = get(root, 'Offset', 'intArray');
|
|
131
|
+
const offset = off && off.value.length === 3 ? [off.value[0], off.value[1], off.value[2]] : [0, 0, 0];
|
|
132
|
+
const dataVersion = num(root, 'DataVersion') ?? 0;
|
|
133
|
+
let paletteTag;
|
|
134
|
+
let dataTag;
|
|
135
|
+
let entitiesTag;
|
|
136
|
+
try {
|
|
137
|
+
if (version === 3) {
|
|
138
|
+
const blocks = need(root, 'Blocks', 'compound', 'Schematic');
|
|
139
|
+
paletteTag = need(blocks, 'Palette', 'compound', 'Blocks');
|
|
140
|
+
dataTag = need(blocks, 'Data', 'byteArray', 'Blocks').value;
|
|
141
|
+
entitiesTag = blocks.value.get('BlockEntities');
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
paletteTag = need(root, 'Palette', 'compound', 'Schematic');
|
|
145
|
+
dataTag = need(root, 'BlockData', 'byteArray', 'Schematic').value;
|
|
146
|
+
entitiesTag = root.value.get('BlockEntities') ?? root.value.get('TileEntities');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
if (e instanceof NbtError)
|
|
151
|
+
throw new SchematicError(e.message);
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
const palette = readPalette(paletteTag);
|
|
155
|
+
const blocks = decodeVarints(dataTag, volume);
|
|
156
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
157
|
+
if (palette[blocks[i]] === undefined)
|
|
158
|
+
throw new SchematicError(`Block ${i} uses palette index ${blocks[i]} that is not in the palette`);
|
|
159
|
+
}
|
|
160
|
+
const blockEntities = readBlockEntities(entitiesTag, version, [width, height, length]);
|
|
161
|
+
const entities = readEntities(root.value.get('Entities'), version, options.maxEntities);
|
|
162
|
+
const meta = get(root, 'Metadata', 'compound');
|
|
163
|
+
const originTag = meta ? get(get(meta, 'Stonecut', 'compound') ?? nbt.compound({}), 'Origin', 'intArray') : undefined;
|
|
164
|
+
const origin = originTag && originTag.value.length === 3 ? [originTag.value[0], originTag.value[1], originTag.value[2]] : undefined;
|
|
165
|
+
return {
|
|
166
|
+
schematic: { width, height, length, offset, dataVersion, palette, blocks, blockEntities, entities, ...(origin ? { origin } : {}) },
|
|
167
|
+
version: version === 3 ? 3 : 2,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function readEntityPos(c) {
|
|
171
|
+
const p = c.value.get('Pos');
|
|
172
|
+
if (!p || p.type !== 'list' || p.value.length !== 3)
|
|
173
|
+
return null;
|
|
174
|
+
const v = p.value.map((t) => (t.type === 'double' || t.type === 'float' || t.type === 'int' ? t.value : NaN));
|
|
175
|
+
return v.every(Number.isFinite) ? [v[0], v[1], v[2]] : null;
|
|
176
|
+
}
|
|
177
|
+
function readEntities(list, version, max) {
|
|
178
|
+
if (!list || list.type !== 'list')
|
|
179
|
+
return [];
|
|
180
|
+
if (max !== undefined && list.value.length > max)
|
|
181
|
+
throw new SchematicError(`Schematic has ${list.value.length} entities, over the limit of ${max}`);
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const t of list.value) {
|
|
184
|
+
if (t.type !== 'compound')
|
|
185
|
+
continue;
|
|
186
|
+
const pos = readEntityPos(t);
|
|
187
|
+
const id = get(t, 'Id', 'string')?.value ?? get(t, 'id', 'string')?.value;
|
|
188
|
+
if (!pos || !id)
|
|
189
|
+
throw new SchematicError('Entity without Pos or Id');
|
|
190
|
+
const nested = get(t, 'Data', 'compound');
|
|
191
|
+
const data = nbt.compound(new Map(version >= 3 && nested ? nested.value : t.value));
|
|
192
|
+
if (!(version >= 3 && nested))
|
|
193
|
+
for (const k of ['Pos', 'Id'])
|
|
194
|
+
data.value.delete(k);
|
|
195
|
+
data.value.delete('id');
|
|
196
|
+
out.push({ pos, id, data });
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
const posList = (v) => nbt.list('double', v.map((x) => nbt.double(x)));
|
|
201
|
+
// ---- writing ----
|
|
202
|
+
function u16(v) {
|
|
203
|
+
if (!Number.isInteger(v) || v < 1 || v > 0xffff)
|
|
204
|
+
throw new SchematicError('Schematic sides must be 1-65535');
|
|
205
|
+
return nbt.short(v > 0x7fff ? v - 0x10000 : v);
|
|
206
|
+
}
|
|
207
|
+
/** Drops palette entries no block uses and renumbers the rest in order of first use. */
|
|
208
|
+
export function compactPalette(s) {
|
|
209
|
+
const map = new Map();
|
|
210
|
+
const palette = [];
|
|
211
|
+
const blocks = new Uint32Array(s.blocks.length);
|
|
212
|
+
for (let i = 0; i < s.blocks.length; i++) {
|
|
213
|
+
const old = s.blocks[i];
|
|
214
|
+
let next = map.get(old);
|
|
215
|
+
if (next === undefined) {
|
|
216
|
+
next = palette.length;
|
|
217
|
+
map.set(old, next);
|
|
218
|
+
palette.push(s.palette[old]);
|
|
219
|
+
}
|
|
220
|
+
blocks[i] = next;
|
|
221
|
+
}
|
|
222
|
+
return { ...s, palette, blocks };
|
|
223
|
+
}
|
|
224
|
+
export function writeSponge(input, version = 3) {
|
|
225
|
+
const s = compactPalette(input);
|
|
226
|
+
if (s.blocks.length !== s.width * s.height * s.length)
|
|
227
|
+
throw new SchematicError('Block count does not match the size');
|
|
228
|
+
const palette = nbt.compound(new Map(s.palette.map((state, i) => [state, nbt.int(i)])));
|
|
229
|
+
const data = nbt.byteArray(encodeVarints(s.blocks));
|
|
230
|
+
const common = [
|
|
231
|
+
['Version', nbt.int(version)],
|
|
232
|
+
['DataVersion', nbt.int(s.dataVersion)],
|
|
233
|
+
[
|
|
234
|
+
'Metadata',
|
|
235
|
+
nbt.compound({ Date: nbt.long(BigInt(Date.now())), ...(s.origin ? { Stonecut: nbt.compound({ Origin: nbt.intArray(s.origin) }) } : {}) }),
|
|
236
|
+
],
|
|
237
|
+
['Width', u16(s.width)],
|
|
238
|
+
['Height', u16(s.height)],
|
|
239
|
+
['Length', u16(s.length)],
|
|
240
|
+
['Offset', nbt.intArray(s.offset)],
|
|
241
|
+
];
|
|
242
|
+
if (version === 2) {
|
|
243
|
+
const entities = s.blockEntities.map((be) => nbt.compound(new Map([['Pos', nbt.intArray(be.pos)], ['Id', nbt.string(be.id)], ...be.data.value])));
|
|
244
|
+
const root = nbt.compound(new Map([
|
|
245
|
+
...common,
|
|
246
|
+
['PaletteMax', nbt.int(s.palette.length)],
|
|
247
|
+
['Palette', palette],
|
|
248
|
+
['BlockData', data],
|
|
249
|
+
['BlockEntities', nbt.list('compound', entities)],
|
|
250
|
+
// Version 2 keeps entity data flat next to Pos and Id, so the world position inside the data is left out.
|
|
251
|
+
['Entities', nbt.list('compound', (s.entities ?? []).map((e) => nbt.compound(new Map([
|
|
252
|
+
...[...e.data.value].filter(([k]) => k !== 'Pos' && k !== 'Id' && k !== 'id'),
|
|
253
|
+
['Pos', posList(e.pos)],
|
|
254
|
+
['Id', nbt.string(e.id)],
|
|
255
|
+
]))))],
|
|
256
|
+
]));
|
|
257
|
+
return gzipSync(writeNbt('Schematic', root));
|
|
258
|
+
}
|
|
259
|
+
const entities = s.blockEntities.map((be) => nbt.compound({ Pos: nbt.intArray(be.pos), Id: nbt.string(be.id), Data: be.data }));
|
|
260
|
+
const blocks = nbt.compound({ Palette: palette, Data: data, BlockEntities: nbt.list('compound', entities) });
|
|
261
|
+
const mobs = (s.entities ?? []).map((e) => nbt.compound({ Pos: posList(e.pos), Id: nbt.string(e.id), Data: e.data }));
|
|
262
|
+
const inner = nbt.compound(new Map([...common, ['Blocks', blocks], ['Entities', nbt.list('compound', mobs)]]));
|
|
263
|
+
return gzipSync(writeNbt('', nbt.compound({ Schematic: inner })));
|
|
264
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { GUIDE } from './guide.js';
|
|
3
|
+
import { backupTools } from './tools/backup.js';
|
|
4
|
+
import { blockTools } from './tools/blocks.js';
|
|
5
|
+
import { registerTools } from './tools/define.js';
|
|
6
|
+
import { fileTools } from './tools/files.js';
|
|
7
|
+
import { schematicTools } from './tools/schematic.js';
|
|
8
|
+
import { serverTools } from './tools/server.js';
|
|
9
|
+
import { setupTools } from './tools/setup.js';
|
|
10
|
+
import { SERVER_NAME, VERSION } from './version.js';
|
|
11
|
+
export const ALL_TOOLS = [...setupTools, ...serverTools, ...blockTools, ...schematicTools, ...backupTools, ...fileTools];
|
|
12
|
+
export function createServer(ctx) {
|
|
13
|
+
const server = new McpServer({ name: SERVER_NAME, version: VERSION }, { instructions: GUIDE });
|
|
14
|
+
registerTools(server, ctx, ALL_TOOLS);
|
|
15
|
+
return server;
|
|
16
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { backupWorld, restoreWorld } from '../ops/backup.js';
|
|
3
|
+
import { confirm } from '../schemas.js';
|
|
4
|
+
import { tool } from './define.js';
|
|
5
|
+
export const backupTools = [
|
|
6
|
+
tool({
|
|
7
|
+
name: 'backup_world',
|
|
8
|
+
title: 'Back up the whole world',
|
|
9
|
+
description: "Copies the whole world folder into the server's own backup folder (the server pauses saving while it copies, so the copy is consistent). Do this before big changes, for example before importing a large map or anything too big for an undo snapshot. The tool never deletes backups.",
|
|
10
|
+
input: {},
|
|
11
|
+
readOnly: false,
|
|
12
|
+
destructive: false,
|
|
13
|
+
idempotent: false,
|
|
14
|
+
run: async (_a, ctx) => {
|
|
15
|
+
const s = ctx.server();
|
|
16
|
+
return backupWorld(s, (await s.status()).rcon);
|
|
17
|
+
},
|
|
18
|
+
}),
|
|
19
|
+
tool({
|
|
20
|
+
name: 'restore_world',
|
|
21
|
+
title: 'Restore a world backup',
|
|
22
|
+
description: 'Replaces the world with a backup from backup_world. The server must be stopped. The current world is not deleted: it is moved into the backup folder as replaced-<time>. status lists the backups.',
|
|
23
|
+
input: {
|
|
24
|
+
name: z.string().min(1).max(80).describe('Backup name from status, for example world-2026-01-31T12-00-00-000'),
|
|
25
|
+
confirm,
|
|
26
|
+
},
|
|
27
|
+
readOnly: false,
|
|
28
|
+
destructive: true,
|
|
29
|
+
idempotent: false,
|
|
30
|
+
needsOk: (a) => `replace the current world with the backup ${a.name} (the current world is kept as a backup)`,
|
|
31
|
+
run: async (a, ctx) => {
|
|
32
|
+
const s = ctx.server();
|
|
33
|
+
if ((await s.status()).running)
|
|
34
|
+
throw new Error('Stop the server first (stop_server), then restore.');
|
|
35
|
+
return restoreWorld(s, a.name);
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
];
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { checkBlockFilter, checkBlockState, checkSnbt } from '../mc/blocks.js';
|
|
3
|
+
import { boxVolume, makeBox } from '../mc/box.js';
|
|
4
|
+
import { describeArea, describeEntities } from '../ops/readout.js';
|
|
5
|
+
import { blockFilter, blockState, confirm, dimension, includeEntities, skipBackup, snbt, vec3 } from '../schemas.js';
|
|
6
|
+
import { tool } from './define.js';
|
|
7
|
+
const area = { from: vec3.describe('One corner [x, y, z]'), to: vec3.describe('The opposite corner [x, y, z], inclusive') };
|
|
8
|
+
export const blockTools = [
|
|
9
|
+
tool({
|
|
10
|
+
name: 'place_blocks',
|
|
11
|
+
title: 'Place blocks',
|
|
12
|
+
description: 'Places single blocks, up to 10000 per call. The tool packs neighbouring equal blocks into /fill commands, places torches, doors, plants, redstone and liquids after the blocks that hold them, and snapshots the area first so undo can put it back. Later entries at the same spot win.',
|
|
13
|
+
input: {
|
|
14
|
+
blocks: z
|
|
15
|
+
.array(z.object({ pos: vec3, block: blockState, data: snbt.optional() }))
|
|
16
|
+
.min(1)
|
|
17
|
+
.max(10_000)
|
|
18
|
+
.describe('Blocks to place: [{pos: [x, y, z], block: "minecraft:stone"}, ...]'),
|
|
19
|
+
dimension,
|
|
20
|
+
skip_backup: skipBackup,
|
|
21
|
+
},
|
|
22
|
+
readOnly: false,
|
|
23
|
+
destructive: true,
|
|
24
|
+
idempotent: true,
|
|
25
|
+
run: async (a, ctx) => ctx.world().place(a.blocks.map((b) => ({ pos: b.pos, state: checkBlockState(b.block), ...(b.data ? { snbt: checkSnbt(b.data) } : {}) })), { dimension: a.dimension, ...(a.skip_backup ? { skipBackup: true } : {}) }),
|
|
26
|
+
}),
|
|
27
|
+
tool({
|
|
28
|
+
name: 'fill_blocks',
|
|
29
|
+
title: 'Fill boxes',
|
|
30
|
+
description: 'Fills one or more boxes with a block, in order. Boxes over 32768 blocks are split for you. mode: replace (default), hollow (walls, inside cleared), outline (walls only, inside kept), keep (only air is filled), destroy (drops items). replace_only limits it to one block or #tag. Snapshots the area first for undo.',
|
|
31
|
+
input: {
|
|
32
|
+
fills: z
|
|
33
|
+
.array(z.object({
|
|
34
|
+
...area,
|
|
35
|
+
block: blockState,
|
|
36
|
+
mode: z.enum(['replace', 'hollow', 'outline', 'keep', 'destroy']).optional(),
|
|
37
|
+
replace_only: blockFilter.optional(),
|
|
38
|
+
}))
|
|
39
|
+
.min(1)
|
|
40
|
+
.max(256),
|
|
41
|
+
dimension,
|
|
42
|
+
skip_backup: skipBackup,
|
|
43
|
+
},
|
|
44
|
+
readOnly: false,
|
|
45
|
+
destructive: true,
|
|
46
|
+
idempotent: true,
|
|
47
|
+
run: async (a, ctx) => ctx.world().fill(a.fills.map((f) => ({
|
|
48
|
+
box: makeBox(f.from, f.to),
|
|
49
|
+
state: checkBlockState(f.block),
|
|
50
|
+
...(f.mode ? { mode: f.mode } : {}),
|
|
51
|
+
...(f.replace_only ? { filter: checkBlockFilter(f.replace_only) } : {}),
|
|
52
|
+
})), { dimension: a.dimension, ...(a.skip_backup ? { skipBackup: true } : {}) }),
|
|
53
|
+
}),
|
|
54
|
+
tool({
|
|
55
|
+
name: 'copy_blocks',
|
|
56
|
+
title: 'Copy an area',
|
|
57
|
+
description: 'Copies the box from..to so its lowest corner lands at destination (with /clone). skip_air keeps what is already at the destination where the source is air. Snapshots the destination first for undo.',
|
|
58
|
+
input: {
|
|
59
|
+
...area,
|
|
60
|
+
destination: vec3.describe('Where the lowest corner of the copy goes [x, y, z]'),
|
|
61
|
+
skip_air: z.boolean().optional().describe('Do not copy air. Default false.'),
|
|
62
|
+
dimension,
|
|
63
|
+
skip_backup: skipBackup,
|
|
64
|
+
},
|
|
65
|
+
readOnly: false,
|
|
66
|
+
destructive: true,
|
|
67
|
+
idempotent: true,
|
|
68
|
+
run: async (a, ctx) => ctx.world().copy(makeBox(a.from, a.to), a.destination, a.skip_air ?? false, { dimension: a.dimension, ...(a.skip_backup ? { skipBackup: true } : {}) }),
|
|
69
|
+
}),
|
|
70
|
+
tool({
|
|
71
|
+
name: 'clear_blocks',
|
|
72
|
+
title: 'Clear an area to air',
|
|
73
|
+
description: 'Sets every block in the box to air (containers are emptied, nothing drops). With include_entities (default on) it also removes the entities in the box first, without drops; players are never touched. Snapshots the area, entities included, for undo.',
|
|
74
|
+
input: { ...area, include_entities: includeEntities, dimension, confirm, skip_backup: skipBackup },
|
|
75
|
+
readOnly: false,
|
|
76
|
+
destructive: true,
|
|
77
|
+
idempotent: true,
|
|
78
|
+
needsOk: (a) => `clear ${boxVolume(makeBox(a.from, a.to))} blocks from ${a.from.join(' ')} to ${a.to.join(' ')}`,
|
|
79
|
+
run: async (a, ctx) => ctx.world().clear(makeBox(a.from, a.to), { dimension: a.dimension, ...(a.skip_backup ? { skipBackup: true } : {}) }, a.include_entities ?? ctx.config.includeEntities),
|
|
80
|
+
}),
|
|
81
|
+
tool({
|
|
82
|
+
name: 'read_entities',
|
|
83
|
+
title: 'List the entities in an area',
|
|
84
|
+
description: 'Lists the entities whose position is inside the box: item frames, paintings, armor stands, mobs, minecarts, boats, items. For each: id, position, rotation, UUID, custom name, what it is tied to (lead) and its data as SNBT (shortened unless full: true). Players are not listed. The server saves first and the tool reads the saved entity files, so it changes nothing. Limited to maxEntities.',
|
|
85
|
+
input: {
|
|
86
|
+
...area,
|
|
87
|
+
full: z.boolean().optional().describe('Give each entity its complete data. Default false (common fields only, shortened).'),
|
|
88
|
+
dimension,
|
|
89
|
+
},
|
|
90
|
+
readOnly: true,
|
|
91
|
+
destructive: false,
|
|
92
|
+
idempotent: true,
|
|
93
|
+
run: async (a, ctx) => {
|
|
94
|
+
const box = makeBox(a.from, a.to);
|
|
95
|
+
const list = await ctx.world().entitiesIn(box, a.dimension);
|
|
96
|
+
ctx.world().checkEntities(list.length, 'The area has');
|
|
97
|
+
return describeEntities(list, box, a.full ?? false);
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
tool({
|
|
101
|
+
name: 'read_blocks',
|
|
102
|
+
title: 'Read the blocks in an area',
|
|
103
|
+
description: 'Returns the blocks in a box with their full states, block entity ids and a count per block. The server saves the world first and the tool reads the saved chunks, so it changes nothing. Limited to maxReadBlocks per call (32768 by default, for example 32x32x32); read big builds in parts. format "layers" (default) is compact, one line per row; "list" gives every non-air block as [x, y, z, state].',
|
|
104
|
+
input: {
|
|
105
|
+
...area,
|
|
106
|
+
format: z.enum(['layers', 'list']).default('layers'),
|
|
107
|
+
dimension,
|
|
108
|
+
},
|
|
109
|
+
readOnly: true,
|
|
110
|
+
destructive: false,
|
|
111
|
+
idempotent: true,
|
|
112
|
+
run: async (a, ctx) => {
|
|
113
|
+
const box = makeBox(a.from, a.to);
|
|
114
|
+
const v = boxVolume(box);
|
|
115
|
+
if (v > ctx.config.maxReadBlocks) {
|
|
116
|
+
throw new Error(`That area is ${v} blocks; read_blocks reads at most ${ctx.config.maxReadBlocks} per call (maxReadBlocks in the config). Read it in smaller boxes.`);
|
|
117
|
+
}
|
|
118
|
+
return describeArea(await ctx.world().read(box, a.dimension), box, a.format);
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
tool({
|
|
122
|
+
name: 'undo',
|
|
123
|
+
title: 'Undo the last change',
|
|
124
|
+
description: 'Puts back the area changed by the most recent change (place, fill, copy, clear, import, or run_command with backup_area), from the snapshot taken before it: blocks, and entities when the snapshot has them (entities added since are removed, removed ones are summoned again). Call again to go further back; status lists what can be undone.',
|
|
125
|
+
input: { confirm },
|
|
126
|
+
readOnly: false,
|
|
127
|
+
destructive: true,
|
|
128
|
+
idempotent: false,
|
|
129
|
+
needsOk: (_a, ctx) => {
|
|
130
|
+
const last = ctx.world().undo.latest();
|
|
131
|
+
return last ? `undo "${last.entry.label}" (${last.entry.blocks} blocks from ${last.entry.box.min.join(' ')} to ${last.entry.box.max.join(' ')})` : null;
|
|
132
|
+
},
|
|
133
|
+
run: async (_a, ctx) => {
|
|
134
|
+
const r = await ctx.world().undoLast();
|
|
135
|
+
if (!r)
|
|
136
|
+
throw new Error('Nothing to undo');
|
|
137
|
+
return r;
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { errorResult, textResult } from '../content.js';
|
|
2
|
+
export function tool(spec) {
|
|
3
|
+
return spec;
|
|
4
|
+
}
|
|
5
|
+
export class NeedsOkError extends Error {
|
|
6
|
+
}
|
|
7
|
+
export async function runTool(ctx, spec, args) {
|
|
8
|
+
try {
|
|
9
|
+
return await ctx.serial(async () => {
|
|
10
|
+
if (spec.needsOk && ctx.config.confirmDestructive && args.confirm !== true) {
|
|
11
|
+
const what = spec.needsOk(args, ctx);
|
|
12
|
+
if (what) {
|
|
13
|
+
throw new NeedsOkError(`Needs the person's OK first: ${what}. Tell them what will change, and if they agree call ${spec.name} again with confirm: true. (confirmDestructive is on in the config.)`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return textResult(await spec.run(args, ctx));
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
return errorResult(e);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function annotations(spec) {
|
|
24
|
+
return {
|
|
25
|
+
title: spec.title,
|
|
26
|
+
readOnlyHint: spec.readOnly,
|
|
27
|
+
destructiveHint: spec.readOnly ? false : spec.destructive,
|
|
28
|
+
idempotentHint: spec.readOnly ? true : spec.idempotent,
|
|
29
|
+
openWorldHint: spec.openWorld ?? false,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function registerTools(server, ctx, specs) {
|
|
33
|
+
for (const spec of specs) {
|
|
34
|
+
const config = { title: spec.title, description: spec.description, inputSchema: spec.input, annotations: annotations(spec) };
|
|
35
|
+
server.registerTool(spec.name, config, (args) => runTool(ctx, spec, args));
|
|
36
|
+
}
|
|
37
|
+
}
|