diff --git a/demo/src/main/java/net/minestom/demo/MainDemo.java b/demo/src/main/java/net/minestom/demo/MainDemo.java index 1d3bf6429..9ad45b6be 100644 --- a/demo/src/main/java/net/minestom/demo/MainDemo.java +++ b/demo/src/main/java/net/minestom/demo/MainDemo.java @@ -1,13 +1,19 @@ package net.minestom.demo; +import net.minestom.demo.commands.GamemodeCommand; +import net.minestom.demo.commands.SaveCommand; import net.minestom.server.MinecraftServer; import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.Player; import net.minestom.server.event.GlobalEventHandler; import net.minestom.server.event.player.PlayerLoginEvent; +import net.minestom.server.instance.AnvilLoader; import net.minestom.server.instance.InstanceContainer; import net.minestom.server.instance.InstanceManager; import net.minestom.server.instance.block.Block; +import net.minestom.server.utils.NamespaceID; +import net.minestom.server.world.biomes.Biome; +import net.minestom.server.world.biomes.BiomeManager; public class MainDemo { @@ -15,6 +21,9 @@ public class MainDemo { // Initialization MinecraftServer minecraftServer = MinecraftServer.init(); + MinecraftServer.getCommandManager().register(new GamemodeCommand()); + MinecraftServer.getCommandManager().register(new SaveCommand()); + InstanceManager instanceManager = MinecraftServer.getInstanceManager(); // Create the instance InstanceContainer instanceContainer = instanceManager.createInstanceContainer(); diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef738f0a6..30aca19e7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ kotlin = "1.6.20" hydrazine = "1.7.2" dependencyGetter = "v1.0.1" minestomData = "3e211f3953" -hephaistos = "2.4.8" +hephaistos = "2.5.0" jetbrainsAnnotations = "23.0.0" # Terminal / Logging diff --git a/src/main/java/net/minestom/server/instance/AnvilLoader.java b/src/main/java/net/minestom/server/instance/AnvilLoader.java index 87be945f3..c7e05017a 100644 --- a/src/main/java/net/minestom/server/instance/AnvilLoader.java +++ b/src/main/java/net/minestom/server/instance/AnvilLoader.java @@ -1,5 +1,8 @@ package net.minestom.server.instance; +import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.IntIntImmutablePair; import net.minestom.server.MinecraftServer; import net.minestom.server.instance.block.Block; import net.minestom.server.instance.block.BlockHandler; @@ -9,6 +12,11 @@ import net.minestom.server.world.biomes.Biome; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jglrxavpok.hephaistos.mca.*; +import org.jglrxavpok.hephaistos.mca.readers.ChunkReader; +import org.jglrxavpok.hephaistos.mca.readers.ChunkSectionReader; +import org.jglrxavpok.hephaistos.mca.readers.SectionBiomeInformation; +import org.jglrxavpok.hephaistos.mca.writer.ChunkSectionWriter; +import org.jglrxavpok.hephaistos.mca.writer.ChunkWriter; import org.jglrxavpok.hephaistos.nbt.*; import org.jglrxavpok.hephaistos.nbt.mutable.MutableNBTCompound; import org.slf4j.Logger; @@ -33,6 +41,21 @@ public class AnvilLoader implements IChunkLoader { private final Path levelPath; private final Path regionPath; + private static class RegionCache extends ConcurrentHashMap> {} + + /** + * Represents the chunks currently loaded per region. Used to determine when a region file can be unloaded. + */ + private final RegionCache perRegionLoadedChunks = new RegionCache(); + + // thread local to avoid contention issues with locks + private final ThreadLocal> blockStateId2ObjectCacheTLS = new ThreadLocal<>() { + @Override + protected Int2ObjectMap initialValue() { + return new Int2ObjectArrayMap<>(); + } + }; + public AnvilLoader(@NotNull Path path) { this.path = path; this.levelPath = path.resolve("level.dat"); @@ -76,61 +99,45 @@ public class AnvilLoader implements IChunkLoader { final RegionFile mcaFile = getMCAFile(instance, chunkX, chunkZ); if (mcaFile == null) return CompletableFuture.completedFuture(null); - final ChunkColumn fileChunk = mcaFile.getChunk(chunkX, chunkZ); - if (fileChunk == null) + final NBTCompound chunkData = mcaFile.getChunkData(chunkX, chunkZ); + if (chunkData == null) return CompletableFuture.completedFuture(null); + final ChunkReader chunkReader = new ChunkReader(chunkData); + Chunk chunk = new DynamicChunk(instance, chunkX, chunkZ); - if(fileChunk.getMinY() < instance.getDimensionType().getMinY()) { - throw new AnvilException( - String.format("Trying to load chunk with minY = %d, but instance dimension type (%s) has a minY of %d", - fileChunk.getMinY(), - instance.getDimensionType().getName().asString(), - instance.getDimensionType().getMinY() - )); - } - if(fileChunk.getMaxY() > instance.getDimensionType().getMaxY()) { - throw new AnvilException( - String.format("Trying to load chunk with maxY = %d, but instance dimension type (%s) has a maxY of %d", - fileChunk.getMaxY(), - instance.getDimensionType().getName().asString(), - instance.getDimensionType().getMaxY() - )); - } - - // TODO: Parallelize block, block entities and biome loading - - if (fileChunk.getGenerationStatus().compareTo(ChunkColumn.GenerationStatus.Biomes) > 0) { - HashMap biomeCache = new HashMap<>(); - - for (ChunkSection section : fileChunk.getSections().values()) { - if (section.getEmpty()) continue; - for (int y = 0; y < Chunk.CHUNK_SECTION_SIZE; y++) { - for (int z = 0; z < Chunk.CHUNK_SIZE_Z; z++) { - for (int x = 0; x < Chunk.CHUNK_SIZE_X; x++) { - int finalX = fileChunk.getX() * Chunk.CHUNK_SIZE_X + x; - int finalZ = fileChunk.getZ() * Chunk.CHUNK_SIZE_Z + z; - int finalY = section.getY() * Chunk.CHUNK_SECTION_SIZE + y; - String biomeName = section.getBiome(x, y, z); - Biome biome = biomeCache.computeIfAbsent(biomeName, n -> - Objects.requireNonNullElse(MinecraftServer.getBiomeManager().getByName(NamespaceID.from(n)), BIOME)); - chunk.setBiome(finalX, finalY, finalZ, biome); - } - } - } + synchronized (chunk) { + var yRange = chunkReader.getYRange(); + if(yRange.getStart() < instance.getDimensionType().getMinY()) { + throw new AnvilException( + String.format("Trying to load chunk with minY = %d, but instance dimension type (%s) has a minY of %d", + yRange.getStart(), + instance.getDimensionType().getName().asString(), + instance.getDimensionType().getMinY() + )); } + if(yRange.getEndInclusive() > instance.getDimensionType().getMaxY()) { + throw new AnvilException( + String.format("Trying to load chunk with maxY = %d, but instance dimension type (%s) has a maxY of %d", + yRange.getEndInclusive(), + instance.getDimensionType().getName().asString(), + instance.getDimensionType().getMaxY() + )); + } + + // TODO: Parallelize block, block entities and biome loading + // Blocks + Biomes + loadSections(chunk, chunkReader); + + // Block entities + loadBlockEntities(chunk, chunkReader); } - // Blocks - loadBlocks(chunk, fileChunk); - loadTileEntities(chunk, fileChunk); - // Lights - for (int sectionY = chunk.getMinSection(); sectionY < chunk.getMaxSection(); sectionY++) { - var section = chunk.getSection(sectionY); - var chunkSection = fileChunk.getSection((byte) sectionY); - section.setSkyLight(chunkSection.getSkyLights()); - section.setBlockLight(chunkSection.getBlockLights()); - } - mcaFile.forget(fileChunk); + synchronized (perRegionLoadedChunks) { + int regionX = CoordinatesKt.chunkToRegion(chunkX); + int regionZ = CoordinatesKt.chunkToRegion(chunkZ); + var chunks = perRegionLoadedChunks.computeIfAbsent(new IntIntImmutablePair(regionX, regionZ), r -> new HashSet<>()); // region cache may have been removed on another thread due to unloadChunk + chunks.add(new IntIntImmutablePair(chunkX, chunkZ)); + }; return CompletableFuture.completedFuture(chunk); } @@ -143,6 +150,10 @@ public class AnvilLoader implements IChunkLoader { if (!Files.exists(regionPath)) { return null; } + synchronized (perRegionLoadedChunks) { + Set previousVersion = perRegionLoadedChunks.put(new IntIntImmutablePair(regionX, regionZ), new HashSet<>()); + assert previousVersion == null : "The AnvilLoader cache should not already have data for this region."; + }; return new RegionFile(new RandomAccessFile(regionPath.toFile(), "rw"), regionX, regionZ, instance.getDimensionType().getMinY(), instance.getDimensionType().getMaxY()-1); } catch (IOException | AnvilException e) { MinecraftServer.getExceptionManager().handleException(e); @@ -151,28 +162,111 @@ public class AnvilLoader implements IChunkLoader { }); } - private void loadBlocks(Chunk chunk, ChunkColumn fileChunk) { - for (var section : fileChunk.getSections().values()) { - if (section.getEmpty()) continue; - final int yOffset = Chunk.CHUNK_SECTION_SIZE * section.getY(); - for (int x = 0; x < Chunk.CHUNK_SECTION_SIZE; x++) { - for (int z = 0; z < Chunk.CHUNK_SECTION_SIZE; z++) { - for (int y = 0; y < Chunk.CHUNK_SECTION_SIZE; y++) { - try { - final BlockState blockState = section.get(x, y, z); - final String blockName = blockState.getName(); - if (blockName.equals("minecraft:air")) continue; - Block block = Objects.requireNonNull(Block.fromNamespaceId(blockName)); - // Properties - final Map properties = blockState.getProperties(); - if (!properties.isEmpty()) block = block.withProperties(properties); - // Handler - final BlockHandler handler = MinecraftServer.getBlockManager().getHandler(block.name()); - if (handler != null) block = block.withHandler(handler); + private void loadSections(Chunk chunk, ChunkReader chunkReader) { + final HashMap biomeCache = new HashMap<>(); + for (var sectionNBT : chunkReader.getSections()) { + ChunkSectionReader sectionReader = new ChunkSectionReader(chunkReader.getMinecraftVersion(), sectionNBT); + Section section = chunk.getSection(sectionReader.getY()); - chunk.setBlock(x, y + yOffset, z, block); - } catch (Exception e) { - MinecraftServer.getExceptionManager().handleException(e); + if(sectionReader.getSkyLight() != null) { + section.setSkyLight(sectionReader.getSkyLight().copyArray()); + } + if(sectionReader.getBlockLight() != null) { + section.setBlockLight(sectionReader.getBlockLight().copyArray()); + } + + if (sectionReader.isSectionEmpty()) continue; + final int sectionY = sectionReader.getY(); + final int yOffset = Chunk.CHUNK_SECTION_SIZE * sectionY; + + // Biomes + if(chunkReader.getGenerationStatus().compareTo(ChunkColumn.GenerationStatus.Biomes) > 0) { + SectionBiomeInformation sectionBiomeInformation = chunkReader.readSectionBiomes(sectionReader); + + if(sectionBiomeInformation != null && sectionBiomeInformation.hasBiomeInformation()) { + if(sectionBiomeInformation.isFilledWithSingleBiome()) { + for (int y = 0; y < Chunk.CHUNK_SECTION_SIZE; y++) { + for (int z = 0; z < Chunk.CHUNK_SIZE_Z; z++) { + for (int x = 0; x < Chunk.CHUNK_SIZE_X; x++) { + int finalX = chunk.chunkX * Chunk.CHUNK_SIZE_X + x; + int finalZ = chunk.chunkZ * Chunk.CHUNK_SIZE_Z + z; + int finalY = sectionY * Chunk.CHUNK_SECTION_SIZE + y; + String biomeName = sectionBiomeInformation.getBaseBiome(); + Biome biome = biomeCache.computeIfAbsent(biomeName, n -> + Objects.requireNonNullElse(MinecraftServer.getBiomeManager().getByName(NamespaceID.from(n)), BIOME)); + chunk.setBiome(finalX, finalY, finalZ, biome); + } + } + } + } else { + for (int y = 0; y < Chunk.CHUNK_SECTION_SIZE; y++) { + for (int z = 0; z < Chunk.CHUNK_SIZE_Z; z++) { + for (int x = 0; x < Chunk.CHUNK_SIZE_X; x++) { + int finalX = chunk.chunkX * Chunk.CHUNK_SIZE_X + x; + int finalZ = chunk.chunkZ * Chunk.CHUNK_SIZE_Z + z; + int finalY = sectionY * Chunk.CHUNK_SECTION_SIZE + y; + + int index = x/4 + (z/4) * 4 + (y/4) * 16; + String biomeName = sectionBiomeInformation.getBiomes()[index]; + Biome biome = biomeCache.computeIfAbsent(biomeName, n -> + Objects.requireNonNullElse(MinecraftServer.getBiomeManager().getByName(NamespaceID.from(n)), BIOME)); + chunk.setBiome(finalX, finalY, finalZ, biome); + } + } + } + } + } + } + + // Blocks + final NBTList blockPalette = sectionReader.getBlockPalette(); + if(blockPalette != null) { + int[] blockStateIndices = sectionReader.getUncompressedBlockStateIDs(); + Block[] convertedPalette = new Block[blockPalette.getSize()]; + for (int i = 0; i < convertedPalette.length; i++) { + final NBTCompound paletteEntry = blockPalette.get(i); + String blockName = Objects.requireNonNull(paletteEntry.getString("Name")); + if (blockName.equals("minecraft:air")) { + convertedPalette[i] = Block.AIR; + } else { + Block block = Objects.requireNonNull(Block.fromNamespaceId(blockName)); + // Properties + final Map properties = new HashMap<>(); + NBTCompound propertiesNBT = paletteEntry.getCompound("Properties"); + if (propertiesNBT != null) { + for (var property : propertiesNBT) { + if (property.getValue().getID() != NBTType.TAG_String) { + LOGGER.warn("Fail to parse block state properties {}, expected a TAG_String for {}, but contents were {}", + propertiesNBT, + property.getKey(), + property.getValue().toSNBT()); + } else { + properties.put(property.getKey(), ((NBTString) property.getValue()).getValue()); + } + } + } + + if (!properties.isEmpty()) block = block.withProperties(properties); + // Handler + final BlockHandler handler = MinecraftServer.getBlockManager().getHandler(block.name()); + if (handler != null) block = block.withHandler(handler); + + convertedPalette[i] = block; + } + } + + for (int y = 0; y < Chunk.CHUNK_SECTION_SIZE; y++) { + for (int z = 0; z < Chunk.CHUNK_SECTION_SIZE; z++) { + for (int x = 0; x < Chunk.CHUNK_SECTION_SIZE; x++) { + try { + int blockIndex = y * Chunk.CHUNK_SECTION_SIZE * Chunk.CHUNK_SECTION_SIZE + z * Chunk.CHUNK_SECTION_SIZE + x; + int paletteIndex = blockStateIndices[blockIndex]; + Block block = convertedPalette[paletteIndex]; + + chunk.setBlock(x, y + yOffset, z, block); + } catch (Exception e) { + MinecraftServer.getExceptionManager().handleException(e); + } } } } @@ -180,8 +274,8 @@ public class AnvilLoader implements IChunkLoader { } } - private void loadTileEntities(Chunk loadedChunk, ChunkColumn fileChunk) { - for (NBTCompound te : fileChunk.getTileEntities()) { + private void loadBlockEntities(Chunk loadedChunk, ChunkReader chunkReader) { + for (NBTCompound te : chunkReader.getBlockEntities()) { final var x = te.getInt("x"); final var y = te.getInt("y"); final var z = te.getInt("z"); @@ -253,19 +347,11 @@ public class AnvilLoader implements IChunkLoader { } } } - ChunkColumn column; - try { - column = mcaFile.getOrCreateChunk(chunkX, chunkZ); - } catch (AnvilException | IOException e) { - LOGGER.error("Failed to save chunk " + chunkX + ", " + chunkZ, e); - MinecraftServer.getExceptionManager().handleException(e); - return AsyncUtils.VOID_FUTURE; - } - save(chunk, column); + ChunkWriter writer = new ChunkWriter(SupportedVersion.Companion.getLatest()); + save(chunk, writer); try { LOGGER.debug("Attempt saving at {} {}", chunk.getChunkX(), chunk.getChunkZ()); - mcaFile.writeColumn(column); - mcaFile.forget(column); + mcaFile.writeColumnData(writer.toNBT(), chunk.getChunkX(), chunk.getChunkZ()); } catch (IOException e) { LOGGER.error("Failed to save chunk " + chunkX + ", " + chunkZ, e); MinecraftServer.getExceptionManager().handleException(e); @@ -274,39 +360,112 @@ public class AnvilLoader implements IChunkLoader { return AsyncUtils.VOID_FUTURE; } - private void save(Chunk chunk, ChunkColumn chunkColumn) { - chunkColumn.changeVersion(SupportedVersion.Companion.getLatest()); - chunkColumn.setYRange(chunk.getMinSection()*16, chunk.getMaxSection()*16-1); - List tileEntities = new ArrayList<>(); - chunkColumn.setGenerationStatus(ChunkColumn.GenerationStatus.Full); - for (int x = 0; x < Chunk.CHUNK_SIZE_X; x++) { - for (int z = 0; z < Chunk.CHUNK_SIZE_Z; z++) { - for (int y = chunkColumn.getMinY(); y < chunkColumn.getMaxY(); y++) { - final Block block = chunk.getBlock(x, y, z); - // Block - chunkColumn.setBlockState(x, y, z, new BlockState(block.name(), block.properties())); - chunkColumn.setBiome(x, y, z, chunk.getBiome(x, y, z).name().asString()); + private BlockState getBlockState(final Block block) { + return blockStateId2ObjectCacheTLS.get().computeIfAbsent(block.stateId(), _unused -> new BlockState(block.name(), block.properties())); + } - // Tile entity - final BlockHandler handler = block.handler(); - var originalNBT = block.nbt(); - if (originalNBT != null || handler != null) { - MutableNBTCompound nbt = originalNBT != null ? - originalNBT.toMutableCompound() : new MutableNBTCompound(); + private void save(Chunk chunk, ChunkWriter chunkWriter) { + final int minY = chunk.getMinSection()*Chunk.CHUNK_SECTION_SIZE; + final int maxY = chunk.getMaxSection()*Chunk.CHUNK_SECTION_SIZE -1; + chunkWriter.setYPos(minY); + List blockEntities = new ArrayList<>(); + chunkWriter.setStatus(ChunkColumn.GenerationStatus.Full); - if (handler != null) { - nbt.setString("id", handler.getNamespaceId().asString()); + List sectionData = new ArrayList<>((maxY - minY + 1) / Chunk.CHUNK_SECTION_SIZE); + int[] palettedBiomes = new int[ChunkSection.Companion.getBiomeArraySize()]; + int[] palettedBlockStates = new int[Chunk.CHUNK_SIZE_X * Chunk.CHUNK_SECTION_SIZE * Chunk.CHUNK_SIZE_Z]; + for (int sectionY = chunk.getMinSection(); sectionY < chunk.getMaxSection(); sectionY++) { + ChunkSectionWriter sectionWriter = new ChunkSectionWriter(SupportedVersion.Companion.getLatest(), (byte)sectionY); + + Section section = chunk.getSection(sectionY); + sectionWriter.setSkyLights(section.getSkyLight()); + sectionWriter.setBlockLights(section.getBlockLight()); + + BiomePalette biomePalette = new BiomePalette(); + BlockPalette blockPalette = new BlockPalette(); + for (int sectionLocalY = 0; sectionLocalY < Chunk.CHUNK_SECTION_SIZE; sectionLocalY++) { + for (int z = 0; z < Chunk.CHUNK_SIZE_Z; z++) { + for (int x = 0; x < Chunk.CHUNK_SIZE_X; x++) { + final int y = sectionLocalY + sectionY * Chunk.CHUNK_SECTION_SIZE; + + int blockIndex = x + sectionLocalY * 16 * 16 + z * 16; + + final Block block = chunk.getBlock(x, y, z); + + final BlockState hephaistosBlockState = getBlockState(block); + blockPalette.increaseReference(hephaistosBlockState); + + palettedBlockStates[blockIndex] = blockPalette.getPaletteIndex(hephaistosBlockState); + + // biome are stored for 4x4x4 volumes, avoid unnecessary work + if(x % 4 == 0 && sectionLocalY % 4 == 0 && z % 4 == 0) { + int biomeIndex = (x/4) + (sectionLocalY/4) * 4 * 4 + (z/4) * 4; + final Biome biome = chunk.getBiome(x, y, z); + final String biomeName = biome.name().asString(); + + biomePalette.increaseReference(biomeName); + palettedBiomes[biomeIndex] = biomePalette.getPaletteIndex(biomeName); + } + + // Block entities + final BlockHandler handler = block.handler(); + var originalNBT = block.nbt(); + if (originalNBT != null || handler != null) { + MutableNBTCompound nbt = originalNBT != null ? + originalNBT.toMutableCompound() : new MutableNBTCompound(); + + if (handler != null) { + nbt.setString("id", handler.getNamespaceId().asString()); + } + nbt.setInt("x", x + Chunk.CHUNK_SIZE_X * chunk.getChunkX()); + nbt.setInt("y", y); + nbt.setInt("z", z + Chunk.CHUNK_SIZE_Z * chunk.getChunkZ()); + nbt.setByte("keepPacked", (byte) 0); + blockEntities.add(nbt.toCompound()); } - nbt.setInt("x", x + Chunk.CHUNK_SIZE_X * chunk.getChunkX()); - nbt.setInt("y", y); - nbt.setInt("z", z + Chunk.CHUNK_SIZE_Z * chunk.getChunkZ()); - nbt.setByte("keepPacked", (byte) 0); - tileEntities.add(nbt.toCompound()); } } } + + sectionWriter.setPalettedBiomes(biomePalette, palettedBiomes); + sectionWriter.setPalettedBlockStates(blockPalette, palettedBlockStates); + + sectionData.add(sectionWriter.toNBT()); } - chunkColumn.setTileEntities(NBT.List(NBTType.TAG_Compound, tileEntities)); + + chunkWriter.setSectionsData(NBT.List(NBTType.TAG_Compound, sectionData)); + chunkWriter.setBlockEntityData(NBT.List(NBTType.TAG_Compound, blockEntities)); + } + + /** + * Unload a given chunk. Also unloads a region when no chunk from that region is loaded. + * @param chunk the chunk to unload + */ + @Override + public void unloadChunk(Chunk chunk) { + final int regionX = CoordinatesKt.chunkToRegion(chunk.chunkX); + final int regionZ = CoordinatesKt.chunkToRegion(chunk.chunkZ); + + final IntIntImmutablePair regionKey = new IntIntImmutablePair(regionX, regionZ); + synchronized (perRegionLoadedChunks) { + Set chunks = perRegionLoadedChunks.get(regionKey); + if(chunks != null) { // if null, trying to unload a chunk from a region that was not created by the AnvilLoader + // don't check return value, trying to unload a chunk not created by the AnvilLoader is valid + chunks.remove(new IntIntImmutablePair(chunk.chunkX, chunk.chunkZ)); + + if(chunks.isEmpty()) { + perRegionLoadedChunks.remove(regionKey); + RegionFile regionFile = alreadyLoaded.remove(RegionFile.Companion.createFileName(regionX, regionZ)); + if(regionFile != null) { + try { + regionFile.close(); + } catch (IOException e) { + MinecraftServer.getExceptionManager().handleException(e); + } + } + } + } + }; } @Override diff --git a/src/main/java/net/minestom/server/instance/IChunkLoader.java b/src/main/java/net/minestom/server/instance/IChunkLoader.java index 2be534c0a..549f32e00 100644 --- a/src/main/java/net/minestom/server/instance/IChunkLoader.java +++ b/src/main/java/net/minestom/server/instance/IChunkLoader.java @@ -101,4 +101,13 @@ public interface IChunkLoader { default boolean supportsParallelLoading() { return false; } + + /** + * Called when a chunk is unloaded, so that this chunk loader can unload any resource it is holding. + * Note: Minestom currently has no way to determine whether the chunk comes from this loader, so you may get + * unload requests for chunks not created by the loader. + * + * @param chunk the chunk to unload + */ + default void unloadChunk(Chunk chunk) {} } diff --git a/src/main/java/net/minestom/server/instance/InstanceContainer.java b/src/main/java/net/minestom/server/instance/InstanceContainer.java index 0faccbe2a..eb85245fb 100644 --- a/src/main/java/net/minestom/server/instance/InstanceContainer.java +++ b/src/main/java/net/minestom/server/instance/InstanceContainer.java @@ -228,6 +228,9 @@ public class InstanceContainer extends Instance { // Clear cache this.chunks.remove(getChunkIndex(chunkX, chunkZ)); chunk.unload(); + if(chunkLoader != null) { + chunkLoader.unloadChunk(chunk); + } var dispatcher = MinecraftServer.process().dispatcher(); dispatcher.deletePartition(chunk); } diff --git a/src/test/java/net/minestom/server/api/Env.java b/src/test/java/net/minestom/server/api/Env.java index 53507eb96..446d50d93 100644 --- a/src/test/java/net/minestom/server/api/Env.java +++ b/src/test/java/net/minestom/server/api/Env.java @@ -5,6 +5,7 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.Player; import net.minestom.server.event.Event; import net.minestom.server.event.EventFilter; +import net.minestom.server.instance.IChunkLoader; import net.minestom.server.instance.Instance; import net.minestom.server.instance.block.Block; import org.jetbrains.annotations.NotNull; @@ -43,8 +44,16 @@ public interface Env { } default @NotNull Instance createFlatInstance() { - var instance = process().instance().createInstanceContainer(); + return createFlatInstance(null); + } + + default @NotNull Instance createFlatInstance(IChunkLoader chunkLoader) { + var instance = process().instance().createInstanceContainer(chunkLoader); instance.setGenerator(unit -> unit.modifier().fillHeight(0, 40, Block.STONE)); return instance; } + + default void destroyInstance(Instance instance) { + process().instance().unregisterInstance(instance); + } } diff --git a/src/test/java/net/minestom/server/instance/AnvilLoaderIntegrationTest.java b/src/test/java/net/minestom/server/instance/AnvilLoaderIntegrationTest.java new file mode 100644 index 000000000..f9a38fa96 --- /dev/null +++ b/src/test/java/net/minestom/server/instance/AnvilLoaderIntegrationTest.java @@ -0,0 +1,209 @@ +package net.minestom.server.instance; + +import net.minestom.server.api.Env; +import net.minestom.server.api.EnvTest; +import net.minestom.server.instance.block.Block; +import net.minestom.server.utils.NamespaceID; +import net.minestom.server.utils.binary.BinaryWriter; +import net.minestom.server.world.biomes.Biome; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@EnvTest +public class AnvilLoaderIntegrationTest { + + private static final Path testRoot = Path.of("src", "test", "resources", "net", "minestom", "server", "instance", "anvil_loader"); + private static final Path worldFolder = Path.of("integration_test_world"); + + + @BeforeAll + public static void prepareTest() throws IOException { + // https://stackoverflow.com/a/60621544 + Files.walkFileTree(testRoot, new SimpleFileVisitor<>() { + + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) + throws IOException { + Files.createDirectories(worldFolder.resolve(testRoot.relativize(dir))); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + Files.copy(file, worldFolder.resolve(testRoot.relativize(file)), StandardCopyOption.REPLACE_EXISTING); + return FileVisitResult.CONTINUE; + } + }); + } + + + + @Test + public void loadHouse(Env env) { + // load a world that contains only a basic house and make sure it is loaded properly + + AnvilLoader chunkLoader = new AnvilLoader(worldFolder) { + // Force loads inside current thread + @Override + public boolean supportsParallelLoading() { + return false; + } + + @Override + public boolean supportsParallelSaving() { + return false; + } + }; + Instance instance = env.createFlatInstance(chunkLoader); + + Consumer checkChunk = chunk -> { + synchronized (chunk) { + assertEquals(-4, chunk.getMinSection()); + assertEquals(20, chunk.getMaxSection()); + + // TODO: skylight + // TODO: block light + for (int y = 0; y < 16; y++) { + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + Biome b = chunk.getBiome(x, y, z); + assertEquals(NamespaceID.from("minecraft:plains"), b.name()); + } + } + } + } + }; + + for (int x = -2; x < 2; x++) { + for (int z = -2; z < 2; z++) { + checkChunk.accept(instance.loadChunk(x, z).join()); // this is a test so we don't care too much about waiting for each chunk + } + } + + // wooden house with nylium ground. Open world inside MC to check out + + // center of world + assertEquals(Block.BEDROCK, instance.getBlock(0, 0, 0)); + // nylium stripes in front and back of house + for (int z = -4; z <= 0; z++) { + assertEquals(Block.WARPED_NYLIUM, instance.getBlock(4, 0, z)); + assertEquals(Block.WARPED_NYLIUM, instance.getBlock(-3, 0, z)); + assertEquals(Block.WARPED_NYLIUM, instance.getBlock(-4, 0, z)); + } + + // side walls + for (int x = -2; x <= 3; x++) { + if(x != 0) { // bedrock block at center + assertEquals(Block.NETHERRACK, instance.getBlock(x, 0, 0)); + } + assertEquals(Block.NETHERRACK, instance.getBlock(x, 0, -4)); + + assertEquals(Block.OAK_PLANKS, instance.getBlock(x, 1, 0)); + assertEquals(Block.OAK_PLANKS, instance.getBlock(x, 1, -4)); + assertEquals(Block.OAK_PLANKS, instance.getBlock(x, 2, 0)); + assertEquals(Block.OAK_PLANKS, instance.getBlock(x, 2, -4)); + } + + // back wall + for (int z = -4; z <= 0; z++) { + assertEquals(Block.NETHERRACK, instance.getBlock(-2, 0, z)); + + assertEquals(Block.OAK_PLANKS, instance.getBlock(-2, 1, z)); + assertEquals(Block.OAK_PLANKS, instance.getBlock(-2, 2, z)); + } + + // door + Block baseDoor = Block.ACACIA_DOOR + .withProperty("facing", "west") + .withProperty("hinge", "left") + .withProperty("open", "false") + .withProperty("powered", "false") + ; + Block bottomDoorPart = baseDoor.withProperty("half", "lower"); + Block topDoorPart = baseDoor.withProperty("half", "upper"); + assertEquals(bottomDoorPart, instance.getBlock(3, 1, -3)); + assertEquals(topDoorPart, instance.getBlock(3, 2, -3)); + + // light blocks + Block endRod = Block.END_ROD.withProperty("facing", "up"); + assertEquals(endRod, instance.getBlock(-1, 1, -1)); + assertEquals(Block.TORCH, instance.getBlock(-1, 2, -1)); + + // flower pot + assertEquals(Block.OAK_PLANKS, instance.getBlock(-1, 1, -3)); + assertEquals(Block.POTTED_POPPY, instance.getBlock(-1, 2, -3)); + + env.destroyInstance(instance); + } + + @Test + public void loadAndSaveChunk(Env env) throws InterruptedException { + Instance instance = env.createFlatInstance(new AnvilLoader(worldFolder) { + // Force loads inside current thread + @Override + public boolean supportsParallelLoading() { + return false; + } + + @Override + public boolean supportsParallelSaving() { + return false; + } + }); + Chunk originalChunk = instance.loadChunk(0,0).join(); + + synchronized (originalChunk) { + instance.saveChunkToStorage(originalChunk); + instance.unloadChunk(originalChunk); + while(originalChunk.isLoaded()) { + Thread.sleep(1); + } + } + + Chunk reloadedChunk = instance.loadChunk(0,0).join(); + for(int section = reloadedChunk.getMinSection(); section < reloadedChunk.getMaxSection(); section++) { + Section originalSection = originalChunk.getSection(section); + Section reloadedSection = reloadedChunk.getSection(section); + + // easiest equality check to write is a memory compare on written output + BinaryWriter originalWriter = new BinaryWriter(); + BinaryWriter reloadedWriter = new BinaryWriter(); + originalSection.write(originalWriter); + reloadedSection.write(reloadedWriter); + + Assertions.assertArrayEquals(originalWriter.toByteArray(), reloadedWriter.toByteArray()); + } + + env.destroyInstance(instance); + } + + @AfterAll + public static void cleanupTest() throws IOException { + Files.walkFileTree(worldFolder, new SimpleFileVisitor<>() { + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException e) + throws IOException { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + }); + } +} diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/level.dat b/src/test/resources/net/minestom/server/instance/anvil_loader/level.dat new file mode 100644 index 000000000..457c25ad6 Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/level.dat differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-1.-1.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-1.-1.mca new file mode 100644 index 000000000..4e98d0052 Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-1.-1.mca differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-1.-2.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-1.-2.mca new file mode 100644 index 000000000..d5872eb0c Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-1.-2.mca differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-1.0.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-1.0.mca new file mode 100644 index 000000000..ef97ba629 Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-1.0.mca differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-1.1.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-1.1.mca new file mode 100644 index 000000000..23e09486d Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-1.1.mca differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-2.-1.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-2.-1.mca new file mode 100644 index 000000000..6da2e96fe Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-2.-1.mca differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-2.-2.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-2.-2.mca new file mode 100644 index 000000000..ba1a34b45 Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-2.-2.mca differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-2.0.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-2.0.mca new file mode 100644 index 000000000..5655dae18 Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-2.0.mca differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-2.1.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-2.1.mca new file mode 100644 index 000000000..14d079fce Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.-2.1.mca differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.0.-1.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.0.-1.mca new file mode 100644 index 000000000..41c91cd5e Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.0.-1.mca differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.0.-2.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.0.-2.mca new file mode 100644 index 000000000..304e070ee Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.0.-2.mca differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.0.0.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.0.0.mca new file mode 100644 index 000000000..7724d59b3 Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.0.0.mca differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.0.1.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.0.1.mca new file mode 100644 index 000000000..cd11b06af Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.0.1.mca differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.1.-1.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.1.-1.mca new file mode 100644 index 000000000..de2686a04 Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.1.-1.mca differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.1.-2.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.1.-2.mca new file mode 100644 index 000000000..aa197ce8a Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.1.-2.mca differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.1.0.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.1.0.mca new file mode 100644 index 000000000..31c0c56d3 Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.1.0.mca differ diff --git a/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.1.1.mca b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.1.1.mca new file mode 100644 index 000000000..686614dc0 Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/anvil_loader/region/r.1.1.mca differ