mc-anvil
Version:
A Typescript library for reading Minecraft Anvil format files and Minecraft NBT format files in the browser.
202 lines (181 loc) • 9.02 kB
text/typescript
import JSZip = require("jszip");
import { associateBy } from "queryz";
import { AnvilParser } from "..";
import { Coordinate3D } from "../anvil/types";
import { RegionFile } from "./types";
const REGION_FORMAT_FILE_NAME = /^r[\.]([\-]?[0-9,]+)[\.]([\-]?[0-9,]+)[\.]mca$/g;
export function isValidRegionFileName(name: string): boolean {
return name.match(REGION_FORMAT_FILE_NAME) !== null;
}
export function parseRegionName(name: string): { x: number, z: number } {
if (!isValidRegionFileName(name)) throw new Error(`${name} is not a valid region file name; expected r.<x>.<z>.mca`);
const m = name.matchAll(REGION_FORMAT_FILE_NAME).next();
return {
x: +m.value[1],
z: +m.value[2]
};
}
/**
* Writes the contents of a user-provided directory entry to an in-memory ZIP file for download.
* @param d the directory entry whose contents should be written.
* @param z the ZIP file to write to.
* @param path the root path within the ZIP file where this directory should be written.
* @param overrideMap optional list of paths to manually override with custom array buffer data.
* @returns a promise which resolves when the ZIP file write is complete.
*/
async function writeDirectoryToZip(d: DirectoryEntry, z: JSZip, path: string, overrideMap?: Map<string, ArrayBuffer>): Promise<void> {
return new Promise((resolve, reject) => {
d.createReader().readEntries(entries => Promise.all<void>(
entries.map(e => {
const thispath = `${path}/${e.name}`;
if (overrideMap?.has(thispath)) // if this path has been manually overridden with data, write that
return new Promise<void>(resolve => {
z.file(thispath, overrideMap.get(thispath)!);
resolve();
});
return e.isDirectory // if here, write the original contents of the directory at this path
? writeDirectoryToZip(e as DirectoryEntry, z, thispath, overrideMap)
: new Promise((resolve, reject) => (e as FileEntry).file(f => {
f.arrayBuffer().then(a => { z.file(thispath, a); resolve(); }).catch(reject)
}));
})
).then(() => resolve()).catch(reject), reject);
});
}
/**
* Provides methods for navigating a Minecraft world save directory. Corresponding region files and the level NBT tag
* can be read, mutated, and saved.
*/
export class SaveParser {
private root: DirectoryEntry;
private cachedRegions: Map<string, RegionFile> = new Map([]);
private dirtyRegions: Map<string, AnvilParser> = new Map([]);
/**
* Constructs a save parser from a directory entry uploaded to the browser.
* @param root directory entry containing the world.
*/
constructor(root: DirectoryEntry) {
this.root = root;
}
/**
* Asynchronously retrieves region files from the world save directory.
* @returns a list of region files, each with coordinates and a corresponding file object.
*/
async getRegions(): Promise<RegionFile[]> {
const allEntries: RegionFile[] = [];
const readEntriesRecursively = (directory: DirectoryEntry) => (
new Promise<void>((resolve, reject) => {
const reader = directory.createReader();
const readBatch = () => {
reader.readEntries(
entries => {
if (entries.length === 0) { // No more entries to read, resolve the promise
resolve();
return;
}
allEntries.push(
...entries
.filter(x => x.isFile && isValidRegionFileName(x.name))
.map(x => ({
...parseRegionName(x.name),
file: x as FileEntry,
}))
);
readBatch(); // Continue reading the next batch of entries
},
error => reject(error)
);
};
// Start reading the first batch of entries
readBatch();
})
);
return new Promise<RegionFile[]>((resolve, reject) => {
this.root.getDirectory(
"region",
undefined,
(regionDirectory) => {
readEntriesRecursively(regionDirectory).then(() => {
resolve(allEntries);
}).catch(reject);
},
reject
);
});
}
/**
* Asynchronously retrieves a file entry corresponding to the world's level NBT tag.
* @returns a file entry containing the level NBT tag.
*/
async getLevel(): Promise<FileEntry> {
return new Promise( (resolve, reject) => {
this.root.getFile("level.dat", undefined, resolve, reject);
});
}
/**
* Returns an region file reference for the region within this world containing the given coordinate.
* If the region does not exist (i.e. it has not yet been rendered), the function returns undefined.
* @param coordinate the coordinates for which to retrive the parser.
* @returns a region file reference with the region data if the region exists; undefined otherwise.
*/
async getRegionFileContainingCoordinate(coordinate: Coordinate3D): Promise<RegionFile | undefined> {
if (this.cachedRegions.size === 0) this.cachedRegions = associateBy(await this.getRegions(), x => `${x.x},${x.z}`, x => x);
const x = Math.floor(coordinate[0] / 512);
const z = Math.floor(coordinate[2] / 512);
return this.cachedRegions.get(`${x},${z}`);
}
/**
* Returns an Anvil parser for reading and mutating the region within this world containing the given coordinate.
* If the region does not exist (i.e. it has not yet been rendered), the function returns undefined.
* @param coordinate the coordinates for which to retrive the parser.
* @returns a parser with the region data if the region exists; undefined otherwise.
*/
async getAnvilParserByCoordinate(coordinate: Coordinate3D): Promise<AnvilParser | undefined> {
/* get the region file for the given coordinates if it exists */
const region = await this.getRegionFileContainingCoordinate(coordinate);
if (!region) return;
const x = region.x;
const z = region.z;
/* get and cache the anvil parser for the region */
return new Promise<AnvilParser>((resolve, reject) => {
if (this.dirtyRegions.get(`${x},${z}`)) resolve(this.dirtyRegions.get(`${x},${z}`)!);
region.file.file(f => f.arrayBuffer().then(xx => {
const parser = new AnvilParser(xx);
this.dirtyRegions.set(`${x},${z}`, parser);
resolve(parser);
}).catch(reject), reject);
});
}
/**
* Places a new block at the specified coordinates within this world. If the region containing the coordinate does
* not exist within this world (i.e. it has not yet been rendered), no action will be taken.
* @param coordinates the coordinates at which to place the block.
* @param name the name of the block to place.
* @param properties key-value map of properties for the block.
*/
async setBlock(coordinates: Coordinate3D, name: string, properties: { [key: string]: string }) {
(await this.getAnvilParserByCoordinate(coordinates))?.setBlock(coordinates, name, properties);
}
/**
* Fetches a block from the specified coordinates within this world. If the region containing the coordinate does
* not exist within this world (i.e. it has not yet been rendered), undefined will be returned.
* @param coordinates the coordinates from which to fetch the block.
* @returns object containing the name and key-value properties of the block if the region is present; undefined otherwise.
*/
async getBlock(coordinates: Coordinate3D) {
return (await this.getAnvilParserByCoordinate(coordinates))?.getBlock(coordinates);
}
/**
* Writes this file, with any updates to regions and chunks reflected, to an in-memory ZIP file for download.
*/
async asZip(): Promise<JSZip> {
const dirtyRegions = [ ...this.dirtyRegions.keys() ];
const zip = new JSZip();
const overrideMap = new Map(dirtyRegions.map(key => [
`/region/r.${key.replace(/,/g, '.')}.mca`,
this.dirtyRegions.get(key)!.buffer()
]));
await writeDirectoryToZip(this.root, zip, "", overrideMap);
return zip;
}
}