stonecut-map-builder 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +86 -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 +83 -0
- package/package.json +54 -4
- package/skills/map-builder/SKILL.md +131 -0
package/dist/nbt/nbt.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { gunzipSync, gzipSync, inflateSync } from 'node:zlib';
|
|
2
|
+
const IDS = [
|
|
3
|
+
'end', 'byte', 'short', 'int', 'long', 'float', 'double', 'byteArray', 'string', 'list', 'compound', 'intArray', 'longArray',
|
|
4
|
+
];
|
|
5
|
+
const MAX_DEPTH = 512;
|
|
6
|
+
export const nbt = {
|
|
7
|
+
byte: (value) => ({ type: 'byte', value }),
|
|
8
|
+
short: (value) => ({ type: 'short', value }),
|
|
9
|
+
int: (value) => ({ type: 'int', value }),
|
|
10
|
+
long: (value) => ({ type: 'long', value }),
|
|
11
|
+
float: (value) => ({ type: 'float', value }),
|
|
12
|
+
double: (value) => ({ type: 'double', value }),
|
|
13
|
+
string: (value) => ({ type: 'string', value }),
|
|
14
|
+
byteArray: (value) => ({ type: 'byteArray', value }),
|
|
15
|
+
intArray: (value) => ({ type: 'intArray', value: Int32Array.from(value) }),
|
|
16
|
+
longArray: (value) => ({ type: 'longArray', value }),
|
|
17
|
+
list: (itemType, value) => ({ type: 'list', itemType: value.length ? value[0].type : itemType, value }),
|
|
18
|
+
compound: (entries) => ({
|
|
19
|
+
type: 'compound',
|
|
20
|
+
value: entries instanceof Map ? entries : new Map(Object.entries(entries)),
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
export class NbtError extends Error {
|
|
24
|
+
}
|
|
25
|
+
// ---- modified UTF-8 ----
|
|
26
|
+
export function encodeModifiedUtf8(s) {
|
|
27
|
+
const out = [];
|
|
28
|
+
for (let i = 0; i < s.length; i++) {
|
|
29
|
+
const c = s.charCodeAt(i);
|
|
30
|
+
if (c !== 0 && c < 0x80)
|
|
31
|
+
out.push(c);
|
|
32
|
+
else if (c < 0x800)
|
|
33
|
+
out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
|
|
34
|
+
else
|
|
35
|
+
out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
|
|
36
|
+
}
|
|
37
|
+
return Buffer.from(out);
|
|
38
|
+
}
|
|
39
|
+
export function decodeModifiedUtf8(b) {
|
|
40
|
+
let s = '';
|
|
41
|
+
for (let i = 0; i < b.length;) {
|
|
42
|
+
const a = b[i];
|
|
43
|
+
if (a < 0x80) {
|
|
44
|
+
s += String.fromCharCode(a);
|
|
45
|
+
i += 1;
|
|
46
|
+
}
|
|
47
|
+
else if ((a & 0xe0) === 0xc0) {
|
|
48
|
+
s += String.fromCharCode(((a & 0x1f) << 6) | (b[i + 1] & 0x3f));
|
|
49
|
+
i += 2;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
s += String.fromCharCode(((a & 0x0f) << 12) | ((b[i + 1] & 0x3f) << 6) | (b[i + 2] & 0x3f));
|
|
53
|
+
i += 3;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return s;
|
|
57
|
+
}
|
|
58
|
+
// ---- reading ----
|
|
59
|
+
class Reader {
|
|
60
|
+
buf;
|
|
61
|
+
tagsLeft;
|
|
62
|
+
pos = 0;
|
|
63
|
+
/** tagsLeft limits the tags read: millions of tiny tags take far more memory than the bytes they come from. */
|
|
64
|
+
constructor(buf, tagsLeft) {
|
|
65
|
+
this.buf = buf;
|
|
66
|
+
this.tagsLeft = tagsLeft;
|
|
67
|
+
}
|
|
68
|
+
need(n) {
|
|
69
|
+
if (n < 0 || this.pos + n > this.buf.length)
|
|
70
|
+
throw new NbtError('NBT data ends too early');
|
|
71
|
+
}
|
|
72
|
+
u8() { this.need(1); return this.buf.readUInt8(this.pos++); }
|
|
73
|
+
i8() { this.need(1); return this.buf.readInt8(this.pos++); }
|
|
74
|
+
i16() { this.need(2); const v = this.buf.readInt16BE(this.pos); this.pos += 2; return v; }
|
|
75
|
+
u16() { this.need(2); const v = this.buf.readUInt16BE(this.pos); this.pos += 2; return v; }
|
|
76
|
+
i32() { this.need(4); const v = this.buf.readInt32BE(this.pos); this.pos += 4; return v; }
|
|
77
|
+
i64() { this.need(8); const v = this.buf.readBigInt64BE(this.pos); this.pos += 8; return v; }
|
|
78
|
+
f32() { this.need(4); const v = this.buf.readFloatBE(this.pos); this.pos += 4; return v; }
|
|
79
|
+
f64() { this.need(8); const v = this.buf.readDoubleBE(this.pos); this.pos += 8; return v; }
|
|
80
|
+
str() {
|
|
81
|
+
const n = this.u16();
|
|
82
|
+
this.need(n);
|
|
83
|
+
const s = decodeModifiedUtf8(this.buf.subarray(this.pos, this.pos + n));
|
|
84
|
+
this.pos += n;
|
|
85
|
+
return s;
|
|
86
|
+
}
|
|
87
|
+
count(width) {
|
|
88
|
+
const n = this.i32();
|
|
89
|
+
if (n < 0)
|
|
90
|
+
throw new NbtError('Negative NBT array length');
|
|
91
|
+
this.need(n * width);
|
|
92
|
+
return n;
|
|
93
|
+
}
|
|
94
|
+
payload(type, depth) {
|
|
95
|
+
if (depth > MAX_DEPTH)
|
|
96
|
+
throw new NbtError('NBT nested too deeply');
|
|
97
|
+
if (--this.tagsLeft < 0)
|
|
98
|
+
throw new NbtError('NBT data has too many tags');
|
|
99
|
+
switch (type) {
|
|
100
|
+
case 'byte': return { type, value: this.i8() };
|
|
101
|
+
case 'short': return { type, value: this.i16() };
|
|
102
|
+
case 'int': return { type, value: this.i32() };
|
|
103
|
+
case 'long': return { type, value: this.i64() };
|
|
104
|
+
case 'float': return { type, value: this.f32() };
|
|
105
|
+
case 'double': return { type, value: this.f64() };
|
|
106
|
+
case 'string': return { type, value: this.str() };
|
|
107
|
+
case 'byteArray': {
|
|
108
|
+
const n = this.count(1);
|
|
109
|
+
const value = new Int8Array(n);
|
|
110
|
+
for (let i = 0; i < n; i++)
|
|
111
|
+
value[i] = this.i8();
|
|
112
|
+
return { type, value };
|
|
113
|
+
}
|
|
114
|
+
case 'intArray': {
|
|
115
|
+
const n = this.count(4);
|
|
116
|
+
const value = new Int32Array(n);
|
|
117
|
+
for (let i = 0; i < n; i++)
|
|
118
|
+
value[i] = this.i32();
|
|
119
|
+
return { type, value };
|
|
120
|
+
}
|
|
121
|
+
case 'longArray': {
|
|
122
|
+
const n = this.count(8);
|
|
123
|
+
const value = new BigInt64Array(n);
|
|
124
|
+
for (let i = 0; i < n; i++)
|
|
125
|
+
value[i] = this.i64();
|
|
126
|
+
return { type, value };
|
|
127
|
+
}
|
|
128
|
+
case 'list': {
|
|
129
|
+
const itemType = tagType(this.u8());
|
|
130
|
+
const n = this.count(0);
|
|
131
|
+
if (itemType === 'end' && n > 0)
|
|
132
|
+
throw new NbtError('List of end tags');
|
|
133
|
+
const value = [];
|
|
134
|
+
for (let i = 0; i < n; i++)
|
|
135
|
+
value.push(this.payload(itemType, depth + 1));
|
|
136
|
+
return { type, itemType, value };
|
|
137
|
+
}
|
|
138
|
+
case 'compound': {
|
|
139
|
+
const value = new Map();
|
|
140
|
+
for (;;) {
|
|
141
|
+
const id = tagType(this.u8());
|
|
142
|
+
if (id === 'end')
|
|
143
|
+
break;
|
|
144
|
+
const name = this.str();
|
|
145
|
+
value.set(name, this.payload(id, depth + 1));
|
|
146
|
+
}
|
|
147
|
+
return { type, value };
|
|
148
|
+
}
|
|
149
|
+
case 'end':
|
|
150
|
+
throw new NbtError('Unexpected end tag');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function tagType(id) {
|
|
155
|
+
const t = IDS[id];
|
|
156
|
+
if (!t)
|
|
157
|
+
throw new NbtError(`Unknown NBT tag id ${id}`);
|
|
158
|
+
return t;
|
|
159
|
+
}
|
|
160
|
+
/** Reads one named root compound from uncompressed NBT bytes. */
|
|
161
|
+
export function readNbt(buf, maxTags = Infinity) {
|
|
162
|
+
const r = new Reader(buf, maxTags);
|
|
163
|
+
const id = tagType(r.u8());
|
|
164
|
+
if (id !== 'compound')
|
|
165
|
+
throw new NbtError('NBT root is not a compound');
|
|
166
|
+
const name = r.str();
|
|
167
|
+
return { name, tag: r.payload('compound', 0) };
|
|
168
|
+
}
|
|
169
|
+
/** Accepts gzip, zlib or uncompressed NBT, up to maxBytes once unpacked and maxTags tags. */
|
|
170
|
+
export function unpackNbt(buf, maxBytes = 512 * 1024 * 1024, maxTags = Infinity) {
|
|
171
|
+
let raw = buf;
|
|
172
|
+
if (buf[0] === 0x1f && buf[1] === 0x8b)
|
|
173
|
+
raw = gunzipSync(buf, { maxOutputLength: maxBytes });
|
|
174
|
+
else if (buf[0] === 0x78)
|
|
175
|
+
raw = inflateSync(buf, { maxOutputLength: maxBytes });
|
|
176
|
+
if (raw.length > maxBytes)
|
|
177
|
+
throw new NbtError(`NBT data is over ${maxBytes} bytes`);
|
|
178
|
+
return readNbt(raw, maxTags);
|
|
179
|
+
}
|
|
180
|
+
// ---- writing ----
|
|
181
|
+
class Writer {
|
|
182
|
+
chunks = [];
|
|
183
|
+
bytes(b) { this.chunks.push(b); }
|
|
184
|
+
u8(v) { const b = Buffer.alloc(1); b.writeUInt8(v); this.bytes(b); }
|
|
185
|
+
i8(v) { const b = Buffer.alloc(1); b.writeInt8(v); this.bytes(b); }
|
|
186
|
+
i16(v) { const b = Buffer.alloc(2); b.writeInt16BE(v); this.bytes(b); }
|
|
187
|
+
i32(v) { const b = Buffer.alloc(4); b.writeInt32BE(v); this.bytes(b); }
|
|
188
|
+
i64(v) { const b = Buffer.alloc(8); b.writeBigInt64BE(v); this.bytes(b); }
|
|
189
|
+
str(s) {
|
|
190
|
+
const b = encodeModifiedUtf8(s);
|
|
191
|
+
if (b.length > 0xffff)
|
|
192
|
+
throw new NbtError('NBT string too long');
|
|
193
|
+
const len = Buffer.alloc(2);
|
|
194
|
+
len.writeUInt16BE(b.length);
|
|
195
|
+
this.bytes(len);
|
|
196
|
+
this.bytes(b);
|
|
197
|
+
}
|
|
198
|
+
payload(tag) {
|
|
199
|
+
switch (tag.type) {
|
|
200
|
+
case 'byte': return this.i8(tag.value);
|
|
201
|
+
case 'short': return this.i16(tag.value);
|
|
202
|
+
case 'int': return this.i32(tag.value);
|
|
203
|
+
case 'long': return this.i64(tag.value);
|
|
204
|
+
case 'float': {
|
|
205
|
+
const b = Buffer.alloc(4);
|
|
206
|
+
b.writeFloatBE(tag.value);
|
|
207
|
+
return this.bytes(b);
|
|
208
|
+
}
|
|
209
|
+
case 'double': {
|
|
210
|
+
const b = Buffer.alloc(8);
|
|
211
|
+
b.writeDoubleBE(tag.value);
|
|
212
|
+
return this.bytes(b);
|
|
213
|
+
}
|
|
214
|
+
case 'string': return this.str(tag.value);
|
|
215
|
+
case 'byteArray':
|
|
216
|
+
this.i32(tag.value.length);
|
|
217
|
+
return this.bytes(Buffer.from(tag.value.buffer, tag.value.byteOffset, tag.value.length));
|
|
218
|
+
case 'intArray': {
|
|
219
|
+
this.i32(tag.value.length);
|
|
220
|
+
const b = Buffer.alloc(tag.value.length * 4);
|
|
221
|
+
tag.value.forEach((v, i) => b.writeInt32BE(v, i * 4));
|
|
222
|
+
return this.bytes(b);
|
|
223
|
+
}
|
|
224
|
+
case 'longArray': {
|
|
225
|
+
this.i32(tag.value.length);
|
|
226
|
+
const b = Buffer.alloc(tag.value.length * 8);
|
|
227
|
+
tag.value.forEach((v, i) => b.writeBigInt64BE(v, i * 8));
|
|
228
|
+
return this.bytes(b);
|
|
229
|
+
}
|
|
230
|
+
case 'list': {
|
|
231
|
+
const itemType = tag.value.length ? tag.value[0].type : tag.itemType;
|
|
232
|
+
if (tag.value.some((t) => t.type !== itemType))
|
|
233
|
+
throw new NbtError('NBT list items must share one type');
|
|
234
|
+
this.u8(IDS.indexOf(itemType));
|
|
235
|
+
this.i32(tag.value.length);
|
|
236
|
+
for (const t of tag.value)
|
|
237
|
+
this.payload(t);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
case 'compound':
|
|
241
|
+
for (const [name, t] of tag.value) {
|
|
242
|
+
this.u8(IDS.indexOf(t.type));
|
|
243
|
+
this.str(name);
|
|
244
|
+
this.payload(t);
|
|
245
|
+
}
|
|
246
|
+
return this.u8(0);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
done() { return Buffer.concat(this.chunks); }
|
|
250
|
+
}
|
|
251
|
+
export function writeNbt(name, root) {
|
|
252
|
+
const w = new Writer();
|
|
253
|
+
w.u8(IDS.indexOf('compound'));
|
|
254
|
+
w.str(name);
|
|
255
|
+
w.payload(root);
|
|
256
|
+
return w.done();
|
|
257
|
+
}
|
|
258
|
+
export function packNbt(name, root) {
|
|
259
|
+
return gzipSync(writeNbt(name, root));
|
|
260
|
+
}
|
|
261
|
+
// ---- small accessors ----
|
|
262
|
+
export function get(c, key, type) {
|
|
263
|
+
const t = c.value.get(key);
|
|
264
|
+
return t && t.type === type ? t : undefined;
|
|
265
|
+
}
|
|
266
|
+
export function need(c, key, type, where) {
|
|
267
|
+
const t = get(c, key, type);
|
|
268
|
+
if (!t)
|
|
269
|
+
throw new NbtError(`${where}: missing ${type} "${key}"`);
|
|
270
|
+
return t;
|
|
271
|
+
}
|
|
272
|
+
/** Reads a number from any numeric tag (schematic writers differ on short vs int). */
|
|
273
|
+
export function num(c, key) {
|
|
274
|
+
const t = c.value.get(key);
|
|
275
|
+
if (!t)
|
|
276
|
+
return undefined;
|
|
277
|
+
if (t.type === 'byte' || t.type === 'short' || t.type === 'int' || t.type === 'float' || t.type === 'double')
|
|
278
|
+
return t.value;
|
|
279
|
+
if (t.type === 'long')
|
|
280
|
+
return Number(t.value);
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
package/dist/nbt/snbt.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Stringified NBT, the text form commands such as /setblock accept.
|
|
2
|
+
const BARE_KEY = /^[A-Za-z0-9._+-]+$/;
|
|
3
|
+
export class SnbtError extends Error {
|
|
4
|
+
}
|
|
5
|
+
function quote(s) {
|
|
6
|
+
if (/[\u0000-\u001f\u007f]/.test(s))
|
|
7
|
+
throw new SnbtError('Text with control characters cannot go into a command');
|
|
8
|
+
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
9
|
+
}
|
|
10
|
+
function decimal(v, suffix) {
|
|
11
|
+
if (!Number.isFinite(v))
|
|
12
|
+
throw new SnbtError('NaN or infinite number cannot go into a command');
|
|
13
|
+
const s = String(v);
|
|
14
|
+
return `${/[.eE]/.test(s) ? s : `${s}.0`}${suffix}`;
|
|
15
|
+
}
|
|
16
|
+
export function toSnbt(tag) {
|
|
17
|
+
switch (tag.type) {
|
|
18
|
+
case 'byte': return `${tag.value}b`;
|
|
19
|
+
case 'short': return `${tag.value}s`;
|
|
20
|
+
case 'int': return String(tag.value);
|
|
21
|
+
case 'long': return `${tag.value}L`;
|
|
22
|
+
case 'float': return decimal(tag.value, 'f');
|
|
23
|
+
case 'double': return decimal(tag.value, 'd');
|
|
24
|
+
case 'string': return quote(tag.value);
|
|
25
|
+
case 'byteArray': return `[B;${Array.from(tag.value, (v) => `${v}b`).join(',')}]`;
|
|
26
|
+
case 'intArray': return `[I;${Array.from(tag.value).join(',')}]`;
|
|
27
|
+
case 'longArray': return `[L;${Array.from(tag.value, (v) => `${v}L`).join(',')}]`;
|
|
28
|
+
case 'list': return `[${tag.value.map(toSnbt).join(',')}]`;
|
|
29
|
+
case 'compound': {
|
|
30
|
+
const parts = [];
|
|
31
|
+
for (const [k, v] of tag.value)
|
|
32
|
+
parts.push(`${BARE_KEY.test(k) ? k : quote(k)}:${toSnbt(v)}`);
|
|
33
|
+
return `{${parts.join(',')}}`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from 'node:fs';
|
|
2
|
+
import { cp } from 'node:fs/promises';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { STATE_DIR } from '../mc/testserver.js';
|
|
5
|
+
// Whole-world copies in the server's own folder. Nothing here deletes a world: restoring moves the
|
|
6
|
+
// current world aside first.
|
|
7
|
+
export class BackupError extends Error {
|
|
8
|
+
}
|
|
9
|
+
function stamp() {
|
|
10
|
+
return new Date().toISOString().replace(/[:.]/g, '-').replace('Z', '');
|
|
11
|
+
}
|
|
12
|
+
export function backupsDir(server) {
|
|
13
|
+
return join(server.dir, STATE_DIR, 'backups');
|
|
14
|
+
}
|
|
15
|
+
function folderBytes(dir) {
|
|
16
|
+
let total = 0;
|
|
17
|
+
for (const d of readdirSync(dir, { withFileTypes: true })) {
|
|
18
|
+
const p = join(dir, d.name);
|
|
19
|
+
total += d.isDirectory() ? folderBytes(p) : statSync(p).size;
|
|
20
|
+
}
|
|
21
|
+
return total;
|
|
22
|
+
}
|
|
23
|
+
export function listBackups(server) {
|
|
24
|
+
const dir = backupsDir(server);
|
|
25
|
+
if (!existsSync(dir))
|
|
26
|
+
return [];
|
|
27
|
+
return readdirSync(dir)
|
|
28
|
+
.filter((n) => existsSync(join(dir, n, 'level.dat')))
|
|
29
|
+
.sort()
|
|
30
|
+
.map((name) => ({ name, bytes: folderBytes(join(dir, name)) }));
|
|
31
|
+
}
|
|
32
|
+
const copyWorld = (from, to) => cp(from, to, { recursive: true, filter: (src) => basename(src) !== 'session.lock' });
|
|
33
|
+
export async function backupWorld(server, running) {
|
|
34
|
+
const began = Date.now();
|
|
35
|
+
const world = server.worldDir;
|
|
36
|
+
if (!existsSync(join(world, 'level.dat')))
|
|
37
|
+
throw new BackupError('There is no world yet; start the server once first');
|
|
38
|
+
const name = `world-${stamp()}`;
|
|
39
|
+
const dest = join(backupsDir(server), name);
|
|
40
|
+
mkdirSync(backupsDir(server), { recursive: true });
|
|
41
|
+
if (running) {
|
|
42
|
+
const c = await server.connection();
|
|
43
|
+
await c.command('save-off');
|
|
44
|
+
try {
|
|
45
|
+
await c.command('save-all flush');
|
|
46
|
+
await copyWorld(world, dest);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await c.command('save-on');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
await copyWorld(world, dest);
|
|
54
|
+
}
|
|
55
|
+
return { name, bytes: folderBytes(dest), seconds: (Date.now() - began) / 1000 };
|
|
56
|
+
}
|
|
57
|
+
export async function restoreWorld(server, name) {
|
|
58
|
+
if (!/^(world|replaced)-[0-9T-]+$/.test(name))
|
|
59
|
+
throw new BackupError('Use a backup name from status (world-...)');
|
|
60
|
+
const src = join(backupsDir(server), name);
|
|
61
|
+
if (!existsSync(join(src, 'level.dat')))
|
|
62
|
+
throw new BackupError(`No backup called ${name}`);
|
|
63
|
+
const world = server.worldDir;
|
|
64
|
+
const keptAs = `replaced-${stamp()}`;
|
|
65
|
+
if (existsSync(world))
|
|
66
|
+
renameSync(world, join(backupsDir(server), keptAs));
|
|
67
|
+
await copyWorld(src, world);
|
|
68
|
+
return { restored: name, previousWorldKeptAs: keptAs };
|
|
69
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { enforceLocked, parseProperties, redactProperties } from '../mc/properties.js';
|
|
4
|
+
import { STATE_DIR } from '../mc/testserver.js';
|
|
5
|
+
import { resolveInside, shown } from '../safety/paths.js';
|
|
6
|
+
// Files inside the test server's folder only: datapacks, configs, mods. The tool's own state
|
|
7
|
+
// folder, the world's chunk files and eula.txt are off limits, and code (mods, jars, libraries,
|
|
8
|
+
// programs and the files that tell Java how to start) needs allowModInstall. The rules are checked
|
|
9
|
+
// on the real path, after links and Windows short names (SERVER~1.PRO) are resolved.
|
|
10
|
+
export class FileError extends Error {
|
|
11
|
+
}
|
|
12
|
+
const PROTECTED_NAMES = new Set(['level.dat', 'level.dat_old', 'session.lock', 'eula.txt']);
|
|
13
|
+
const PROTECTED_EXT = /\.(mca|mcc)$/i;
|
|
14
|
+
/** Code the server runs: mods, and the libraries NeoForge's launch files point at. */
|
|
15
|
+
const CODE_FOLDERS = new Set(['mods', 'libraries']);
|
|
16
|
+
/** Java argument files: they can make Java load any program. */
|
|
17
|
+
const CODE_NAMES = new Set(['user_jvm_args.txt', 'win_args.txt', 'unix_args.txt']);
|
|
18
|
+
const CODE_EXT = /\.(jar|dll|so|dylib|jnilib|exe|com|bat|cmd|ps1|sh)$/i;
|
|
19
|
+
function resolve(server, rel) {
|
|
20
|
+
return resolveInside(server.dir, rel, { hidden: [STATE_DIR] });
|
|
21
|
+
}
|
|
22
|
+
export function listFiles(server, rel, depth, max = 500) {
|
|
23
|
+
const start = resolve(server, rel || '.');
|
|
24
|
+
if (!existsSync(start))
|
|
25
|
+
throw new FileError(`Nothing at ${rel}`);
|
|
26
|
+
const entries = [];
|
|
27
|
+
const walk = (dir, level) => {
|
|
28
|
+
for (const d of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
29
|
+
if (entries.length >= max)
|
|
30
|
+
return;
|
|
31
|
+
const full = join(dir, d.name);
|
|
32
|
+
if (dir === server.dir && d.name.toLowerCase() === STATE_DIR)
|
|
33
|
+
continue;
|
|
34
|
+
const st = lstatSync(full);
|
|
35
|
+
if (st.isSymbolicLink())
|
|
36
|
+
entries.push({ path: shown(server.dir, full), type: 'link' });
|
|
37
|
+
else if (st.isDirectory()) {
|
|
38
|
+
entries.push({ path: `${shown(server.dir, full)}/`, type: 'folder' });
|
|
39
|
+
if (level < depth)
|
|
40
|
+
walk(full, level + 1);
|
|
41
|
+
}
|
|
42
|
+
else
|
|
43
|
+
entries.push({ path: shown(server.dir, full), type: 'file', bytes: st.size });
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
if (!statSync(start).isDirectory())
|
|
47
|
+
throw new FileError(`${rel} is a file; use read_file`);
|
|
48
|
+
walk(start, 1);
|
|
49
|
+
return { entries, truncated: entries.length >= max };
|
|
50
|
+
}
|
|
51
|
+
export function readServerFile(server, config, rel, encoding) {
|
|
52
|
+
const p = resolve(server, rel);
|
|
53
|
+
if (!existsSync(p) || !statSync(p).isFile())
|
|
54
|
+
throw new FileError(`No file at ${rel}`);
|
|
55
|
+
const size = statSync(p).size;
|
|
56
|
+
if (size > config.maxFileBytes)
|
|
57
|
+
throw new FileError(`${rel} is ${size} bytes, over the limit of ${config.maxFileBytes} (maxFileBytes in the config)`);
|
|
58
|
+
// The RCON password stays hidden in every file and either encoding (latin1 keeps the bytes as they are).
|
|
59
|
+
let text = readFileSync(p).toString('latin1');
|
|
60
|
+
const pw = rconPassword(server);
|
|
61
|
+
if (pw)
|
|
62
|
+
text = text.split(pw).join('<hidden>');
|
|
63
|
+
if (basename(p).toLowerCase() === 'server.properties')
|
|
64
|
+
text = redactProperties(text);
|
|
65
|
+
const content = Buffer.from(text, 'latin1').toString(encoding === 'base64' ? 'base64' : 'utf8');
|
|
66
|
+
return { path: shown(server.dir, p), bytes: size, content };
|
|
67
|
+
}
|
|
68
|
+
function rconPassword(server) {
|
|
69
|
+
try {
|
|
70
|
+
return server.password();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Mods, jars, libraries, programs and Java argument files. rel is relative to the server folder. */
|
|
77
|
+
export function isCodeFile(rel) {
|
|
78
|
+
const parts = rel.toLowerCase().split(/[\\/]/).filter(Boolean);
|
|
79
|
+
return CODE_FOLDERS.has(parts[0] ?? '') || CODE_NAMES.has(parts[parts.length - 1] ?? '') || CODE_EXT.test(rel);
|
|
80
|
+
}
|
|
81
|
+
export function writeServerFile(server, config, rel, content, encoding, overwrite) {
|
|
82
|
+
if (!config.allowFileWrites)
|
|
83
|
+
throw new FileError('File writes are turned off (allowFileWrites is false in the config). The person can turn them on.');
|
|
84
|
+
const p = resolve(server, rel);
|
|
85
|
+
if (isCodeFile(shown(server.dir, p)) && !config.allowModInstall) {
|
|
86
|
+
throw new FileError('Adding or changing mods and other code (jars, libraries, programs, Java argument files) is turned off (allowModInstall is false in the config). Tell the person which mod you want and why; they can turn it on.');
|
|
87
|
+
}
|
|
88
|
+
const name = basename(p).toLowerCase();
|
|
89
|
+
if (PROTECTED_NAMES.has(name)) {
|
|
90
|
+
throw new FileError(name === 'eula.txt' ? 'eula.txt can only change through accept_eula, after the person accepts the EULA' : `${name} is part of the world and cannot be written`);
|
|
91
|
+
}
|
|
92
|
+
if (PROTECTED_EXT.test(name))
|
|
93
|
+
throw new FileError('World chunk files cannot be written; change blocks with the block tools');
|
|
94
|
+
const data = encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8');
|
|
95
|
+
if (data.length > config.maxFileBytes)
|
|
96
|
+
throw new FileError(`That is ${data.length} bytes, over the limit of ${config.maxFileBytes} (maxFileBytes in the config)`);
|
|
97
|
+
const settings = name === 'server.properties' && dirname(p) === resolve(server, '.');
|
|
98
|
+
// Minecraft could not read a broken file and would fall back to its defaults; refuse it instead.
|
|
99
|
+
if (settings)
|
|
100
|
+
parseProperties(data.toString('utf8'));
|
|
101
|
+
const existed = existsSync(p);
|
|
102
|
+
if (existed && statSync(p).isDirectory())
|
|
103
|
+
throw new FileError(`${rel} is a folder`);
|
|
104
|
+
if (existed && !overwrite)
|
|
105
|
+
throw new FileError(`${rel} exists. Call again with overwrite: true to replace it.`);
|
|
106
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
107
|
+
// Check again after making folders, in case a link appeared on the way.
|
|
108
|
+
resolve(server, rel);
|
|
109
|
+
writeFileSync(p, data);
|
|
110
|
+
let note;
|
|
111
|
+
if (settings) {
|
|
112
|
+
const fixed = enforceLocked(server.dir, server.meta.rconPort);
|
|
113
|
+
if (fixed.length)
|
|
114
|
+
note = `Kept these settings as the tool needs them (local only, RCON on): ${fixed.join(', ')}`;
|
|
115
|
+
note = `${note ? `${note}. ` : ''}Restart the server for changes to take effect.`;
|
|
116
|
+
}
|
|
117
|
+
return { path: shown(server.dir, p), bytes: data.length, created: !existed, ...(note ? { note } : {}) };
|
|
118
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { isAir } from '../mc/blocks.js';
|
|
2
|
+
import { nbt } from '../nbt/nbt.js';
|
|
3
|
+
import { toSnbt } from '../nbt/snbt.js';
|
|
4
|
+
import { uuidString } from '../mc/entities.js';
|
|
5
|
+
// Turns an area into compact text for the agent: a palette, then one line per row of blocks
|
|
6
|
+
// with repeats written as index*count.
|
|
7
|
+
export function rle(values) {
|
|
8
|
+
const out = [];
|
|
9
|
+
for (let i = 0; i < values.length;) {
|
|
10
|
+
let j = i;
|
|
11
|
+
while (j < values.length && values[j] === values[i])
|
|
12
|
+
j++;
|
|
13
|
+
out.push(j - i > 1 ? `${values[i]}*${j - i}` : String(values[i]));
|
|
14
|
+
i = j;
|
|
15
|
+
}
|
|
16
|
+
return out.join(' ');
|
|
17
|
+
}
|
|
18
|
+
export function describeArea(read, box, format) {
|
|
19
|
+
const s = read.schematic;
|
|
20
|
+
const counts = new Map();
|
|
21
|
+
for (const i of s.blocks)
|
|
22
|
+
counts.set(s.palette[i], (counts.get(s.palette[i]) ?? 0) + 1);
|
|
23
|
+
const base = {
|
|
24
|
+
from: box.min,
|
|
25
|
+
to: box.max,
|
|
26
|
+
size: [s.width, s.height, s.length],
|
|
27
|
+
counts: Object.fromEntries([...counts.entries()].sort((a, b) => b[1] - a[1])),
|
|
28
|
+
block_entities: s.blockEntities.map((b) => ({ pos: [box.min[0] + b.pos[0], box.min[1] + b.pos[1], box.min[2] + b.pos[2]], id: b.id })),
|
|
29
|
+
...(read.missingChunks.length ? { not_generated_chunks: read.missingChunks, note: 'Chunks that are not generated read as air.' } : {}),
|
|
30
|
+
};
|
|
31
|
+
if (format === 'list') {
|
|
32
|
+
const blocks = [];
|
|
33
|
+
for (let y = 0; y < s.height; y++)
|
|
34
|
+
for (let z = 0; z < s.length; z++)
|
|
35
|
+
for (let x = 0; x < s.width; x++) {
|
|
36
|
+
const state = s.palette[s.blocks[x + z * s.width + y * s.width * s.length]];
|
|
37
|
+
if (!isAir(state))
|
|
38
|
+
blocks.push([box.min[0] + x, box.min[1] + y, box.min[2] + z, state]);
|
|
39
|
+
}
|
|
40
|
+
return { ...base, blocks };
|
|
41
|
+
}
|
|
42
|
+
const layers = [];
|
|
43
|
+
for (let y = 0; y < s.height; y++) {
|
|
44
|
+
const rows = [];
|
|
45
|
+
let allAir = true;
|
|
46
|
+
for (let z = 0; z < s.length; z++) {
|
|
47
|
+
const row = [];
|
|
48
|
+
for (let x = 0; x < s.width; x++) {
|
|
49
|
+
const v = s.blocks[x + z * s.width + y * s.width * s.length];
|
|
50
|
+
if (!isAir(s.palette[v]))
|
|
51
|
+
allAir = false;
|
|
52
|
+
row.push(v);
|
|
53
|
+
}
|
|
54
|
+
rows.push(rle(row));
|
|
55
|
+
}
|
|
56
|
+
layers.push({ y: box.min[1] + y, rows: allAir ? 'all air' : rows });
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
...base,
|
|
60
|
+
palette: s.palette,
|
|
61
|
+
how_to_read: `Each layer is one y level. rows[0] is z=${box.min[2]}, the last is z=${box.max[2]}. Each row runs from x=${box.min[0]} to x=${box.max[0]} as palette indices; "3*5" means index 3 five times.`,
|
|
62
|
+
layers,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Fields every entity has that say little about it; left out of the short form. */
|
|
66
|
+
const NOISE = new Set([
|
|
67
|
+
'Pos', 'UUID', 'Motion', 'Rotation', 'id', 'Brain', 'Air', 'Fire', 'fall_distance', 'FallDistance', 'OnGround', 'PortalCooldown',
|
|
68
|
+
'Invulnerable', 'HurtTime', 'HurtByTimestamp', 'DeathTime', 'AbsorptionAmount', 'FallFlying', 'attributes', 'Attributes',
|
|
69
|
+
'LeftHanded', 'CanPickUpLoot', 'PersistenceRequired', 'ForcedAge', 'InLove', 'Age',
|
|
70
|
+
]);
|
|
71
|
+
const round = (v) => Math.round(v * 1000) / 1000;
|
|
72
|
+
export function describeEntities(list, box, full) {
|
|
73
|
+
return {
|
|
74
|
+
from: box.min,
|
|
75
|
+
to: box.max,
|
|
76
|
+
count: list.length,
|
|
77
|
+
entities: list.map((e) => {
|
|
78
|
+
const rot = e.data.value.get('Rotation');
|
|
79
|
+
const name = e.data.value.get('CustomName');
|
|
80
|
+
const leash = e.data.value.get('leash') ?? e.data.value.get('Leash');
|
|
81
|
+
const shown = full ? e.data : nbt.compound(new Map([...e.data.value].filter(([k]) => !NOISE.has(k))));
|
|
82
|
+
let text;
|
|
83
|
+
try {
|
|
84
|
+
text = toSnbt(shown);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
text = '(data has text that cannot be shown as SNBT)';
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
id: e.id,
|
|
91
|
+
pos: e.pos.map(round),
|
|
92
|
+
...(rot?.type === 'list' ? { rotation: rot.value.map((t) => (t.type === 'float' ? round(t.value) : 0)) } : {}),
|
|
93
|
+
uuid: e.uuid ? uuidString(e.uuid) : null,
|
|
94
|
+
...(name ? { name: name.type === 'string' ? name.value : toSnbt(name) } : {}),
|
|
95
|
+
...(leash ? { leash: toSnbt(leash) } : {}),
|
|
96
|
+
nbt: full || text.length <= 600 ? text : `${text.slice(0, 600)}... (full: true for all of it)`,
|
|
97
|
+
};
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
}
|
package/dist/ops/undo.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export class UndoStore {
|
|
4
|
+
dir;
|
|
5
|
+
keep;
|
|
6
|
+
constructor(dir, keep) {
|
|
7
|
+
this.dir = dir;
|
|
8
|
+
this.keep = keep;
|
|
9
|
+
}
|
|
10
|
+
ids() {
|
|
11
|
+
if (!existsSync(this.dir))
|
|
12
|
+
return [];
|
|
13
|
+
return readdirSync(this.dir)
|
|
14
|
+
.map((f) => /^(\d+)\.json$/.exec(f)?.[1])
|
|
15
|
+
.filter((x) => !!x)
|
|
16
|
+
.map(Number)
|
|
17
|
+
.sort((a, b) => a - b);
|
|
18
|
+
}
|
|
19
|
+
list() {
|
|
20
|
+
return this.ids().map((id) => JSON.parse(readFileSync(join(this.dir, `${id}.json`), 'utf8')));
|
|
21
|
+
}
|
|
22
|
+
push(entry, schem) {
|
|
23
|
+
mkdirSync(this.dir, { recursive: true });
|
|
24
|
+
const ids = this.ids();
|
|
25
|
+
const id = (ids[ids.length - 1] ?? 0) + 1;
|
|
26
|
+
const full = { id, time: new Date().toISOString(), ...entry };
|
|
27
|
+
writeFileSync(join(this.dir, `${id}.schem`), schem);
|
|
28
|
+
writeFileSync(join(this.dir, `${id}.json`), `${JSON.stringify(full)}\n`);
|
|
29
|
+
for (const old of ids.slice(0, Math.max(0, ids.length + 1 - this.keep)))
|
|
30
|
+
this.remove(old);
|
|
31
|
+
return full;
|
|
32
|
+
}
|
|
33
|
+
latest() {
|
|
34
|
+
const ids = this.ids();
|
|
35
|
+
const id = ids[ids.length - 1];
|
|
36
|
+
if (id === undefined)
|
|
37
|
+
return null;
|
|
38
|
+
const entry = JSON.parse(readFileSync(join(this.dir, `${id}.json`), 'utf8'));
|
|
39
|
+
return { entry, schem: readFileSync(join(this.dir, `${id}.schem`)) };
|
|
40
|
+
}
|
|
41
|
+
remove(id) {
|
|
42
|
+
rmSync(join(this.dir, `${id}.schem`), { force: true });
|
|
43
|
+
rmSync(join(this.dir, `${id}.json`), { force: true });
|
|
44
|
+
}
|
|
45
|
+
}
|