UNPKG

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
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; } }