@james-pre/mc-admin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE.md +675 -0
  2. package/README.md +15 -0
  3. package/dist/buffers.d.ts +1 -0
  4. package/dist/buffers.js +1 -0
  5. package/dist/cli.d.ts +6 -0
  6. package/dist/cli.js +110 -0
  7. package/dist/common/buffers.d.ts +8 -0
  8. package/dist/common/buffers.js +47 -0
  9. package/dist/common/chunk.d.ts +41 -0
  10. package/dist/common/chunk.js +31 -0
  11. package/dist/common/index.d.ts +7 -0
  12. package/dist/common/index.js +7 -0
  13. package/dist/common/level.d.ts +21 -0
  14. package/dist/common/level.js +16 -0
  15. package/dist/common/log.d.ts +13 -0
  16. package/dist/common/log.js +10 -0
  17. package/dist/common/nbt.d.ts +85 -0
  18. package/dist/common/nbt.js +179 -0
  19. package/dist/common/rcon.d.ts +58 -0
  20. package/dist/common/rcon.js +154 -0
  21. package/dist/common/region.d.ts +52 -0
  22. package/dist/common/region.js +128 -0
  23. package/dist/common/snbt.d.ts +98 -0
  24. package/dist/common/snbt.js +332 -0
  25. package/dist/common/tsconfig.tsbuildinfo +1 -0
  26. package/dist/config.d.ts +20 -0
  27. package/dist/config.js +12 -0
  28. package/dist/index.d.ts +10 -0
  29. package/dist/index.js +10 -0
  30. package/dist/level.d.ts +53 -0
  31. package/dist/level.js +136 -0
  32. package/dist/log.d.ts +18 -0
  33. package/dist/log.js +137 -0
  34. package/dist/main.d.ts +2 -0
  35. package/dist/main.js +9 -0
  36. package/dist/nbt.d.ts +1 -0
  37. package/dist/nbt.js +1 -0
  38. package/dist/prune.d.ts +85 -0
  39. package/dist/prune.js +201 -0
  40. package/dist/rcon.d.ts +12 -0
  41. package/dist/rcon.js +19 -0
  42. package/dist/region.d.ts +1 -0
  43. package/dist/region.js +1 -0
  44. package/dist/snbt.d.ts +14 -0
  45. package/dist/snbt.js +63 -0
  46. package/dist/tsconfig.tsbuildinfo +1 -0
  47. package/dist/utils.d.ts +9 -0
  48. package/dist/utils.js +52 -0
  49. package/package.json +68 -0
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # mc-admin
2
+
3
+ Minecraft server administration CLI.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install -g @james-pre/mc-admin
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```sh
14
+ mc-admin --help
15
+ ```
@@ -0,0 +1 @@
1
+ export * from './common/buffers.js';
@@ -0,0 +1 @@
1
+ export * from './common/buffers.js';
package/dist/cli.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { Command } from 'commander';
2
+ declare const cli: Command<[], {
3
+ config?: string | undefined;
4
+ world?: string | undefined;
5
+ }, {}>;
6
+ export default cli;
package/dist/cli.js ADDED
@@ -0,0 +1,110 @@
1
+ import { Command, InvalidArgumentError, Option } from 'commander';
2
+ import * as io from 'ioium/node';
3
+ import { existsSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { styleText } from 'node:util';
6
+ import { _throw, pick } from 'utilium';
7
+ import { bytes as formatBytes } from 'utilium/format';
8
+ import $pkg from '../package.json' with { type: 'json' };
9
+ import { config, configManager } from './config.js';
10
+ import * as prune from './prune.js';
11
+ const cli = new Command('mc-admin')
12
+ .version($pkg.version)
13
+ .description($pkg.description)
14
+ .option('-C, --config <file>', 'configuration file to use')
15
+ .option('-w, --world <path>', 'override the world path to use')
16
+ .hook('preAction', () => {
17
+ const opts = cli.opts();
18
+ if (opts.config)
19
+ configManager.loadFile(resolve(opts.config), {});
20
+ else
21
+ configManager.loadDefaults();
22
+ if (opts.world)
23
+ configManager.set('world_path', resolve(opts.world));
24
+ });
25
+ const cli_regions = cli.command('regions').alias('region');
26
+ const secondsPattern = /^\d+(\.\d+)?$/, timeOnlyPattern = /^[\d.]+[smh]$/i;
27
+ function seconds(val) {
28
+ if (secondsPattern.test(val))
29
+ return Number(val);
30
+ if (timeOnlyPattern.test(val))
31
+ val = 'T' + val;
32
+ try {
33
+ return Temporal.Duration.from('P' + val.toUpperCase()).total('seconds');
34
+ }
35
+ catch {
36
+ throw new InvalidArgumentError('expected seconds or a duration like 30s, 5m, 2h, or 1DT12H');
37
+ }
38
+ }
39
+ const excludeReasons = {
40
+ excluded: 'protected',
41
+ inhabited: 'inhabited',
42
+ unreadable: 'unparsed chunks',
43
+ conflict: 'destination conflict',
44
+ };
45
+ /** Kept regions are only worth reporting when the reason is something the user didn't ask for. */
46
+ const surprising = ['unreadable', 'conflict'];
47
+ cli_regions
48
+ .command('prune')
49
+ .description('Prune region files')
50
+ .option('-t, --threshold <duration>', 'keep regions with at least this much play time', seconds)
51
+ .addOption(new Option('--move [dir]', 'move pruned region files').conflicts('delete'))
52
+ .addOption(new Option('--delete', 'delete pruned region files').conflicts('move'))
53
+ .option('--atomic', 'Stop at the first failure, leaving the remaining regions untouched')
54
+ .option('-v, --verbose', 'Report every region that is kept, and why')
55
+ .option('--conflicting <mode>', `How to handle conflicts when moving regions (${prune.conflictModes.join(', ')})`, (val) => prune.conflictModes.includes(val)
56
+ ? val
57
+ : _throw(new InvalidArgumentError('Invalid conflict mode')), 'throw')
58
+ .action(async function (options) {
59
+ const threshold = BigInt(Math.round(options.threshold ?? config.prune_threshold) * 20); // seconds -> ticks
60
+ const world = resolve(config.world_path);
61
+ if (!existsSync(world))
62
+ io.exit(`invalid world directory: ${world}`);
63
+ const into = typeof options.move == 'string' ? resolve(options.move) : null;
64
+ const tx = new prune.Transaction(world)
65
+ .on('prepare_error', (err, file) => io.error(styleText('bold', file.path), io.errorText(err)))
66
+ .on('prepare_exclude', (reason, region) => {
67
+ if (!options.verbose && !surprising.includes(reason))
68
+ return;
69
+ const text = `${styleText('dim', region.dimension.id)} ${styleText('bold', region.name)} kept: ${excludeReasons[reason]}`;
70
+ if (surprising.includes(reason))
71
+ io.warn(text);
72
+ else
73
+ io.log(text);
74
+ })
75
+ .on('execute_error', (err, region) => {
76
+ io.error(styleText('dim', region.dimension.id), styleText('bold', region.name), styleText('gray', `(${region.kind})`), io.errorText(err));
77
+ process.exitCode = 1;
78
+ });
79
+ const pruneOpts = { threshold, exclude: config.protected_regions, into, ...pick(options, 'atomic', 'conflicting', 'delete') };
80
+ await tx.prepare(pruneOpts);
81
+ if (!tx.regions.length) {
82
+ io.log('Found no prunable region files.');
83
+ return;
84
+ }
85
+ io.setTableTargetWidth(process.stdout.columns);
86
+ io.table([
87
+ { name: 'Dimension', text: r => r.dimension.id },
88
+ { name: 'Region File', text: r => r.name },
89
+ { name: 'Chunks', text: r => r.chunks, padStart: true },
90
+ { name: 'Size', text: r => formatBytes(r.size), padStart: true },
91
+ { name: 'Max chunk time', text: r => (Number(r.inhabitedTicks) / 1200).toFixed(1) + ' min', padStart: true },
92
+ ], { formatHead: t => styleText('bold', t) }, tx.regions);
93
+ const ioRegions = styleText('blue', tx.regions.length.toString()), ioSize = styleText('blue', formatBytes(tx.pruneSize));
94
+ if (!options.delete && !options.move) {
95
+ io.log('Found', ioRegions, 'prunable regions, totaling', ioSize);
96
+ return;
97
+ }
98
+ io.log('About to', options.delete ? styleText('red', 'DELETE') : styleText('yellow', 'move'), ioRegions, 'regions, totaling', ioSize);
99
+ await io.assertYes();
100
+ const { pruned, freed } = await tx.execute(pruneOpts);
101
+ io.log(options.delete ? 'Deleted' : 'Moved', styleText('blue', pruned.length.toString()), 'regions, freeing', styleText('blue', formatBytes(freed)));
102
+ });
103
+ cli.command('console')
104
+ .alias('con')
105
+ .alias('rcon')
106
+ .alias('rc')
107
+ .action(function () {
108
+ //
109
+ });
110
+ export default cli;
@@ -0,0 +1,8 @@
1
+ /** A view of the same bytes, without copying them. */
2
+ export declare function toBytes(data: BufferSource): Uint8Array<ArrayBuffer>;
3
+ export type CompressionFormat = 'gzip' | 'deflate' | 'deflate-raw';
4
+ export declare function decompress(data: BufferSource, format: CompressionFormat): Promise<Uint8Array<ArrayBuffer>>;
5
+ /** Split text into lines, holding a partial line until the rest of it arrives. */
6
+ export declare function lines(): TransformStream<string, string>;
7
+ /** Split a byte stream into lines of text. */
8
+ export declare function toLines(source: ReadableStream<BufferSource>): ReadableStream<string>;
@@ -0,0 +1,47 @@
1
+ /** A view of the same bytes, without copying them. */
2
+ export function toBytes(data) {
3
+ return ArrayBuffer.isView(data) ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : new Uint8Array(data);
4
+ }
5
+ export async function decompress(data, format) {
6
+ const input = new ReadableStream({
7
+ start(controller) {
8
+ controller.enqueue(toBytes(data));
9
+ controller.close();
10
+ },
11
+ });
12
+ const reader = input.pipeThrough(new DecompressionStream(format)).getReader();
13
+ const parts = [];
14
+ let length = 0;
15
+ for (let next = await reader.read(); !next.done; next = await reader.read()) {
16
+ parts.push(next.value);
17
+ length += next.value.byteLength;
18
+ }
19
+ const result = new Uint8Array(length);
20
+ let offset = 0;
21
+ for (const part of parts) {
22
+ result.set(part, offset);
23
+ offset += part.byteLength;
24
+ }
25
+ return result;
26
+ }
27
+ /** Split text into lines, holding a partial line until the rest of it arrives. */
28
+ export function lines() {
29
+ let partial = '';
30
+ return new TransformStream({
31
+ transform(chunk, controller) {
32
+ partial += chunk;
33
+ const parts = partial.split(/\r?\n/);
34
+ partial = parts.pop();
35
+ for (const part of parts)
36
+ controller.enqueue(part);
37
+ },
38
+ flush(controller) {
39
+ if (partial)
40
+ controller.enqueue(partial);
41
+ },
42
+ });
43
+ }
44
+ /** Split a byte stream into lines of text. */
45
+ export function toLines(source) {
46
+ return source.pipeThrough(new TextDecoderStream()).pipeThrough(lines());
47
+ }
@@ -0,0 +1,41 @@
1
+ import type { Tag } from './nbt.js';
2
+ export declare enum Compression {
3
+ GZip = 1,
4
+ ZLib = 2,
5
+ None = 3,
6
+ LZ4 = 4,
7
+ Custom = 127
8
+ }
9
+ /** Set on a chunk's compression byte when its payload lives in a `c.<x>.<z>.mcc` file instead. */
10
+ export declare const externalFlag = 128;
11
+ /** Where a chunk sits in the file, from the header alone. */
12
+ export interface Entry {
13
+ /** The chunk's index in the header, `x + z * 32`. */
14
+ index: number;
15
+ /** Chunk coordinates within the region, 0 to 31. */
16
+ x: number;
17
+ z: number;
18
+ /** Byte offset of the chunk's record. */
19
+ offset: number;
20
+ /** Length of the chunk's allocation, in sectors. */
21
+ sectors: number;
22
+ /** When the chunk was last written, in epoch seconds; 0 if it never was. */
23
+ timestamp: number;
24
+ }
25
+ export interface Raw extends Entry {
26
+ compression: Compression;
27
+ /** The payload is in a `c.<x>.<z>.mcc` file next to the region, and `data` is empty. */
28
+ external: boolean;
29
+ /** The still-compressed NBT payload. */
30
+ data: Uint8Array<ArrayBuffer>;
31
+ }
32
+ export interface Parsed extends Raw {
33
+ tag: Tag;
34
+ }
35
+ /**
36
+ * Decompress a chunk's NBT payload.
37
+ *
38
+ * @throws When the chunk is external, or compressed with a scheme this can't undo — LZ4 and the
39
+ * `Custom` escape hatch, both of which only a modded server writes.
40
+ */
41
+ export declare function payload(chunk: Raw): Promise<Uint8Array<ArrayBuffer>>;
@@ -0,0 +1,31 @@
1
+ import { decompress } from './buffers.js';
2
+ export var Compression;
3
+ (function (Compression) {
4
+ Compression[Compression["GZip"] = 1] = "GZip";
5
+ Compression[Compression["ZLib"] = 2] = "ZLib";
6
+ Compression[Compression["None"] = 3] = "None";
7
+ Compression[Compression["LZ4"] = 4] = "LZ4";
8
+ Compression[Compression["Custom"] = 127] = "Custom";
9
+ })(Compression || (Compression = {}));
10
+ /** Set on a chunk's compression byte when its payload lives in a `c.<x>.<z>.mcc` file instead. */
11
+ export const externalFlag = 0x80;
12
+ const formats = {
13
+ [Compression.GZip]: 'gzip',
14
+ [Compression.ZLib]: 'deflate',
15
+ };
16
+ /**
17
+ * Decompress a chunk's NBT payload.
18
+ *
19
+ * @throws When the chunk is external, or compressed with a scheme this can't undo — LZ4 and the
20
+ * `Custom` escape hatch, both of which only a modded server writes.
21
+ */
22
+ export async function payload(chunk) {
23
+ if (chunk.external)
24
+ throw new Error(`chunk ${chunk.x},${chunk.z} is stored externally`);
25
+ if (chunk.compression === Compression.None)
26
+ return chunk.data;
27
+ const format = formats[chunk.compression];
28
+ if (!format)
29
+ throw new Error(`chunk ${chunk.x},${chunk.z} uses unsupported compression (${Compression[chunk.compression] ?? chunk.compression})`);
30
+ return await decompress(chunk.data, format);
31
+ }
@@ -0,0 +1,7 @@
1
+ export * as chunk from './chunk.js';
2
+ export * as log from './log.js';
3
+ export * as nbt from './nbt.js';
4
+ export * as rcon from './rcon.js';
5
+ export { Region } from './region.js';
6
+ export * as region from './region.js';
7
+ export * as snbt from './snbt.js';
@@ -0,0 +1,7 @@
1
+ export * as chunk from './chunk.js';
2
+ export * as log from './log.js';
3
+ export * as nbt from './nbt.js';
4
+ export * as rcon from './rcon.js';
5
+ export { Region } from './region.js';
6
+ export * as region from './region.js';
7
+ export * as snbt from './snbt.js';
@@ -0,0 +1,21 @@
1
+ /** The directory each vanilla dimension uses, relative to the level root. */
2
+ export declare const vanillaDimensions: {
3
+ readonly 'minecraft:overworld': "";
4
+ readonly 'minecraft:the_nether': "DIM-1";
5
+ readonly 'minecraft:the_end': "DIM1";
6
+ };
7
+ export declare const vanillaIds: Map<string, string>;
8
+ /** The subdirectories a dimension splits its region files across, all on the same grid. */
9
+ export declare const regionKinds: readonly ["region", "entities", "poi"];
10
+ export type RegionKind = (typeof regionKinds)[number];
11
+ /** A region file's coordinates and location on disk. */
12
+ export interface RegionFile {
13
+ kind: RegionKind;
14
+ /** Region coordinates. */
15
+ x: number;
16
+ z: number;
17
+ name: string;
18
+ path: string;
19
+ }
20
+ /** A id (e.g. for dimensions) with its namespace made explicit, so ids from different sources compare equal. */
21
+ export declare function normalizeId(id: string): string;
@@ -0,0 +1,16 @@
1
+ /** The directory each vanilla dimension uses, relative to the level root. */
2
+ export const vanillaDimensions = {
3
+ 'minecraft:overworld': '',
4
+ 'minecraft:the_nether': 'DIM-1',
5
+ 'minecraft:the_end': 'DIM1',
6
+ };
7
+ export const vanillaIds = new Map(Object.entries(vanillaDimensions)
8
+ .filter(([, dir]) => dir)
9
+ .map(([id, dir]) => [dir, id]));
10
+ /** The subdirectories a dimension splits its region files across, all on the same grid. */
11
+ export const regionKinds = ['region', 'entities', 'poi'];
12
+ /** A id (e.g. for dimensions) with its namespace made explicit, so ids from different sources compare equal. */
13
+ export function normalizeId(id) {
14
+ const lower = id.toLowerCase();
15
+ return lower.includes(':') ? lower : `minecraft:${lower}`;
16
+ }
@@ -0,0 +1,13 @@
1
+ export declare const logLevels: readonly ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"];
2
+ export type LogLevel = (typeof logLevels)[number];
3
+ /** A server log line, `[09:46:08] [Server thread/INFO]: message`. */
4
+ export interface LogLine {
5
+ /** The time of day the line was logged, as `HH:MM:SS`. */
6
+ timestamp: string;
7
+ /** The thread that logged the line. */
8
+ thread: string;
9
+ level: LogLevel;
10
+ message: string;
11
+ }
12
+ /** Parse a server log line, or null when it isn't one. */
13
+ export declare function parse(line: string): LogLine | null;
@@ -0,0 +1,10 @@
1
+ export const logLevels = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL'];
2
+ const linePattern = new RegExp(String.raw `^\[(\d{2}:\d{2}:\d{2})\] \[([^\]]*)\/(${logLevels.join('|')})\]:\s*(.*)$`);
3
+ /** Parse a server log line, or null when it isn't one. */
4
+ export function parse(line) {
5
+ const match = linePattern.exec(line);
6
+ if (!match)
7
+ return null;
8
+ const [, timestamp, thread, level, message] = match;
9
+ return { timestamp, thread, level: level, message };
10
+ }
@@ -0,0 +1,85 @@
1
+ export declare enum TagType {
2
+ End = 0,
3
+ Byte = 1,
4
+ Short = 2,
5
+ Int = 3,
6
+ Long = 4,
7
+ Float = 5,
8
+ Double = 6,
9
+ ByteArray = 7,
10
+ String = 8,
11
+ List = 9,
12
+ Compound = 10,
13
+ IntArray = 11,
14
+ LongArray = 12
15
+ }
16
+ /** A compound's children, keyed by tag name. */
17
+ export type Compound = Map<string, Tag>;
18
+ /** The value carried by a tag of each type. */
19
+ export interface TagValues {
20
+ [TagType.End]: never;
21
+ [TagType.Byte]: number;
22
+ [TagType.Short]: number;
23
+ [TagType.Int]: number;
24
+ [TagType.Long]: bigint;
25
+ [TagType.Float]: number;
26
+ [TagType.Double]: number;
27
+ [TagType.ByteArray]: Int8Array;
28
+ [TagType.String]: string;
29
+ [TagType.List]: Tag[];
30
+ [TagType.Compound]: Compound;
31
+ [TagType.IntArray]: Int32Array;
32
+ [TagType.LongArray]: BigInt64Array;
33
+ }
34
+ export interface TagOf<T extends TagType> {
35
+ type: T;
36
+ value: TagValues[T];
37
+ }
38
+ export interface ListTag extends TagOf<TagType.List> {
39
+ /** The type shared by every item. `End` when the list is empty. */
40
+ of: TagType;
41
+ }
42
+ /** Every type that can appear as a value. `End` only ever terminates a compound. */
43
+ export type ValueType = Exclude<TagType, TagType.End | TagType.List>;
44
+ export type Tag = {
45
+ [T in ValueType]: TagOf<T>;
46
+ }[ValueType] | ListTag;
47
+ /** A root tag together with its name, which is usually empty. */
48
+ export interface Named {
49
+ name: string;
50
+ tag: Tag;
51
+ }
52
+ /**
53
+ * Walks an NBT payload one tag at a time.
54
+ * @internal
55
+ */
56
+ export declare class Reader {
57
+ protected readonly view: DataView;
58
+ protected readonly bytes: Uint8Array;
59
+ offset: number;
60
+ constructor(data: BufferSource);
61
+ protected int8(): number;
62
+ protected uint8(): number;
63
+ protected int16(): number;
64
+ protected int32(): number;
65
+ protected int64(): bigint;
66
+ protected float32(): number;
67
+ protected float64(): number;
68
+ /** A length-prefixed name or string value. */
69
+ string(): string;
70
+ /** The type byte introducing the next tag. */
71
+ type(): TagType;
72
+ /** The body of a tag whose type byte has already been read. */
73
+ tag(type: TagType): Tag;
74
+ }
75
+ /** Parse an uncompressed NBT payload. */
76
+ export declare function parse(data: BufferSource): Named;
77
+ /**
78
+ * Parse an NBT file, decompressing it first when it needs it.
79
+ */
80
+ export declare function parseCompressed(data: BufferSource): Promise<Named>;
81
+ /**
82
+ * Follow a path of compound keys and list indices, giving up rather than throwing when any step
83
+ * is missing or the wrong type.
84
+ */
85
+ export declare function get(tag: Tag | null, ...path: (string | number)[]): Tag | null;
@@ -0,0 +1,179 @@
1
+ import { decodeUTF8 } from 'utilium';
2
+ import { decompress, toBytes } from './buffers.js';
3
+ export var TagType;
4
+ (function (TagType) {
5
+ TagType[TagType["End"] = 0] = "End";
6
+ TagType[TagType["Byte"] = 1] = "Byte";
7
+ TagType[TagType["Short"] = 2] = "Short";
8
+ TagType[TagType["Int"] = 3] = "Int";
9
+ TagType[TagType["Long"] = 4] = "Long";
10
+ TagType[TagType["Float"] = 5] = "Float";
11
+ TagType[TagType["Double"] = 6] = "Double";
12
+ TagType[TagType["ByteArray"] = 7] = "ByteArray";
13
+ TagType[TagType["String"] = 8] = "String";
14
+ TagType[TagType["List"] = 9] = "List";
15
+ TagType[TagType["Compound"] = 10] = "Compound";
16
+ TagType[TagType["IntArray"] = 11] = "IntArray";
17
+ TagType[TagType["LongArray"] = 12] = "LongArray";
18
+ })(TagType || (TagType = {}));
19
+ /**
20
+ * Walks an NBT payload one tag at a time.
21
+ * @internal
22
+ */
23
+ export class Reader {
24
+ view;
25
+ bytes;
26
+ offset = 0;
27
+ constructor(data) {
28
+ this.bytes = toBytes(data);
29
+ this.view = new DataView(this.bytes.buffer, this.bytes.byteOffset, this.bytes.byteLength);
30
+ }
31
+ int8() {
32
+ return this.view.getInt8(this.offset++);
33
+ }
34
+ uint8() {
35
+ return this.view.getUint8(this.offset++);
36
+ }
37
+ int16() {
38
+ const value = this.view.getInt16(this.offset);
39
+ this.offset += 2;
40
+ return value;
41
+ }
42
+ int32() {
43
+ const value = this.view.getInt32(this.offset);
44
+ this.offset += 4;
45
+ return value;
46
+ }
47
+ int64() {
48
+ const value = this.view.getBigInt64(this.offset);
49
+ this.offset += 8;
50
+ return value;
51
+ }
52
+ float32() {
53
+ const value = this.view.getFloat32(this.offset);
54
+ this.offset += 4;
55
+ return value;
56
+ }
57
+ float64() {
58
+ const value = this.view.getFloat64(this.offset);
59
+ this.offset += 8;
60
+ return value;
61
+ }
62
+ /** A length-prefixed name or string value. */
63
+ string() {
64
+ const length = this.view.getUint16(this.offset);
65
+ this.offset += 2;
66
+ if (this.offset + length > this.bytes.byteLength)
67
+ throw new RangeError(`string runs past the end of the payload at ${this.offset}`);
68
+ const value = decodeUTF8(this.bytes.subarray(this.offset, this.offset + length));
69
+ this.offset += length;
70
+ return value;
71
+ }
72
+ /** The type byte introducing the next tag. */
73
+ type() {
74
+ const type = this.uint8();
75
+ if (!(type in TagType))
76
+ throw new TypeError(`bad tag ${type} at ${this.offset - 1}`);
77
+ return type;
78
+ }
79
+ /** The body of a tag whose type byte has already been read. */
80
+ tag(type) {
81
+ switch (type) {
82
+ case TagType.Byte:
83
+ return { type, value: this.int8() };
84
+ case TagType.Short:
85
+ return { type, value: this.int16() };
86
+ case TagType.Int:
87
+ return { type, value: this.int32() };
88
+ case TagType.Long:
89
+ return { type, value: this.int64() };
90
+ case TagType.Float:
91
+ return { type, value: this.float32() };
92
+ case TagType.Double:
93
+ return { type, value: this.float64() };
94
+ case TagType.ByteArray: {
95
+ const length = this.int32();
96
+ if (this.offset + length > this.bytes.byteLength)
97
+ throw new RangeError(`byte array runs past the end of the payload`);
98
+ const value = new Int8Array(this.bytes.slice(this.offset, this.offset + length).buffer);
99
+ this.offset += length;
100
+ return { type, value };
101
+ }
102
+ case TagType.String:
103
+ return { type, value: this.string() };
104
+ case TagType.List: {
105
+ const of = this.type();
106
+ const length = this.int32();
107
+ const value = [];
108
+ if (of !== TagType.End)
109
+ for (let i = 0; i < length; i++)
110
+ value.push(this.tag(of));
111
+ return { type, of, value };
112
+ }
113
+ case TagType.Compound: {
114
+ const value = new Map();
115
+ for (let child = this.type(); child !== TagType.End; child = this.type()) {
116
+ const name = this.string();
117
+ value.set(name, this.tag(child));
118
+ }
119
+ return { type, value };
120
+ }
121
+ case TagType.IntArray: {
122
+ const length = this.int32();
123
+ const value = new Int32Array(length);
124
+ for (let i = 0; i < length; i++)
125
+ value[i] = this.int32();
126
+ return { type, value };
127
+ }
128
+ case TagType.LongArray: {
129
+ const length = this.int32();
130
+ const value = new BigInt64Array(length);
131
+ for (let i = 0; i < length; i++)
132
+ value[i] = this.int64();
133
+ return { type, value };
134
+ }
135
+ default:
136
+ throw new TypeError(`${TagType[type]} is not a value at ${this.offset - 1}`);
137
+ }
138
+ }
139
+ }
140
+ /** Parse an uncompressed NBT payload. */
141
+ export function parse(data) {
142
+ const reader = new Reader(data);
143
+ const type = reader.type();
144
+ if (type === TagType.End)
145
+ throw new TypeError('payload is empty');
146
+ return { name: reader.string(), tag: reader.tag(type) };
147
+ }
148
+ /**
149
+ * Parse an NBT file, decompressing it first when it needs it.
150
+ */
151
+ export async function parseCompressed(data) {
152
+ const bytes = toBytes(data);
153
+ if (bytes[0] === 0x1f && bytes[1] === 0x8b)
154
+ return parse(await decompress(bytes, 'gzip'));
155
+ if (bytes[0] === 0x78)
156
+ return parse(await decompress(bytes, 'deflate'));
157
+ return parse(bytes);
158
+ }
159
+ /**
160
+ * Follow a path of compound keys and list indices, giving up rather than throwing when any step
161
+ * is missing or the wrong type.
162
+ */
163
+ export function get(tag, ...path) {
164
+ let current = tag;
165
+ for (const key of path) {
166
+ if (!current)
167
+ return null;
168
+ if (typeof key === 'number') {
169
+ if (current.type !== TagType.List)
170
+ return null;
171
+ current = current.value[key];
172
+ continue;
173
+ }
174
+ if (current.type !== TagType.Compound)
175
+ return null;
176
+ current = current.value.get(key) ?? null;
177
+ }
178
+ return current;
179
+ }