wip saving to disk / chunk async
This commit is contained in:
parent
42cef0a838
commit
860c8f4866
14 changed files with 442 additions and 82 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
build/
|
build/
|
||||||
bundle/
|
bundle/
|
||||||
|
world/
|
|
@ -7,6 +7,12 @@ export class Reader {
|
||||||
this.offset = 0;
|
this.offset = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public readBuffer(bytes:number) {
|
||||||
|
const value = this.buffer.subarray(this.offset, this.offset + bytes);
|
||||||
|
this.offset += bytes;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
public readByte() {
|
public readByte() {
|
||||||
const value = this.buffer.readInt8(this.offset);
|
const value = this.buffer.readInt8(this.offset);
|
||||||
this.offset++;
|
this.offset++;
|
||||||
|
|
|
@ -2,5 +2,7 @@
|
||||||
"port": 25565,
|
"port": 25565,
|
||||||
"onlineMode": false,
|
"onlineMode": false,
|
||||||
"maxPlayers": 20,
|
"maxPlayers": 20,
|
||||||
"seed": "really janky"
|
"seed": "really janky",
|
||||||
|
"saveCompression": "DEFLATE",
|
||||||
|
"worldName": "world"
|
||||||
}
|
}
|
|
@ -3,4 +3,6 @@ export interface Config {
|
||||||
onlineMode: boolean,
|
onlineMode: boolean,
|
||||||
maxPlayers: number,
|
maxPlayers: number,
|
||||||
seed: number|string,
|
seed: number|string,
|
||||||
|
worldName: string,
|
||||||
|
saveCompression: "NONE"
|
||||||
}
|
}
|
45
nibbleArray.ts
Normal file
45
nibbleArray.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
export class NibbleArray {
|
||||||
|
private array:Uint8Array;
|
||||||
|
|
||||||
|
public constructor(size:number|ArrayBuffer|Uint8Array) {
|
||||||
|
if (size instanceof ArrayBuffer) {
|
||||||
|
this.array = new Uint8Array(size);
|
||||||
|
} else if (size instanceof Uint8Array) {
|
||||||
|
this.array = new Uint8Array(size);
|
||||||
|
} else {
|
||||||
|
this.array = new Uint8Array(Math.round(size / 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can determine which side of the byte to read
|
||||||
|
// from if the halved index has a remainder.
|
||||||
|
private isLowOrHighNibble(index:number) {
|
||||||
|
return index % 1 !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(index:number) {
|
||||||
|
index = index / 2;
|
||||||
|
|
||||||
|
const arrayIndex = index | 0;
|
||||||
|
if (this.isLowOrHighNibble(index)) {
|
||||||
|
return this.array[arrayIndex] >> 4;
|
||||||
|
} else {
|
||||||
|
return this.array[arrayIndex] & 0x0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public set(index:number, value:number) {
|
||||||
|
index = index / 2;
|
||||||
|
|
||||||
|
const arrayIndex = index | 0;
|
||||||
|
if (this.isLowOrHighNibble(index)) {
|
||||||
|
this.array[arrayIndex] = value << 4 | this.array[arrayIndex] & 0xf;
|
||||||
|
} else {
|
||||||
|
this.array[arrayIndex] = this.array[arrayIndex] & 0xf0 | value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public toBuffer() {
|
||||||
|
return Buffer.from(this.array);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import { FunkyArray } from "../funkyArray";
|
import { FunkyArray } from "../funkyArray";
|
||||||
|
import { NibbleArray } from "../nibbleArray";
|
||||||
import { Player } from "./entities/Player";
|
import { Player } from "./entities/Player";
|
||||||
import { World } from "./World";
|
import { World } from "./World";
|
||||||
|
|
||||||
|
@ -9,31 +10,65 @@ export class Chunk {
|
||||||
public readonly z:number;
|
public readonly z:number;
|
||||||
public readonly playersInChunk:FunkyArray<number, Player>;
|
public readonly playersInChunk:FunkyArray<number, Player>;
|
||||||
|
|
||||||
|
public savingToDisk:boolean = false;
|
||||||
|
public forceLoaded:boolean = false;
|
||||||
|
|
||||||
private blocks:Uint8Array;
|
private blocks:Uint8Array;
|
||||||
|
private metadata:NibbleArray;
|
||||||
|
|
||||||
public static CreateCoordPair(x:number, z:number) {
|
public static CreateCoordPair(x:number, z:number) {
|
||||||
return (x >= 0 ? 0 : 2147483648) | (x & 0x7fff) << 16 | (z >= 0 ? 0 : 0x8000) | z & 0x7fff;
|
return (x >= 0 ? 0 : 2147483648) | (x & 0x7fff) << 16 | (z >= 0 ? 0 : 0x8000) | z & 0x7fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
public constructor(world:World, x:number, z:number) {
|
public constructor(world:World, x:number, z:number, generateOrBlockData?:boolean|ArrayBuffer, metadata?:ArrayBuffer) {
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.x = x;
|
this.x = x;
|
||||||
this.z = z;
|
this.z = z;
|
||||||
this.playersInChunk = new FunkyArray<number, Player>();
|
this.playersInChunk = new FunkyArray<number, Player>();
|
||||||
|
|
||||||
this.blocks = new Uint8Array(16 * 16 * this.MAX_HEIGHT);
|
if (generateOrBlockData instanceof ArrayBuffer && metadata instanceof ArrayBuffer) {
|
||||||
|
this.blocks = new Uint8Array(generateOrBlockData);
|
||||||
|
this.metadata = new NibbleArray(metadata);
|
||||||
|
} else {
|
||||||
|
this.blocks = new Uint8Array(16 * 16 * this.MAX_HEIGHT);
|
||||||
|
this.metadata = new NibbleArray(16 * 16 * this.MAX_HEIGHT);
|
||||||
|
|
||||||
this.world.generator.generate(this);
|
if (generateOrBlockData) {
|
||||||
|
this.world.generator.generate(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setBlock(blockId:number, x:number, y:number, z:number) {
|
public setBlock(blockId:number, x:number, y:number, z:number) {
|
||||||
|
if (x < 0 || x > 15 || y < 0 || y > 127 || z < 0 || z > 15) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.blocks[x << 11 | z << 7 | y] = blockId;
|
this.blocks[x << 11 | z << 7 | y] = blockId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setBlockWithMetadata(blockId:number, metadata:number, x:number, y:number, z:number) {
|
||||||
|
if (x < 0 || x > 15 || y < 0 || y > 127 || z < 0 || z > 15) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
x = x << 11 | z << 7 | y;
|
||||||
|
|
||||||
|
this.blocks[x] = blockId;
|
||||||
|
this.metadata.set(x, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
public getBlockId(x:number, y:number, z:number) {
|
public getBlockId(x:number, y:number, z:number) {
|
||||||
return this.blocks[x << 11 | z << 7 | y];
|
return this.blocks[x << 11 | z << 7 | y];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getBlockMetadata(x:number, y:number, z:number) {
|
||||||
|
return this.metadata.get(x << 11 | z << 7 | y);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMetadataBuffer() {
|
||||||
|
return this.metadata.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
public getData() {
|
public getData() {
|
||||||
return this.blocks;
|
return this.blocks;
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { PacketSpawnPosition } from "./packets/SpawnPosition";
|
||||||
import { PacketPlayerPositionLook } from "./packets/PlayerPositionLook";
|
import { PacketPlayerPositionLook } from "./packets/PlayerPositionLook";
|
||||||
import { PacketChat } from "./packets/Chat";
|
import { PacketChat } from "./packets/Chat";
|
||||||
import { PacketNamedEntitySpawn } from "./packets/NamedEntitySpawn";
|
import { PacketNamedEntitySpawn } from "./packets/NamedEntitySpawn";
|
||||||
|
import { WorldSaveManager } from "./WorldSaveManager";
|
||||||
|
|
||||||
export class MinecraftServer {
|
export class MinecraftServer {
|
||||||
private static readonly PROTOCOL_VERSION = 14;
|
private static readonly PROTOCOL_VERSION = 14;
|
||||||
|
@ -28,6 +29,7 @@ export class MinecraftServer {
|
||||||
private tickCounter:number = 0;
|
private tickCounter:number = 0;
|
||||||
private clients:FunkyArray<string, MPClient>;
|
private clients:FunkyArray<string, MPClient>;
|
||||||
private worlds:FunkyArray<number, World>;
|
private worlds:FunkyArray<number, World>;
|
||||||
|
public saveManager:WorldSaveManager;
|
||||||
private overworld:World;
|
private overworld:World;
|
||||||
|
|
||||||
// https://stackoverflow.com/a/7616484
|
// https://stackoverflow.com/a/7616484
|
||||||
|
@ -48,27 +50,38 @@ export class MinecraftServer {
|
||||||
public constructor(config:Config) {
|
public constructor(config:Config) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
|
||||||
|
if (this.config.saveCompression === "NONE") {
|
||||||
|
Console.printWarn("=============- WARNING -=============");
|
||||||
|
Console.printWarn(" Chunk compression is disabled. This");
|
||||||
|
Console.printWarn(" will lead to large file sizes!");
|
||||||
|
Console.printWarn("=====================================");
|
||||||
|
}
|
||||||
|
|
||||||
this.clients = new FunkyArray<string, MPClient>();
|
this.clients = new FunkyArray<string, MPClient>();
|
||||||
|
|
||||||
// Convert seed if needed
|
// Convert seed if needed
|
||||||
const worldSeed = typeof(this.config.seed) === "string" ? this.hashCode(this.config.seed) : this.config.seed;
|
let worldSeed = typeof(this.config.seed) === "string" ? this.hashCode(this.config.seed) : this.config.seed;
|
||||||
|
|
||||||
|
// Init save manager and load seed from it if possible
|
||||||
|
this.saveManager = new WorldSaveManager(this.config, worldSeed);
|
||||||
|
if (this.saveManager.worldSeed !== Number.MIN_VALUE) {
|
||||||
|
worldSeed = this.saveManager.worldSeed;
|
||||||
|
}
|
||||||
|
|
||||||
this.worlds = new FunkyArray<number, World>();
|
this.worlds = new FunkyArray<number, World>();
|
||||||
this.worlds.set(0, this.overworld = new World(worldSeed));
|
this.worlds.set(0, this.overworld = new World(this.saveManager, worldSeed));
|
||||||
|
|
||||||
// Generate spawn area (overworld)
|
// Generate spawn area (overworld)
|
||||||
const generateStartTime = Date.now();
|
(async () => {
|
||||||
Console.printInfo("Generating spawn area...");
|
const generateStartTime = Date.now();
|
||||||
let generatedCount = 0;
|
Console.printInfo("Generating spawn area...");
|
||||||
for (let x = -3; x < 3; x++) {
|
for (let x = -3; x < 3; x++) {
|
||||||
for (let z = -3; z < 3; z++) {
|
for (let z = -3; z < 3; z++) {
|
||||||
this.overworld.getChunk(x, z);
|
await this.overworld.getChunkSafe(x, z);
|
||||||
if (generatedCount++ % 5 === 0) {
|
|
||||||
Console.printInfo(`Generating spawn area... ${Math.floor(generatedCount / 36 * 100)}%`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Console.printInfo(`Done! Took ${Date.now() - generateStartTime}ms`);
|
||||||
Console.printInfo(`Done! Took ${Date.now() - generateStartTime}ms`);
|
}).bind(this)();
|
||||||
|
|
||||||
this.serverClock = setInterval(() => {
|
this.serverClock = setInterval(() => {
|
||||||
// Every 1 sec
|
// Every 1 sec
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { FunkyArray } from "../funkyArray";
|
import { FunkyArray } from "../funkyArray";
|
||||||
import { Chunk } from "./Chunk";
|
import { Chunk } from "./Chunk";
|
||||||
|
import { WorldSaveManager } from "./WorldSaveManager";
|
||||||
import { IEntity } from "./entities/IEntity";
|
import { IEntity } from "./entities/IEntity";
|
||||||
import { Player } from "./entities/Player";
|
import { Player } from "./entities/Player";
|
||||||
//import { FlatGenerator } from "./generators/Flat";
|
//import { FlatGenerator } from "./generators/Flat";
|
||||||
|
@ -10,13 +11,17 @@ import { PacketBlockChange } from "./packets/BlockChange";
|
||||||
export class World {
|
export class World {
|
||||||
public static ENTITY_MAX_SEND_DISTANCE = 50;
|
public static ENTITY_MAX_SEND_DISTANCE = 50;
|
||||||
|
|
||||||
|
private readonly saveManager;
|
||||||
|
|
||||||
public chunks:FunkyArray<number, Chunk>;
|
public chunks:FunkyArray<number, Chunk>;
|
||||||
public entites:FunkyArray<number, IEntity>;
|
public entites:FunkyArray<number, IEntity>;
|
||||||
public players:FunkyArray<number, Player>;
|
public players:FunkyArray<number, Player>;
|
||||||
|
|
||||||
public generator:IGenerator;
|
public generator:IGenerator;
|
||||||
|
|
||||||
public constructor(seed:number) {
|
public constructor(saveManager:WorldSaveManager, seed:number) {
|
||||||
|
this.saveManager = saveManager;
|
||||||
|
|
||||||
this.chunks = new FunkyArray<number, Chunk>();
|
this.chunks = new FunkyArray<number, Chunk>();
|
||||||
this.entites = new FunkyArray<number, IEntity>();
|
this.entites = new FunkyArray<number, IEntity>();
|
||||||
this.players = new FunkyArray<number, Player>();
|
this.players = new FunkyArray<number, Player>();
|
||||||
|
@ -37,7 +42,7 @@ export class World {
|
||||||
const chunk = this.getChunkByCoordPair(coordPair);
|
const chunk = this.getChunkByCoordPair(coordPair);
|
||||||
chunk.playersInChunk.remove(entity.entityId);
|
chunk.playersInChunk.remove(entity.entityId);
|
||||||
|
|
||||||
if (chunk.playersInChunk.length === 0) {
|
if (!chunk.forceLoaded && chunk.playersInChunk.length === 0) {
|
||||||
this.unloadChunk(coordPair);
|
this.unloadChunk(coordPair);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -52,11 +57,36 @@ export class World {
|
||||||
const coordPair = Chunk.CreateCoordPair(x, z);
|
const coordPair = Chunk.CreateCoordPair(x, z);
|
||||||
const existingChunk = this.chunks.get(coordPair);
|
const existingChunk = this.chunks.get(coordPair);
|
||||||
if (!(existingChunk instanceof Chunk)) {
|
if (!(existingChunk instanceof Chunk)) {
|
||||||
if (generate) {
|
throw new Error(`BADLOOKUP: Chunk [${x}, ${z}] does not exist.`);
|
||||||
return this.chunks.set(coordPair, new Chunk(this, x, z));
|
}
|
||||||
|
|
||||||
|
return existingChunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getChunkSafe(x:number, z:number) {
|
||||||
|
return new Promise<Chunk>((resolve, reject) => {
|
||||||
|
const coordPair = Chunk.CreateCoordPair(x, z);
|
||||||
|
const existingChunk = this.chunks.get(coordPair);
|
||||||
|
if (!(existingChunk instanceof Chunk)) {
|
||||||
|
if (this.saveManager.chunksOnDisk.includes(coordPair)) {
|
||||||
|
return this.saveManager.readChunkFromDisk(this, x, z)
|
||||||
|
.then(chunk => {
|
||||||
|
//console.log("Loaded " + x + "," + z + " from disk");
|
||||||
|
resolve(this.chunks.set(coordPair, chunk));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return resolve(this.chunks.set(coordPair, new Chunk(this, x, z, true)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`BADLOOKUP: Chunk [${x}, ${z}] does not exist.`);
|
resolve(existingChunk);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getChunkByCoordPair(coordPair:number) {
|
||||||
|
const existingChunk = this.chunks.get(coordPair);
|
||||||
|
if (!(existingChunk instanceof Chunk)) {
|
||||||
|
throw new Error(`BADLOOKUP: Chunk ${coordPair} does not exist.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return existingChunk;
|
return existingChunk;
|
||||||
|
@ -93,18 +123,22 @@ export class World {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public getChunkByCoordPair(coordPair:number) {
|
public async unloadChunk(coordPair:number) {
|
||||||
const existingChunk = this.chunks.get(coordPair);
|
const chunk = this.getChunkByCoordPair(coordPair);
|
||||||
if (!(existingChunk instanceof Chunk)) {
|
if (!chunk.savingToDisk) {
|
||||||
throw new Error(`BADLOOKUP: Chunk ${coordPair} does not exist.`);
|
chunk.savingToDisk = true;
|
||||||
|
|
||||||
|
await this.saveManager.writeChunkToDisk(chunk);
|
||||||
|
|
||||||
|
if (chunk.playersInChunk.length === 0) {
|
||||||
|
this.chunks.remove(coordPair);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A player loaded the chunk while we were, flushing to disk.
|
||||||
|
// Keep it loaded.
|
||||||
|
chunk.savingToDisk = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return existingChunk;
|
|
||||||
}
|
|
||||||
|
|
||||||
public unloadChunk(coordPair:number) {
|
|
||||||
// TODO: Save to disk
|
|
||||||
this.chunks.remove(coordPair);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public tick() {
|
public tick() {
|
||||||
|
@ -116,7 +150,7 @@ export class World {
|
||||||
for (const coordPair of entity.justUnloaded) {
|
for (const coordPair of entity.justUnloaded) {
|
||||||
const chunkToUnload = this.getChunkByCoordPair(coordPair);
|
const chunkToUnload = this.getChunkByCoordPair(coordPair);
|
||||||
chunkToUnload.playersInChunk.remove(entity.entityId);
|
chunkToUnload.playersInChunk.remove(entity.entityId);
|
||||||
if (chunkToUnload.playersInChunk.length === 0) {
|
if (!chunkToUnload.forceLoaded && chunkToUnload.playersInChunk.length === 0) {
|
||||||
this.unloadChunk(coordPair);
|
this.unloadChunk(coordPair);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
174
server/WorldSaveManager.ts
Normal file
174
server/WorldSaveManager.ts
Normal file
|
@ -0,0 +1,174 @@
|
||||||
|
import { readFileSync, readFile, writeFile, existsSync, mkdirSync, writeFileSync, readdirSync } from "fs";
|
||||||
|
import { Reader, Writer } from "../bufferStuff";
|
||||||
|
import { Config } from "../config";
|
||||||
|
import { Chunk } from "./Chunk";
|
||||||
|
import { SaveCompressionType } from "./enums/SaveCompressionType";
|
||||||
|
import { deflate, inflate } from "zlib";
|
||||||
|
import { World } from "./World";
|
||||||
|
|
||||||
|
export class WorldSaveManager {
|
||||||
|
private readonly worldFolderPath;
|
||||||
|
private readonly worldChunksFolderPath;
|
||||||
|
private readonly worldPlayerDataFolderPath;
|
||||||
|
private readonly infoFilePath;
|
||||||
|
|
||||||
|
private readonly config:Config;
|
||||||
|
|
||||||
|
public worldCreationDate = new Date();
|
||||||
|
public worldLastLoadDate = new Date();
|
||||||
|
public worldSeed = Number.MIN_VALUE;
|
||||||
|
|
||||||
|
public chunksOnDisk:Array<number>;
|
||||||
|
|
||||||
|
public constructor(config:Config, numericalSeed:number) {
|
||||||
|
this.chunksOnDisk = new Array<number>();
|
||||||
|
|
||||||
|
this.worldFolderPath = `./${config.worldName}`;
|
||||||
|
this.worldChunksFolderPath = `${this.worldFolderPath}/chunks`;
|
||||||
|
this.worldPlayerDataFolderPath = `${this.worldFolderPath}/playerdata`;
|
||||||
|
this.infoFilePath = `${this.worldFolderPath}/info.hwd`;
|
||||||
|
|
||||||
|
this.config = config;
|
||||||
|
|
||||||
|
// Create world folder if it doesn't exist
|
||||||
|
if (!existsSync(this.worldFolderPath)) {
|
||||||
|
mkdirSync(this.worldFolderPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(this.infoFilePath)) {
|
||||||
|
this.readInfoFile();
|
||||||
|
} else {
|
||||||
|
// World info file does not exist
|
||||||
|
this.worldSeed = numericalSeed;
|
||||||
|
this.createInfoFile(numericalSeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(this.worldChunksFolderPath)) {
|
||||||
|
mkdirSync(this.worldChunksFolderPath);
|
||||||
|
} else {
|
||||||
|
const chunkFiles = readdirSync(this.worldChunksFolderPath);
|
||||||
|
for (let file of chunkFiles) {
|
||||||
|
if (file.endsWith(".hwc")) {
|
||||||
|
const numbers = file.split(".")[0].split(",");
|
||||||
|
this.chunksOnDisk.push(Chunk.CreateCoordPair(parseInt(numbers[0]), parseInt(numbers[1])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(this.worldPlayerDataFolderPath)) {
|
||||||
|
mkdirSync(this.worldPlayerDataFolderPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createInfoFile(numericalSeed:number) {
|
||||||
|
const infoFileWriter = new Writer(26);
|
||||||
|
infoFileWriter.writeUByte(0xFD); // Info File Magic
|
||||||
|
infoFileWriter.writeUByte(0); // File Version
|
||||||
|
infoFileWriter.writeLong(this.worldCreationDate.getTime()); // World creation date
|
||||||
|
infoFileWriter.writeLong(this.worldLastLoadDate.getTime()); // Last load date
|
||||||
|
infoFileWriter.writeLong(numericalSeed);
|
||||||
|
writeFileSync(this.infoFilePath, infoFileWriter.toBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
private readInfoFile() {
|
||||||
|
const infoFileReader = new Reader(readFileSync(this.infoFilePath));
|
||||||
|
const fileMagic = infoFileReader.readUByte();
|
||||||
|
if (fileMagic !== 0xFD) {
|
||||||
|
throw new Error("World info file is invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileVersion = infoFileReader.readByte();
|
||||||
|
if (fileVersion === 0) {
|
||||||
|
this.worldCreationDate = new Date(Number(infoFileReader.readLong()));
|
||||||
|
infoFileReader.readLong(); // Last load time is currently ignored
|
||||||
|
this.worldSeed = Number(infoFileReader.readLong());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public writeChunkToDisk(chunk:Chunk) {
|
||||||
|
return new Promise<boolean>((resolve, reject) => {
|
||||||
|
const saveType = SaveCompressionType[this.config.saveCompression];
|
||||||
|
const chunkFileWriter = new Writer(10);
|
||||||
|
chunkFileWriter.writeUByte(0xFC); // Chunk File Magic
|
||||||
|
chunkFileWriter.writeUByte(0); // File Version
|
||||||
|
chunkFileWriter.writeUByte(saveType); // Save compression type
|
||||||
|
chunkFileWriter.writeUByte(16); // Chunk X
|
||||||
|
chunkFileWriter.writeUByte(128); // Chunk Y
|
||||||
|
chunkFileWriter.writeUByte(16); // Chunk Z
|
||||||
|
|
||||||
|
const chunkData = new Writer().writeBuffer(Buffer.from(chunk.getData())).writeBuffer(chunk.getMetadataBuffer()).toBuffer();
|
||||||
|
|
||||||
|
if (saveType === SaveCompressionType.NONE) {
|
||||||
|
chunkFileWriter.writeInt(chunkData.length); // Data length
|
||||||
|
chunkFileWriter.writeBuffer(chunkData); // Chunk data
|
||||||
|
|
||||||
|
writeFile(`${this.worldChunksFolderPath}/${chunk.x},${chunk.z}.hwc`, chunkFileWriter.toBuffer(), () => {
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
} else if (saveType === SaveCompressionType.DEFLATE) {
|
||||||
|
deflate(chunkData, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkFileWriter.writeInt(data.length);
|
||||||
|
chunkFileWriter.writeBuffer(data);
|
||||||
|
|
||||||
|
writeFile(`${this.worldChunksFolderPath}/${chunk.x},${chunk.z}.hwc`, chunkFileWriter.toBuffer(), () => {
|
||||||
|
const cPair = Chunk.CreateCoordPair(chunk.x, chunk.z);
|
||||||
|
if (!this.chunksOnDisk.includes(cPair)) {
|
||||||
|
this.chunksOnDisk.push(cPair);
|
||||||
|
}
|
||||||
|
//console.log(`Wrote ${chunk.x},${chunk.z} to disk`);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
} else if (saveType === SaveCompressionType.XZ) {
|
||||||
|
// TODO: Implement XZ chunk saving
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
readChunkFromDisk(world:World, x:number, z:number) {
|
||||||
|
return new Promise<Chunk>((resolve, reject) => {
|
||||||
|
readFile(`${this.worldChunksFolderPath}/${x},${z}.hwc`, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkFileReader = new Reader(data);
|
||||||
|
|
||||||
|
// Check file validity
|
||||||
|
if (chunkFileReader.readUByte() !== 0xFC) {
|
||||||
|
return reject(new Error("Chunk file is invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileVersion = chunkFileReader.readUByte();
|
||||||
|
if (fileVersion === 0) {
|
||||||
|
const saveCompressionType:SaveCompressionType = chunkFileReader.readUByte();
|
||||||
|
const chunkX = chunkFileReader.readUByte();
|
||||||
|
const chunkY = chunkFileReader.readUByte();
|
||||||
|
const chunkZ = chunkFileReader.readUByte();
|
||||||
|
const totalByteSize = chunkX * chunkZ * chunkY;
|
||||||
|
|
||||||
|
const contentLength = chunkFileReader.readInt();
|
||||||
|
if (saveCompressionType === SaveCompressionType.NONE) {
|
||||||
|
const chunkData = new Reader(chunkFileReader.readBuffer(contentLength));
|
||||||
|
const chunk = new Chunk(world, x, z, chunkData.readBuffer(totalByteSize).buffer, chunkData.readBuffer(totalByteSize / 2).buffer);
|
||||||
|
resolve(chunk);
|
||||||
|
} else if (saveCompressionType === SaveCompressionType.DEFLATE) {
|
||||||
|
inflate(chunkFileReader.readBuffer(contentLength), (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkData = new Reader(data);
|
||||||
|
const chunk = new Chunk(world, x, z, chunkData.readBuffer(totalByteSize).buffer, chunkData.readBuffer(totalByteSize / 2).buffer);
|
||||||
|
resolve(chunk);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,18 +10,15 @@ export class Block {
|
||||||
static readonly grass = new Block(2);
|
static readonly grass = new Block(2);
|
||||||
static readonly dirt = new Block(3);
|
static readonly dirt = new Block(3);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
static readonly bedrock = new Block(7);
|
static readonly bedrock = new Block(7);
|
||||||
|
|
||||||
static readonly waterStill = new Block(9);
|
static readonly waterStill = new Block(9);
|
||||||
|
|
||||||
|
static readonly sand = new Block(12);
|
||||||
|
static readonly gravel = new Block(13);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
static readonly wood = new Block(17);
|
static readonly wood = new Block(17);
|
||||||
static readonly leaves = new Block(18);
|
static readonly leaves = new Block(18);
|
||||||
|
|
||||||
|
static readonly clay = new Block(82);
|
||||||
}
|
}
|
|
@ -27,7 +27,7 @@ export class Player extends EntityLiving {
|
||||||
this.z = 8;
|
this.z = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
onTick() {
|
private async updatePlayerChunks() {
|
||||||
const bitX = this.x >> 4;
|
const bitX = this.x >> 4;
|
||||||
const bitZ = this.z >> 4;
|
const bitZ = this.z >> 4;
|
||||||
if (bitX != this.lastX >> 4 || bitZ != this.lastZ >> 4 || this.firstUpdate) {
|
if (bitX != this.lastX >> 4 || bitZ != this.lastZ >> 4 || this.firstUpdate) {
|
||||||
|
@ -35,11 +35,9 @@ export class Player extends EntityLiving {
|
||||||
this.firstUpdate = false;
|
this.firstUpdate = false;
|
||||||
// TODO: Make this based on the player's coords
|
// TODO: Make this based on the player's coords
|
||||||
this.mpClient?.send(new PacketPreChunk(0, 0, true).writeData());
|
this.mpClient?.send(new PacketPreChunk(0, 0, true).writeData());
|
||||||
const chunk = this.world.getChunk(0, 0);
|
const chunk = await this.world.getChunkSafe(0, 0);
|
||||||
(async () => {
|
const chunkData = await (new PacketMapChunk(0, 0, 0, 15, 127, 15, chunk).writeData());
|
||||||
const chunkData = await (new PacketMapChunk(0, 0, 0, 15, 127, 15, chunk).writeData());
|
this.mpClient?.send(chunkData);
|
||||||
this.mpClient?.send(chunkData);
|
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load or keep any chunks we need
|
// Load or keep any chunks we need
|
||||||
|
@ -48,14 +46,12 @@ export class Player extends EntityLiving {
|
||||||
for (let z = bitZ - 6; z < bitZ + 6; z++) {
|
for (let z = bitZ - 6; z < bitZ + 6; z++) {
|
||||||
const coordPair = Chunk.CreateCoordPair(x, z);
|
const coordPair = Chunk.CreateCoordPair(x, z);
|
||||||
if (!this.loadedChunks.includes(coordPair)) {
|
if (!this.loadedChunks.includes(coordPair)) {
|
||||||
const chunk = this.world.getChunk(x, z);
|
const chunk = await this.world.getChunkSafe(x, z);
|
||||||
this.mpClient?.send(new PacketPreChunk(x, z, true).writeData());
|
this.mpClient?.send(new PacketPreChunk(x, z, true).writeData());
|
||||||
this.loadedChunks.push(coordPair);
|
this.loadedChunks.push(coordPair);
|
||||||
chunk.playersInChunk.set(this.entityId, this);
|
chunk.playersInChunk.set(this.entityId, this);
|
||||||
(async () => {
|
const chunkData = await (new PacketMapChunk(x, 0, z, 15, 127, 15, chunk).writeData());
|
||||||
const chunkData = await (new PacketMapChunk(x, 0, z, 15, 127, 15, chunk).writeData());
|
this.mpClient?.send(chunkData);
|
||||||
this.mpClient?.send(chunkData);
|
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
currentLoads.push(coordPair);
|
currentLoads.push(coordPair);
|
||||||
}
|
}
|
||||||
|
@ -73,6 +69,10 @@ export class Player extends EntityLiving {
|
||||||
// Overwrite loaded chunks
|
// Overwrite loaded chunks
|
||||||
this.loadedChunks = currentLoads;
|
this.loadedChunks = currentLoads;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onTick() {
|
||||||
|
this.updatePlayerChunks();
|
||||||
|
|
||||||
super.onTick();
|
super.onTick();
|
||||||
}
|
}
|
||||||
|
|
5
server/enums/SaveCompressionType.ts
Normal file
5
server/enums/SaveCompressionType.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export enum SaveCompressionType {
|
||||||
|
NONE = 0,
|
||||||
|
DEFLATE = 1,
|
||||||
|
XZ = 2
|
||||||
|
}
|
|
@ -5,6 +5,8 @@ import { Noise2D, makeNoise2D } from "../../external/OpenSimplex2D";
|
||||||
|
|
||||||
export class HillyGenerator implements IGenerator {
|
export class HillyGenerator implements IGenerator {
|
||||||
private seed:number;
|
private seed:number;
|
||||||
|
seedGenerator:() => number;
|
||||||
|
|
||||||
private generator:Noise2D;
|
private generator:Noise2D;
|
||||||
private generator1:Noise2D;
|
private generator1:Noise2D;
|
||||||
private generator2:Noise2D;
|
private generator2:Noise2D;
|
||||||
|
@ -14,20 +16,35 @@ export class HillyGenerator implements IGenerator {
|
||||||
private generator6:Noise2D;
|
private generator6:Noise2D;
|
||||||
private oceanGenerator:Noise2D;
|
private oceanGenerator:Noise2D;
|
||||||
private mountainGenerator:Noise2D;
|
private mountainGenerator:Noise2D;
|
||||||
|
private underwaterGravelGenerator:Noise2D;
|
||||||
|
private underwaterSandGenerator:Noise2D;
|
||||||
|
private underwaterClayGenerator:Noise2D;
|
||||||
|
|
||||||
public constructor(seed:number) {
|
public constructor(seed:number) {
|
||||||
this.seed = seed;
|
this.seed = seed;
|
||||||
|
this.seedGenerator = this.mulberry32(this.seed);
|
||||||
|
|
||||||
const generatorSeed = this.mulberry32(this.seed);
|
this.generator = this.createGenerator();
|
||||||
this.generator = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
this.generator1 = this.createGenerator();
|
||||||
this.generator1 = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
this.generator2 = this.createGenerator();
|
||||||
this.generator2 = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
this.generator3 = this.createGenerator();
|
||||||
this.generator3 = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
this.generator4 = this.createGenerator();
|
||||||
this.generator4 = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
this.generator5 = this.createGenerator();
|
||||||
this.generator5 = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
this.generator6 = this.createGenerator();
|
||||||
this.generator6 = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
this.oceanGenerator = this.createGenerator();
|
||||||
this.oceanGenerator = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
this.mountainGenerator = this.createGenerator();
|
||||||
this.mountainGenerator = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
this.underwaterGravelGenerator = this.createGenerator();
|
||||||
|
this.underwaterSandGenerator = this.createGenerator();
|
||||||
|
this.underwaterClayGenerator = this.createGenerator();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createGenerator() {
|
||||||
|
return makeNoise2D(this.seedGenerator() * Number.MAX_SAFE_INTEGER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is soooo much faster than using Math.round in here
|
||||||
|
private fastRound(num:number) {
|
||||||
|
return num >= 0.5 ? (num | 0) + 1 : num | 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://stackoverflow.com/a/47593316
|
// https://stackoverflow.com/a/47593316
|
||||||
|
@ -47,7 +64,7 @@ export class HillyGenerator implements IGenerator {
|
||||||
for (let x = 0; x < 16; x++) {
|
for (let x = 0; x < 16; x++) {
|
||||||
for (let z = 0; z < 16; z++) {
|
for (let z = 0; z < 16; z++) {
|
||||||
const oceanValue = this.oceanGenerator((chunk.x * 16 + x) / 128, (chunk.z * 16 + z) / 128) * 100;
|
const oceanValue = this.oceanGenerator((chunk.x * 16 + x) / 128, (chunk.z * 16 + z) / 128) * 100;
|
||||||
orgColY = colWaterY = colY = 60 + (
|
orgColY = colWaterY = colY = 60 + this.fastRound((
|
||||||
this.generator((chunk.x * 16 + x) / 16, (chunk.z * 16 + z) / 16) * 16 +
|
this.generator((chunk.x * 16 + x) / 16, (chunk.z * 16 + z) / 16) * 16 +
|
||||||
this.generator1((chunk.x * 16 + x) / 16, (chunk.z * 16 + z) / 16) * 16 +
|
this.generator1((chunk.x * 16 + x) / 16, (chunk.z * 16 + z) / 16) * 16 +
|
||||||
this.generator2((chunk.x * 16 + x) / 8, (chunk.z * 16 + z) / 8) * 8 +
|
this.generator2((chunk.x * 16 + x) / 8, (chunk.z * 16 + z) / 8) * 8 +
|
||||||
|
@ -57,9 +74,14 @@ export class HillyGenerator implements IGenerator {
|
||||||
this.generator6((chunk.x * 16 + x) / 16, (chunk.z * 16 + z) / 16) * 16 +
|
this.generator6((chunk.x * 16 + x) / 16, (chunk.z * 16 + z) / 16) * 16 +
|
||||||
oceanValue +
|
oceanValue +
|
||||||
(Math.max(this.mountainGenerator((chunk.x * 16 + x) / 128, (chunk.z * 16 + z) / 128), 0) * 50 + Math.min(oceanValue, 0))
|
(Math.max(this.mountainGenerator((chunk.x * 16 + x) / 128, (chunk.z * 16 + z) / 128), 0) * 50 + Math.min(oceanValue, 0))
|
||||||
) / 9;
|
) / 9);
|
||||||
colDirtMin = colY - 2;
|
colDirtMin = colY - 2;
|
||||||
chunk.setBlock(Block.grass.blockId, x, colY, z);
|
const sandNoise = this.underwaterSandGenerator((chunk.x * 16 + x) / 16, (chunk.z * 16 + z) / 16);
|
||||||
|
if (colY === 59 && sandNoise > 0.5) {
|
||||||
|
chunk.setBlock(Block.sand.blockId, x, colY, z);
|
||||||
|
} else {
|
||||||
|
chunk.setBlock(Block.grass.blockId, x, colY, z);
|
||||||
|
}
|
||||||
|
|
||||||
while (colY-- > 0) {
|
while (colY-- > 0) {
|
||||||
if (colY >= colDirtMin) {
|
if (colY >= colDirtMin) {
|
||||||
|
@ -71,29 +93,55 @@ export class HillyGenerator implements IGenerator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate underwater blocks
|
||||||
if (colWaterY <= 58) {
|
if (colWaterY <= 58) {
|
||||||
chunk.setBlock(Block.dirt.blockId, x, colWaterY, z);
|
if (this.underwaterGravelGenerator((chunk.x * 16 + x) / 16, (chunk.z * 16 + z) / 16) > 0.3) {
|
||||||
|
chunk.setBlock(Block.gravel.blockId, x, colWaterY, z);
|
||||||
|
} else if (sandNoise > 0.4) {
|
||||||
|
chunk.setBlock(Block.sand.blockId, x, colWaterY, z);
|
||||||
|
} else if (this.underwaterClayGenerator((chunk.x * 16 + x) / 16, (chunk.z * 16 + z) / 16) > 0.5) {
|
||||||
|
chunk.setBlock(Block.clay.blockId, x, colWaterY, z);
|
||||||
|
} else {
|
||||||
|
chunk.setBlock(Block.dirt.blockId, x, colWaterY, z);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
while (colWaterY <= 58) {
|
while (colWaterY <= 58) {
|
||||||
colWaterY++;
|
colWaterY++;
|
||||||
chunk.setBlock(Block.waterStill.blockId, x, colWaterY, z);
|
chunk.setBlock(Block.waterStill.blockId, x, colWaterY, z);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Move trees to it's own generator
|
||||||
if (chunk.getBlockId(x, orgColY + 1, z) !== Block.waterStill.blockId && chunk.getBlockId(x, orgColY, z) === Block.grass.blockId && treeRNG() > 0.995) {
|
if (chunk.getBlockId(x, orgColY + 1, z) !== Block.waterStill.blockId && chunk.getBlockId(x, orgColY, z) === Block.grass.blockId && treeRNG() > 0.995) {
|
||||||
|
const treeType = treeRNG() >= 0.5;
|
||||||
chunk.setBlock(Block.dirt.blockId, x, orgColY, z);
|
chunk.setBlock(Block.dirt.blockId, x, orgColY, z);
|
||||||
let tY = orgColY + 1;
|
let tYT = 0, tY = tYT = orgColY + 4 + this.fastRound(treeRNG() - 0.2), tLY = 0;
|
||||||
while (tY < orgColY + 5) {
|
while (tY > orgColY) {
|
||||||
chunk.setBlock(Block.wood.blockId, x, tY, z);
|
chunk.setBlockWithMetadata(Block.wood.blockId, treeType ? 2 : 0, x, tY, z);
|
||||||
|
if (tLY !== 0 && tLY < 3) {
|
||||||
|
for (let tX = -2; tX <= 2; tX++) {
|
||||||
|
for (let tZ = -2; tZ <= 2; tZ++) {
|
||||||
|
if (tX === 0 && tZ === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chunk.setBlockWithMetadata(Block.leaves.blockId, treeType ? 2 : 0, x + tX, tY, z + tZ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tY--;
|
||||||
|
tLY++;
|
||||||
|
}
|
||||||
|
tY = 0;
|
||||||
|
while (tY < 2) {
|
||||||
|
for (let tX = -1; tX < 2; tX++) {
|
||||||
|
for (let tZ = -1; tZ < 2; tZ++) {
|
||||||
|
if (tX === 0 && tZ === 0 && tY !== 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chunk.setBlockWithMetadata(Block.leaves.blockId, treeType ? 2 : 0, x + tX, tYT + tY, z + tZ);
|
||||||
|
}
|
||||||
|
}
|
||||||
tY++;
|
tY++;
|
||||||
}
|
}
|
||||||
chunk.setBlock(Block.leaves.blockId, x - 1, tY - 2, z);
|
|
||||||
chunk.setBlock(Block.leaves.blockId, x + 1, tY - 2, z);
|
|
||||||
chunk.setBlock(Block.leaves.blockId, x, tY - 2, z - 1);
|
|
||||||
chunk.setBlock(Block.leaves.blockId, x, tY - 2, z + 1);
|
|
||||||
chunk.setBlock(Block.leaves.blockId, x - 2, tY - 2, z);
|
|
||||||
chunk.setBlock(Block.leaves.blockId, x + 2, tY - 2, z);
|
|
||||||
chunk.setBlock(Block.leaves.blockId, x, tY - 2, z - 2);
|
|
||||||
chunk.setBlock(Block.leaves.blockId, x, tY - 2, z + 2);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,7 +34,6 @@ export class PacketMapChunk implements IPacket {
|
||||||
public writeData() {
|
public writeData() {
|
||||||
return new Promise<Buffer>((resolve, reject) => {
|
return new Promise<Buffer>((resolve, reject) => {
|
||||||
const blocks = new Writer(32768);
|
const blocks = new Writer(32768);
|
||||||
const metadata = new Writer(16384);
|
|
||||||
const lighting = new Writer(32768);
|
const lighting = new Writer(32768);
|
||||||
|
|
||||||
let blockMeta = false;
|
let blockMeta = false;
|
||||||
|
@ -43,7 +42,6 @@ export class PacketMapChunk implements IPacket {
|
||||||
for (let y = 0; y < 128; y++) {
|
for (let y = 0; y < 128; y++) {
|
||||||
blocks.writeUByte(this.chunk.getBlockId(x, y, z));
|
blocks.writeUByte(this.chunk.getBlockId(x, y, z));
|
||||||
if (blockMeta) {
|
if (blockMeta) {
|
||||||
metadata.writeUByte(0);
|
|
||||||
// Light level 15 for 2 blocks (1111 1111)
|
// Light level 15 for 2 blocks (1111 1111)
|
||||||
lighting.writeUByte(0xff); // TODO: Lighting (Client seems to do it's own (when a block update happens) so it's not top priority)
|
lighting.writeUByte(0xff); // TODO: Lighting (Client seems to do it's own (when a block update happens) so it's not top priority)
|
||||||
lighting.writeUByte(0xff);
|
lighting.writeUByte(0xff);
|
||||||
|
@ -55,7 +53,7 @@ export class PacketMapChunk implements IPacket {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write meta and lighting data into block buffer for compression
|
// Write meta and lighting data into block buffer for compression
|
||||||
blocks.writeBuffer(metadata.toBuffer()).writeBuffer(lighting.toBuffer());
|
blocks.writeBuffer(this.chunk.getMetadataBuffer()).writeBuffer(lighting.toBuffer());
|
||||||
|
|
||||||
deflate(blocks.toBuffer(), (err, data) => {
|
deflate(blocks.toBuffer(), (err, data) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|
Loading…
Reference in a new issue