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,370 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { cloneCommand, fillCommands, isAir, packPlacements, schematicPlacements, } from '../mc/blocks.js';
|
|
4
|
+
import { boxAt, boxSize, boxVolume, chunkCount, chunksOf, makeBox, splitBox, unionBox } from '../mc/box.js';
|
|
5
|
+
import { STATE_DIR } from '../mc/testserver.js';
|
|
6
|
+
import { emptyResult, mergeResults, runInOrder, runParallel } from '../rcon/rcon.js';
|
|
7
|
+
import { resolveInside, shown } from '../safety/paths.js';
|
|
8
|
+
import { readSponge, schematicByteLimit, writeSponge } from '../schematic/sponge.js';
|
|
9
|
+
import { AIR, readArea, regionDir } from '../world/region.js';
|
|
10
|
+
import { readEntities, toSchematicEntities } from '../world/entities.js';
|
|
11
|
+
import { removeCommands, summonCommands, uuidString } from '../mc/entities.js';
|
|
12
|
+
import { UndoStore } from './undo.js';
|
|
13
|
+
// Block work on the test server: keeps the area loaded, takes a snapshot for undo first,
|
|
14
|
+
// then sends the commands in batches over RCON.
|
|
15
|
+
const PARALLEL = 4;
|
|
16
|
+
const MAX_CHUNKS = 1024;
|
|
17
|
+
export class WorldError extends Error {
|
|
18
|
+
}
|
|
19
|
+
const DIM_ID = { overworld: 'minecraft:overworld', the_nether: 'minecraft:the_nether', the_end: 'minecraft:the_end' };
|
|
20
|
+
export function inDimension(dim, cmd) {
|
|
21
|
+
return dim === 'overworld' ? cmd : `execute in ${DIM_ID[dim]} run ${cmd}`;
|
|
22
|
+
}
|
|
23
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
24
|
+
function checkChunks(n) {
|
|
25
|
+
if (n > MAX_CHUNKS)
|
|
26
|
+
throw new WorldError(`That touches ${n} chunks; the limit is ${MAX_CHUNKS} per call. Split the work into smaller areas.`);
|
|
27
|
+
}
|
|
28
|
+
export class World {
|
|
29
|
+
server;
|
|
30
|
+
config;
|
|
31
|
+
undo;
|
|
32
|
+
constructor(server, config) {
|
|
33
|
+
this.server = server;
|
|
34
|
+
this.config = config;
|
|
35
|
+
this.undo = new UndoStore(join(server.dir, STATE_DIR, 'undo'), config.undoHistory);
|
|
36
|
+
}
|
|
37
|
+
async cmd(c) {
|
|
38
|
+
return (await this.server.connection()).command(c);
|
|
39
|
+
}
|
|
40
|
+
/** Asks the server to write everything to disk, so the region files match the world. */
|
|
41
|
+
async save() {
|
|
42
|
+
const reply = await this.cmd('save-all flush');
|
|
43
|
+
if (/error|failed/i.test(reply))
|
|
44
|
+
throw new WorldError(`Saving failed: ${reply}`);
|
|
45
|
+
}
|
|
46
|
+
async read(box, dim, withEntities = false) {
|
|
47
|
+
await this.save();
|
|
48
|
+
const area = await readArea(regionDir(this.server.worldDir, dim), box);
|
|
49
|
+
if (!withEntities)
|
|
50
|
+
return { ...area, entities: [] };
|
|
51
|
+
const entities = await readEntities(regionDir(this.server.worldDir, dim, 'entities'), box);
|
|
52
|
+
this.checkEntities(entities.length, 'The area has');
|
|
53
|
+
area.schematic.entities = toSchematicEntities(entities, box);
|
|
54
|
+
area.schematic.origin = [...box.min];
|
|
55
|
+
return { ...area, entities };
|
|
56
|
+
}
|
|
57
|
+
/** Entities in the area right now (saved first). */
|
|
58
|
+
async entitiesIn(box, dim) {
|
|
59
|
+
checkChunks(chunkCount(box));
|
|
60
|
+
await this.save();
|
|
61
|
+
return readEntities(regionDir(this.server.worldDir, dim, 'entities'), box);
|
|
62
|
+
}
|
|
63
|
+
checkEntities(n, what) {
|
|
64
|
+
if (n > this.config.maxEntities) {
|
|
65
|
+
throw new WorldError(`${what} ${n} entities, over the limit of ${this.config.maxEntities} (maxEntities in the config). Use a smaller area, or include_entities: false.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Removes entities without loot (living ones get an empty loot table first). */
|
|
69
|
+
async removeEntities(list, dim) {
|
|
70
|
+
if (!list.length)
|
|
71
|
+
return { removed: 0, result: emptyResult() };
|
|
72
|
+
const result = await runInOrder(await this.server.connection(), removeCommands(list).map((c) => inDimension(dim, c)), 1000);
|
|
73
|
+
result.errors = result.errors.filter((e) => !e.command.includes('DeathLootTable'));
|
|
74
|
+
return { removed: list.length, result };
|
|
75
|
+
}
|
|
76
|
+
/** UUIDs of the items and XP orbs in and right around the box, before a change. */
|
|
77
|
+
async dropsBefore(box, dim) {
|
|
78
|
+
return new Set((await this.entitiesIn(grow(box), dim)).filter(isDrop).map((e) => e.uuid?.join(',') ?? ''));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Removes items and XP orbs that appeared in or right around the box during a change: blocks such as
|
|
82
|
+
* doors and torches pop off when what holds them is cleared, and leads drop when their mob goes.
|
|
83
|
+
*/
|
|
84
|
+
async sweepDrops(box, dim, before) {
|
|
85
|
+
await sleep(100);
|
|
86
|
+
const drops = (await this.entitiesIn(grow(box), dim)).filter((e) => isDrop(e) && e.uuid && !before.has(e.uuid.join(',')));
|
|
87
|
+
await runInOrder(await this.server.connection(), drops.map((e) => inDimension(dim, `kill ${uuidString(e.uuid)}`)));
|
|
88
|
+
return drops.length;
|
|
89
|
+
}
|
|
90
|
+
/** Summons schematic entities with their corner at origin, in order (a summon may be followed by data merges). */
|
|
91
|
+
async summonEntities(entities, origin, oldOrigin, dim, warnings) {
|
|
92
|
+
if (!entities.length)
|
|
93
|
+
return { summoned: 0, result: emptyResult() };
|
|
94
|
+
const plan = summonCommands(entities, origin, oldOrigin);
|
|
95
|
+
warnings.push(...plan.warnings);
|
|
96
|
+
const result = await runInOrder(await this.server.connection(), plan.commands.map((c) => inDimension(dim, c)), 1000);
|
|
97
|
+
const summoned = plan.commands.filter((c) => c.startsWith('summon ')).length - result.errors.filter((e) => e.command.includes('summon ')).length;
|
|
98
|
+
return { summoned, result };
|
|
99
|
+
}
|
|
100
|
+
/** Whether an entity with this UUID is alive anywhere loaded. A mob in its death animation counts as gone. */
|
|
101
|
+
async alive(uuid) {
|
|
102
|
+
const r = await this.cmd(`data get entity ${uuid} Health`);
|
|
103
|
+
if (/No entity was found/i.test(r))
|
|
104
|
+
return false;
|
|
105
|
+
const m = /:\s*(-?[\d.]+)f\s*$/.exec(r.trim());
|
|
106
|
+
return !(m && Number(m[1]) <= 0);
|
|
107
|
+
}
|
|
108
|
+
/** Force-loads the chunks of boxes while fn runs, then releases only the ones it added. */
|
|
109
|
+
async withLoaded(boxes, dim, fn) {
|
|
110
|
+
const keys = new Map();
|
|
111
|
+
for (const b of boxes) {
|
|
112
|
+
// Counted before listing, so a huge box is refused without listing its chunks.
|
|
113
|
+
checkChunks(chunkCount(b));
|
|
114
|
+
for (const c of chunksOf(b))
|
|
115
|
+
keys.set(`${c[0]},${c[1]}`, c);
|
|
116
|
+
}
|
|
117
|
+
checkChunks(keys.size);
|
|
118
|
+
const listed = await this.cmd(inDimension(dim, 'forceload query'));
|
|
119
|
+
const already = new Set([...listed.matchAll(/\[(-?\d+), (-?\d+)\]/g)].map((m) => `${m[1]},${m[2]}`));
|
|
120
|
+
const added = [...keys.entries()].filter(([k]) => !already.has(k)).map(([, c]) => c);
|
|
121
|
+
const conns = await this.server.connections(PARALLEL);
|
|
122
|
+
const add = await runParallel(conns, added.map(([cx, cz]) => inDimension(dim, `forceload add ${cx * 16} ${cz * 16}`)));
|
|
123
|
+
try {
|
|
124
|
+
if (add.errors.length)
|
|
125
|
+
throw new WorldError(`Could not load the area: ${add.errors[0].reply}`);
|
|
126
|
+
await this.waitLoaded([...keys.values()], dim);
|
|
127
|
+
return await fn();
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await runParallel(conns, added.map(([cx, cz]) => inDimension(dim, `forceload remove ${cx * 16} ${cz * 16}`))).catch(() => undefined);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async waitLoaded(chunks, dim, timeoutMs = 60_000) {
|
|
134
|
+
let pending = chunks;
|
|
135
|
+
const until = Date.now() + timeoutMs;
|
|
136
|
+
const conns = await this.server.connections(PARALLEL);
|
|
137
|
+
while (pending.length) {
|
|
138
|
+
const still = [];
|
|
139
|
+
let next = 0;
|
|
140
|
+
await Promise.all(conns.map(async (c) => {
|
|
141
|
+
while (next < pending.length) {
|
|
142
|
+
const ch = pending[next++];
|
|
143
|
+
const r = await c.command(inDimension(dim, `execute if loaded ${ch[0] * 16} 0 ${ch[1] * 16}`));
|
|
144
|
+
if (!/passed/i.test(r))
|
|
145
|
+
still.push(ch);
|
|
146
|
+
}
|
|
147
|
+
}));
|
|
148
|
+
pending = still;
|
|
149
|
+
if (!pending.length)
|
|
150
|
+
return;
|
|
151
|
+
if (Date.now() > until)
|
|
152
|
+
throw new WorldError(`${pending.length} chunks did not load within ${timeoutMs / 1000}s`);
|
|
153
|
+
await sleep(200);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Saves the area as it is now, so undo can put it back. Null when automatic backups are off. */
|
|
157
|
+
async snapshot(box, dim, label, opts) {
|
|
158
|
+
if (!this.config.autoBackup)
|
|
159
|
+
return null;
|
|
160
|
+
const volume = boxVolume(box);
|
|
161
|
+
if (volume > this.config.maxImportBlocks) {
|
|
162
|
+
if (opts.skipBackup)
|
|
163
|
+
return null;
|
|
164
|
+
throw new WorldError(`The area is ${volume} blocks, too big for an undo snapshot (limit ${this.config.maxImportBlocks}). Run backup_world first, then call again with skip_backup: true.`);
|
|
165
|
+
}
|
|
166
|
+
const { schematic } = await this.read(box, dim, this.config.includeEntities);
|
|
167
|
+
return this.undo.push({ label, box, dimension: dim, blocks: volume, entities: this.config.includeEntities }, writeSponge(schematic, 3));
|
|
168
|
+
}
|
|
169
|
+
async runBatch(batch, dim) {
|
|
170
|
+
const conns = await this.server.connections(PARALLEL);
|
|
171
|
+
const first = await runParallel(conns, batch.first.map((c) => inDimension(dim, c)));
|
|
172
|
+
const second = await runInOrder(conns[0], batch.second.map((c) => inDimension(dim, c)));
|
|
173
|
+
return mergeResults(first, second);
|
|
174
|
+
}
|
|
175
|
+
result(r, warnings, undo, began, entities) {
|
|
176
|
+
return {
|
|
177
|
+
...(entities ? { entities } : {}),
|
|
178
|
+
commands: r.ran,
|
|
179
|
+
changed: r.changed,
|
|
180
|
+
unchanged: r.unchanged,
|
|
181
|
+
errors: r.errors,
|
|
182
|
+
warnings,
|
|
183
|
+
undo: undo?.id ?? null,
|
|
184
|
+
seconds: Math.round((Date.now() - began) / 100) / 10,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
checkSize(volume, what) {
|
|
188
|
+
if (volume > this.config.maxFillBlocks) {
|
|
189
|
+
throw new WorldError(`${what} covers ${volume} blocks, over the limit of ${this.config.maxFillBlocks} (maxFillBlocks in the config). Split it into smaller steps.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async place(placements, opts) {
|
|
193
|
+
const began = Date.now();
|
|
194
|
+
const box = unionBox(placements.map((p) => ({ min: p.pos, max: p.pos })));
|
|
195
|
+
this.checkSize(placements.length, 'This placement');
|
|
196
|
+
const batch = packPlacements(placements);
|
|
197
|
+
return this.withLoaded([box], opts.dimension, async () => {
|
|
198
|
+
const undo = await this.snapshot(box, opts.dimension, `place ${placements.length} blocks`, opts);
|
|
199
|
+
const r = await this.runBatch(batch, opts.dimension);
|
|
200
|
+
return this.result(r, batch.warnings, undo, began);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
async fill(fills, opts, label = 'fill') {
|
|
204
|
+
const began = Date.now();
|
|
205
|
+
const total = fills.reduce((a, f) => a + boxVolume(f.box), 0);
|
|
206
|
+
this.checkSize(total, 'This fill');
|
|
207
|
+
const box = unionBox(fills.map((f) => f.box));
|
|
208
|
+
return this.withLoaded(fills.map((f) => f.box), opts.dimension, async () => {
|
|
209
|
+
const undo = await this.snapshot(box, opts.dimension, label, opts);
|
|
210
|
+
const conns = await this.server.connections(PARALLEL);
|
|
211
|
+
let r = emptyResult();
|
|
212
|
+
for (const f of fills) {
|
|
213
|
+
const cmds = fillCommands(f.box, f.state, f.mode, f.filter).map((c) => inDimension(opts.dimension, c));
|
|
214
|
+
const hollow = f.mode === 'hollow' || f.mode === 'outline';
|
|
215
|
+
r = mergeResults(r, hollow ? await runInOrder(conns[0], cmds) : await runParallel(conns, cmds));
|
|
216
|
+
}
|
|
217
|
+
return this.result(r, [], undo, began);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
async copy(src, dest, masked, opts) {
|
|
221
|
+
const began = Date.now();
|
|
222
|
+
const destBox = boxAt(dest, boxSize(src));
|
|
223
|
+
this.checkSize(boxVolume(src), 'This copy');
|
|
224
|
+
const overlap = [0, 1, 2].every((i) => src.min[i] <= destBox.max[i] && destBox.min[i] <= src.max[i]);
|
|
225
|
+
const parts = splitBox(src, 32768);
|
|
226
|
+
if (overlap && parts.length > 1)
|
|
227
|
+
throw new WorldError('Source and destination overlap and the area is over 32768 blocks. Copy it in two steps through a free spot.');
|
|
228
|
+
return this.withLoaded([src, destBox], opts.dimension, async () => {
|
|
229
|
+
const undo = await this.snapshot(destBox, opts.dimension, 'copy', opts);
|
|
230
|
+
const cmds = parts.map((p) => {
|
|
231
|
+
const d = [dest[0] + p.min[0] - src.min[0], dest[1] + p.min[1] - src.min[1], dest[2] + p.min[2] - src.min[2]];
|
|
232
|
+
return inDimension(opts.dimension, cloneCommand(p, d, masked, overlap));
|
|
233
|
+
});
|
|
234
|
+
const r = await runInOrder(await this.server.connection(), cmds);
|
|
235
|
+
return this.result(r, [], undo, began);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
/** Sets the box to air. With entities, removes the entities in it first (undo brings them back). */
|
|
239
|
+
async clear(box, opts, withEntities) {
|
|
240
|
+
const began = Date.now();
|
|
241
|
+
this.checkSize(boxVolume(box), 'This clear');
|
|
242
|
+
return this.withLoaded([box], opts.dimension, async () => {
|
|
243
|
+
const undo = await this.snapshot(box, opts.dimension, 'clear', opts);
|
|
244
|
+
const drops = await this.dropsBefore(box, opts.dimension);
|
|
245
|
+
let removed = 0;
|
|
246
|
+
let r = emptyResult();
|
|
247
|
+
if (withEntities) {
|
|
248
|
+
const gone = await this.removeEntities(await this.entitiesIn(box, opts.dimension), opts.dimension);
|
|
249
|
+
removed = gone.removed;
|
|
250
|
+
r = gone.result;
|
|
251
|
+
}
|
|
252
|
+
const conns = await this.server.connections(PARALLEL);
|
|
253
|
+
r = mergeResults(r, await runParallel(conns, fillCommands(box, AIR).map((c) => inDimension(opts.dimension, c))));
|
|
254
|
+
await this.sweepDrops(box, opts.dimension, drops);
|
|
255
|
+
return this.result(r, [], undo, began, withEntities ? { removed, summoned: 0 } : undefined);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Pastes a schematic with its corner at origin. includeAir clears the area first.
|
|
260
|
+
* Entities: 'replace' removes the entities in the area (when includeAir) and summons those of the
|
|
261
|
+
* schematic; 'restore' (undo) removes only entities that were not there before and summons only
|
|
262
|
+
* the missing ones.
|
|
263
|
+
*/
|
|
264
|
+
async paste(s, origin, includeAir, opts, label, snapshot = true, entityMode = 'none') {
|
|
265
|
+
const began = Date.now();
|
|
266
|
+
const box = boxAt(origin, [s.width, s.height, s.length]);
|
|
267
|
+
const volume = boxVolume(box);
|
|
268
|
+
if (volume > this.config.maxImportBlocks) {
|
|
269
|
+
throw new WorldError(`The schematic is ${volume} blocks, over the limit of ${this.config.maxImportBlocks} (maxImportBlocks in the config).`);
|
|
270
|
+
}
|
|
271
|
+
const { placements, warnings } = schematicPlacements(s, origin);
|
|
272
|
+
const batch = packPlacements(placements);
|
|
273
|
+
warnings.push(...batch.warnings);
|
|
274
|
+
const entities = entityMode === 'none' ? [] : (s.entities ?? []);
|
|
275
|
+
this.checkEntities(entities.length, 'The schematic has');
|
|
276
|
+
const dim = opts.dimension;
|
|
277
|
+
return this.withLoaded([box], dim, async () => {
|
|
278
|
+
const undo = snapshot ? await this.snapshot(box, dim, label, opts) : null;
|
|
279
|
+
const drops = includeAir ? await this.dropsBefore(box, dim) : null;
|
|
280
|
+
let r = emptyResult();
|
|
281
|
+
let removed = 0;
|
|
282
|
+
let toSummon = entities;
|
|
283
|
+
if (entityMode === 'replace' && includeAir) {
|
|
284
|
+
const gone = await this.removeEntities(await this.entitiesIn(box, dim), dim);
|
|
285
|
+
removed = gone.removed;
|
|
286
|
+
r = gone.result;
|
|
287
|
+
}
|
|
288
|
+
else if (entityMode === 'restore') {
|
|
289
|
+
const before = new Set(entities.map((e) => snapshotUuid(e)).filter((u) => u !== null));
|
|
290
|
+
const added = (await this.entitiesIn(box, dim)).filter((e) => !e.uuid || !before.has(uuidString(e.uuid)));
|
|
291
|
+
const gone = await this.removeEntities(added, dim);
|
|
292
|
+
removed = gone.removed;
|
|
293
|
+
r = gone.result;
|
|
294
|
+
const missing = [];
|
|
295
|
+
for (const e of entities) {
|
|
296
|
+
const u = snapshotUuid(e);
|
|
297
|
+
if (u === null || !(await this.alive(u)))
|
|
298
|
+
missing.push(e);
|
|
299
|
+
}
|
|
300
|
+
toSummon = missing;
|
|
301
|
+
}
|
|
302
|
+
if (includeAir) {
|
|
303
|
+
const conns = await this.server.connections(PARALLEL);
|
|
304
|
+
r = mergeResults(r, await runParallel(conns, fillCommands(box, AIR).map((c) => inDimension(dim, c))));
|
|
305
|
+
}
|
|
306
|
+
r = mergeResults(r, await this.runBatch(batch, dim));
|
|
307
|
+
if (drops)
|
|
308
|
+
await this.sweepDrops(box, dim, drops);
|
|
309
|
+
let summoned = 0;
|
|
310
|
+
if (entityMode !== 'none') {
|
|
311
|
+
const made = await this.summonEntities(toSummon, origin, s.origin, dim, warnings);
|
|
312
|
+
summoned = made.summoned;
|
|
313
|
+
r = mergeResults(r, made.result);
|
|
314
|
+
}
|
|
315
|
+
return this.result(r, warnings, undo, began, entityMode === 'none' ? undefined : { removed, summoned });
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
async importFile(file, origin, includeAir, opts, withEntities) {
|
|
319
|
+
const limit = schematicByteLimit(this.config.maxImportBlocks);
|
|
320
|
+
if (statSync(file).size > limit)
|
|
321
|
+
throw new WorldError(`${shown(this.server.dir, file)} is over ${limit} bytes, too big for a schematic within maxImportBlocks (${this.config.maxImportBlocks}).`);
|
|
322
|
+
const { schematic, version } = readSponge(readFileSync(file), { maxBlocks: this.config.maxImportBlocks, ...(withEntities ? { maxEntities: this.config.maxEntities } : {}) });
|
|
323
|
+
const r = await this.paste(schematic, origin, includeAir, opts, `import ${shown(this.server.dir, file)}`, true, withEntities ? 'replace' : 'none');
|
|
324
|
+
return { ...r, size: [schematic.width, schematic.height, schematic.length], version, dataVersion: schematic.dataVersion };
|
|
325
|
+
}
|
|
326
|
+
async exportFile(box, file, version, dim, withEntities) {
|
|
327
|
+
const volume = boxVolume(box);
|
|
328
|
+
if (volume > this.config.maxImportBlocks)
|
|
329
|
+
throw new WorldError(`The area is ${volume} blocks, over the limit of ${this.config.maxImportBlocks} (maxImportBlocks in the config).`);
|
|
330
|
+
const { schematic, missingChunks } = await this.read(box, dim, withEntities);
|
|
331
|
+
const data = writeSponge(schematic, version);
|
|
332
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
333
|
+
writeFileSync(file, data);
|
|
334
|
+
let nonAir = 0;
|
|
335
|
+
for (const i of schematic.blocks)
|
|
336
|
+
if (!isAir(schematic.palette[i]))
|
|
337
|
+
nonAir++;
|
|
338
|
+
return { file: shown(this.server.dir, file), size: boxSize(box), blocks: volume, nonAir, blockEntities: schematic.blockEntities.length, entities: schematic.entities?.length ?? 0, bytes: data.length, missingChunks };
|
|
339
|
+
}
|
|
340
|
+
async undoLast() {
|
|
341
|
+
const last = this.undo.latest();
|
|
342
|
+
if (!last)
|
|
343
|
+
return null;
|
|
344
|
+
const { schematic } = readSponge(last.schem);
|
|
345
|
+
const r = await this.paste(schematic, last.entry.box.min, true, { dimension: last.entry.dimension }, 'undo', false, last.entry.entities ? 'restore' : 'none');
|
|
346
|
+
if (r.errors.length === 0)
|
|
347
|
+
this.undo.remove(last.entry.id);
|
|
348
|
+
return { ...r, label: last.entry.label };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/** The box with one more block on every side. */
|
|
352
|
+
function grow(b) {
|
|
353
|
+
return { min: [b.min[0] - 1, b.min[1] - 1, b.min[2] - 1], max: [b.max[0] + 1, b.max[1] + 1, b.max[2] + 1] };
|
|
354
|
+
}
|
|
355
|
+
function isDrop(e) {
|
|
356
|
+
return e.id === 'minecraft:item' || e.id === 'minecraft:experience_orb';
|
|
357
|
+
}
|
|
358
|
+
function snapshotUuid(e) {
|
|
359
|
+
const u = e.data.value.get('UUID');
|
|
360
|
+
return u?.type === 'intArray' && u.value.length === 4 ? uuidString(u.value) : null;
|
|
361
|
+
}
|
|
362
|
+
export function boxFrom(from, to) {
|
|
363
|
+
return makeBox(from, to);
|
|
364
|
+
}
|
|
365
|
+
export function serverPath(server, rel) {
|
|
366
|
+
return resolveInside(server.dir, rel, { hidden: [STATE_DIR] });
|
|
367
|
+
}
|
|
368
|
+
export function exists(p) {
|
|
369
|
+
return existsSync(p);
|
|
370
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { connect } from 'node:net';
|
|
2
|
+
import { assertLoopback, assertPort, RCON_HOST } from '../safety/address.js';
|
|
3
|
+
import { MAX_COMMAND_BYTES } from '../mc/blocks.js';
|
|
4
|
+
// Source RCON protocol as Minecraft implements it: little-endian packets of
|
|
5
|
+
// [length][request id][type][body\0][\0]. Type 3 logs in, type 2 runs a command, type 0 is a reply.
|
|
6
|
+
// Minecraft reads one packet per read and drops the connection if two arrive together, so this
|
|
7
|
+
// client waits for each reply before sending the next command.
|
|
8
|
+
const TYPE_AUTH = 3;
|
|
9
|
+
const TYPE_COMMAND = 2;
|
|
10
|
+
/** Minecraft splits long replies into packets of this many characters. */
|
|
11
|
+
const FRAGMENT = 4096;
|
|
12
|
+
export class RconError extends Error {
|
|
13
|
+
}
|
|
14
|
+
export function encodePacket(id, type, body) {
|
|
15
|
+
const text = Buffer.from(body, 'utf8');
|
|
16
|
+
const buf = Buffer.alloc(14 + text.length);
|
|
17
|
+
buf.writeInt32LE(10 + text.length, 0);
|
|
18
|
+
buf.writeInt32LE(id, 4);
|
|
19
|
+
buf.writeInt32LE(type, 8);
|
|
20
|
+
text.copy(buf, 12);
|
|
21
|
+
return buf;
|
|
22
|
+
}
|
|
23
|
+
/** Takes whole packets off the front of buf. Returns them and the bytes left over. */
|
|
24
|
+
export function decodePackets(buf) {
|
|
25
|
+
const packets = [];
|
|
26
|
+
let p = 0;
|
|
27
|
+
while (buf.length - p >= 4) {
|
|
28
|
+
const len = buf.readInt32LE(p);
|
|
29
|
+
if (len < 10 || len > 1024 * 1024)
|
|
30
|
+
throw new RconError(`Bad RCON packet length ${len}`);
|
|
31
|
+
if (buf.length - p - 4 < len)
|
|
32
|
+
break;
|
|
33
|
+
packets.push({ id: buf.readInt32LE(p + 4), type: buf.readInt32LE(p + 8), body: buf.toString('utf8', p + 12, p + 4 + len - 2) });
|
|
34
|
+
p += 4 + len;
|
|
35
|
+
}
|
|
36
|
+
return { packets, rest: buf.subarray(p) };
|
|
37
|
+
}
|
|
38
|
+
export class Rcon {
|
|
39
|
+
socket;
|
|
40
|
+
timeoutMs;
|
|
41
|
+
buf = Buffer.alloc(0);
|
|
42
|
+
nextId = 1;
|
|
43
|
+
waiting = null;
|
|
44
|
+
queue = Promise.resolve();
|
|
45
|
+
closed = false;
|
|
46
|
+
constructor(socket, timeoutMs) {
|
|
47
|
+
this.socket = socket;
|
|
48
|
+
this.timeoutMs = timeoutMs;
|
|
49
|
+
socket.setNoDelay(true);
|
|
50
|
+
socket.on('data', (d) => this.onData(d));
|
|
51
|
+
socket.on('error', (e) => this.fail(new RconError(`RCON connection error: ${e.message}`)));
|
|
52
|
+
socket.on('close', () => {
|
|
53
|
+
this.closed = true;
|
|
54
|
+
this.fail(new RconError('RCON connection closed by the server'));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
static async connect(port, password, timeoutMs = 60_000, host = RCON_HOST) {
|
|
58
|
+
assertLoopback(host, 'RCON address');
|
|
59
|
+
assertPort(port, 'RCON port');
|
|
60
|
+
const socket = await new Promise((res, rej) => {
|
|
61
|
+
const s = connect({ host, port });
|
|
62
|
+
const t = setTimeout(() => {
|
|
63
|
+
s.destroy();
|
|
64
|
+
rej(new RconError(`No answer from RCON on ${host}:${port}`));
|
|
65
|
+
}, 5000);
|
|
66
|
+
s.once('connect', () => {
|
|
67
|
+
clearTimeout(t);
|
|
68
|
+
res(s);
|
|
69
|
+
});
|
|
70
|
+
s.once('error', (e) => {
|
|
71
|
+
clearTimeout(t);
|
|
72
|
+
rej(new RconError(`Cannot reach RCON on ${host}:${port}: ${e.message}`));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
const rcon = new Rcon(socket, timeoutMs);
|
|
76
|
+
try {
|
|
77
|
+
await rcon.send(TYPE_AUTH, password);
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
rcon.close();
|
|
81
|
+
throw e;
|
|
82
|
+
}
|
|
83
|
+
return rcon;
|
|
84
|
+
}
|
|
85
|
+
get isOpen() {
|
|
86
|
+
return !this.closed;
|
|
87
|
+
}
|
|
88
|
+
/** Runs one command and returns the server's reply text. */
|
|
89
|
+
command(cmd) {
|
|
90
|
+
if (/[\r\n\0]/.test(cmd))
|
|
91
|
+
return Promise.reject(new RconError('A command must be one line'));
|
|
92
|
+
const c = cmd.replace(/^\//, '');
|
|
93
|
+
if (Buffer.byteLength(c) > MAX_COMMAND_BYTES) {
|
|
94
|
+
return Promise.reject(new RconError(`Command is ${Buffer.byteLength(c)} bytes; RCON takes at most ${MAX_COMMAND_BYTES}`));
|
|
95
|
+
}
|
|
96
|
+
return this.send(TYPE_COMMAND, c);
|
|
97
|
+
}
|
|
98
|
+
close() {
|
|
99
|
+
this.closed = true;
|
|
100
|
+
this.socket.destroy();
|
|
101
|
+
}
|
|
102
|
+
send(type, body) {
|
|
103
|
+
const run = () => new Promise((resolve, reject) => {
|
|
104
|
+
if (this.closed)
|
|
105
|
+
return reject(new RconError('RCON connection is closed'));
|
|
106
|
+
const id = this.nextId++;
|
|
107
|
+
const timer = setTimeout(() => this.fail(new RconError(`RCON gave no reply within ${this.timeoutMs / 1000}s`)), this.timeoutMs);
|
|
108
|
+
this.waiting = { id, parts: [], resolve, reject, timer };
|
|
109
|
+
this.socket.write(encodePacket(id, type, body));
|
|
110
|
+
});
|
|
111
|
+
const p = this.queue.then(run, run);
|
|
112
|
+
this.queue = p.catch(() => undefined);
|
|
113
|
+
return p;
|
|
114
|
+
}
|
|
115
|
+
onData(d) {
|
|
116
|
+
let decoded;
|
|
117
|
+
try {
|
|
118
|
+
decoded = decodePackets(Buffer.concat([this.buf, d]));
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
this.fail(e);
|
|
122
|
+
this.close();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.buf = decoded.rest;
|
|
126
|
+
for (const pkt of decoded.packets) {
|
|
127
|
+
const w = this.waiting;
|
|
128
|
+
if (!w)
|
|
129
|
+
continue;
|
|
130
|
+
if (pkt.id === -1) {
|
|
131
|
+
this.fail(new RconError('RCON refused the password'));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (pkt.id !== w.id)
|
|
135
|
+
continue;
|
|
136
|
+
w.parts.push(pkt.body);
|
|
137
|
+
if (w.settle)
|
|
138
|
+
clearTimeout(w.settle);
|
|
139
|
+
if (pkt.body.length >= FRAGMENT)
|
|
140
|
+
w.settle = setTimeout(() => this.finish(w), 150);
|
|
141
|
+
else
|
|
142
|
+
this.finish(w);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
finish(w) {
|
|
146
|
+
if (this.waiting !== w)
|
|
147
|
+
return;
|
|
148
|
+
clearTimeout(w.timer);
|
|
149
|
+
if (w.settle)
|
|
150
|
+
clearTimeout(w.settle);
|
|
151
|
+
this.waiting = null;
|
|
152
|
+
w.resolve(w.parts.join(''));
|
|
153
|
+
}
|
|
154
|
+
fail(e) {
|
|
155
|
+
const w = this.waiting;
|
|
156
|
+
if (!w)
|
|
157
|
+
return;
|
|
158
|
+
clearTimeout(w.timer);
|
|
159
|
+
if (w.settle)
|
|
160
|
+
clearTimeout(w.settle);
|
|
161
|
+
this.waiting = null;
|
|
162
|
+
w.reject(e);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const UNCHANGED = /^(Could not set the block|No blocks were (filled|cloned)|Nothing changed)/i;
|
|
166
|
+
const ERROR = /(Unknown or incomplete command|Unknown block|Unknown command|Incorrect argument|Expected |Invalid |<--\[HERE\]|not loaded|out of (this|the) world|Too many blocks|Cannot |Can't |Could not |Unknown |is not a valid|Unable to |No entity was found)/i;
|
|
167
|
+
export function classifyReply(reply) {
|
|
168
|
+
const r = reply.trim();
|
|
169
|
+
if (UNCHANGED.test(r))
|
|
170
|
+
return 'unchanged';
|
|
171
|
+
if (ERROR.test(r))
|
|
172
|
+
return 'error';
|
|
173
|
+
return 'ok';
|
|
174
|
+
}
|
|
175
|
+
/** Blocks a /fill, /clone or /setblock reply says it changed. */
|
|
176
|
+
export function changedCount(reply) {
|
|
177
|
+
const m = /(\d+) block/i.exec(reply);
|
|
178
|
+
if (m)
|
|
179
|
+
return Number(m[1]);
|
|
180
|
+
return /^Changed the block/i.test(reply.trim()) ? 1 : 0;
|
|
181
|
+
}
|
|
182
|
+
export function mergeResults(a, b) {
|
|
183
|
+
return { ran: a.ran + b.ran, changed: a.changed + b.changed, ok: a.ok + b.ok, unchanged: a.unchanged + b.unchanged, errors: [...a.errors, ...b.errors].slice(0, 20) };
|
|
184
|
+
}
|
|
185
|
+
export function emptyResult() {
|
|
186
|
+
return { ran: 0, changed: 0, ok: 0, unchanged: 0, errors: [] };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Runs commands over several connections at once. Only for commands whose order does not
|
|
190
|
+
* matter (they touch different blocks). Stops early after too many errors.
|
|
191
|
+
*/
|
|
192
|
+
export async function runParallel(conns, commands, maxErrors = 20) {
|
|
193
|
+
const result = { ran: 0, changed: 0, ok: 0, unchanged: 0, errors: [] };
|
|
194
|
+
let next = 0;
|
|
195
|
+
const worker = async (c) => {
|
|
196
|
+
while (next < commands.length && result.errors.length < maxErrors) {
|
|
197
|
+
const cmd = commands[next++];
|
|
198
|
+
const reply = await c.command(cmd);
|
|
199
|
+
result.ran++;
|
|
200
|
+
const kind = classifyReply(reply);
|
|
201
|
+
if (kind === 'ok') {
|
|
202
|
+
result.ok++;
|
|
203
|
+
result.changed += changedCount(reply);
|
|
204
|
+
}
|
|
205
|
+
else if (kind === 'unchanged')
|
|
206
|
+
result.unchanged++;
|
|
207
|
+
else
|
|
208
|
+
result.errors.push({ command: cmd, reply: reply.trim() });
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
await Promise.all(conns.map(worker));
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
export async function runInOrder(conn, commands, maxErrors = 20) {
|
|
215
|
+
return runParallel([conn], commands, maxErrors);
|
|
216
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
// The tool only ever talks to this computer. RCON is always 127.0.0.1; these checks also guard
|
|
3
|
+
// server.properties so the test server never listens on a network address.
|
|
4
|
+
export const RCON_HOST = '127.0.0.1';
|
|
5
|
+
export class AddressError extends Error {
|
|
6
|
+
}
|
|
7
|
+
/** True for literal loopback addresses: 127.0.0.0/8, ::1 and ::ffff:127.x.x.x. Host names are not accepted. */
|
|
8
|
+
export function isLoopback(host) {
|
|
9
|
+
const h = host.trim().replace(/^\[|\]$/g, '').toLowerCase();
|
|
10
|
+
if (isIP(h) === 4)
|
|
11
|
+
return h.split('.')[0] === '127';
|
|
12
|
+
if (isIP(h) === 6) {
|
|
13
|
+
if (h === '::1' || h === '0:0:0:0:0:0:0:1')
|
|
14
|
+
return true;
|
|
15
|
+
const mapped = /^(?:0{0,4}:){0,5}:?ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(h) ?? /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(h);
|
|
16
|
+
return mapped !== null && isIP(mapped[1]) === 4 && mapped[1].split('.')[0] === '127';
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
export function assertLoopback(host, what) {
|
|
21
|
+
if (!isLoopback(host))
|
|
22
|
+
throw new AddressError(`${what} must be a loopback address such as 127.0.0.1, not "${host}". Only a test server on this computer is supported.`);
|
|
23
|
+
}
|
|
24
|
+
export function assertPort(port, what) {
|
|
25
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
|
26
|
+
throw new AddressError(`${what} must be a whole number from 1024 to 65535, not ${port}`);
|
|
27
|
+
}
|
|
28
|
+
/** Hosts the setup may download from. Every download is also checked against a published hash. */
|
|
29
|
+
export const DOWNLOAD_HOSTS = ['piston-meta.mojang.com', 'piston-data.mojang.com', 'launchermeta.mojang.com', 'launcher.mojang.com', 'maven.neoforged.net'];
|
|
30
|
+
export function assertDownloadUrl(url) {
|
|
31
|
+
let u;
|
|
32
|
+
try {
|
|
33
|
+
u = new URL(url);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new AddressError(`Not a URL: ${url}`);
|
|
37
|
+
}
|
|
38
|
+
if (u.protocol !== 'https:')
|
|
39
|
+
throw new AddressError(`Downloads must use https: ${url}`);
|
|
40
|
+
if (!DOWNLOAD_HOSTS.includes(u.hostname) || u.port !== '' || u.username || u.password) {
|
|
41
|
+
throw new AddressError(`Downloads only come from official sources (${DOWNLOAD_HOSTS.join(', ')}), not ${u.host}`);
|
|
42
|
+
}
|
|
43
|
+
return u;
|
|
44
|
+
}
|