geforkt von Mirrors/FastAsyncWorldEdit
Re-add delchunks command (#481)
The new command now writes a json file to WorldEdit's working directory with instructions on which chunks to delete, which is read by the plugin/mod at startup and calls the ChunkDeleter. The chunk deleter parses the json and iterates the instructions, backing up .mca files as it goes and overwriting the offset headers with 0 wherever a chunk needs to be deleted. This allows Minecraft to reclaim the space used for that chunk, as well as forcing it to be generated from scratch next time the area is loaded.
Dieser Commit ist enthalten in:
Ursprung
902754ce8a
Commit
d763ab374c
@ -53,6 +53,7 @@ import org.slf4j.Logger;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
@ -158,6 +159,11 @@ public class BukkitWorld extends AbstractWorld {
|
||||
return getWorld().getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getStoragePath() {
|
||||
return getWorld().getWorldFolder().toPath();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getBlockLightLevel(BlockVector3 pt) {
|
||||
return getWorld().getBlockAt(pt.getBlockX(), pt.getBlockY(), pt.getBlockZ()).getLightLevel();
|
||||
|
@ -38,6 +38,7 @@ import com.sk89q.worldedit.extension.platform.Capability;
|
||||
import com.sk89q.worldedit.extension.platform.Platform;
|
||||
import com.sk89q.worldedit.extent.inventory.BlockBag;
|
||||
import com.sk89q.worldedit.internal.command.CommandUtil;
|
||||
import com.sk89q.worldedit.internal.anvil.ChunkDeleter;
|
||||
import com.sk89q.worldedit.registry.state.Property;
|
||||
import com.sk89q.worldedit.world.biome.BiomeType;
|
||||
import com.sk89q.worldedit.world.block.BlockCategory;
|
||||
@ -73,6 +74,9 @@ import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@ -82,6 +86,7 @@ import java.util.logging.Level;
|
||||
import java.util.zip.ZipEntry;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
import static com.sk89q.worldedit.internal.anvil.ChunkDeleter.DELCHUNKS_FILE_NAME;
|
||||
|
||||
/**
|
||||
* Plugin for Bukkit.
|
||||
@ -109,6 +114,11 @@ public class WorldEditPlugin extends JavaPlugin implements TabCompleter {
|
||||
// Setup platform
|
||||
server = new BukkitServerInterface(this, getServer());
|
||||
worldEdit.getPlatformManager().register(server);
|
||||
|
||||
Path delChunks = Paths.get(getDataFolder().getPath(), DELCHUNKS_FILE_NAME);
|
||||
if (Files.exists(delChunks)) {
|
||||
ChunkDeleter.runFromFile(delChunks, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -156,9 +156,6 @@ public class BukkitImplLoader {
|
||||
if (cls.isSynthetic()) continue;
|
||||
if (BukkitImplAdapter.class.isAssignableFrom(cls)) {
|
||||
return (BukkitImplAdapter) cls.newInstance();
|
||||
} else {
|
||||
log.debug("Failed to load the Bukkit adapter class '" + className +
|
||||
"' because it does not implement " + BukkitImplAdapter.class.getCanonicalName());
|
||||
}
|
||||
} catch (ClassNotFoundException e) {
|
||||
log.warn("Failed to load the Bukkit adapter class '" + className +
|
||||
|
@ -19,10 +19,7 @@
|
||||
|
||||
package com.sk89q.worldedit.command;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
import static com.sk89q.worldedit.command.util.Logging.LogMode.REGION;
|
||||
|
||||
import com.sk89q.worldedit.LocalConfiguration;
|
||||
import com.google.gson.JsonIOException;
|
||||
import com.sk89q.worldedit.LocalSession;
|
||||
import com.sk89q.worldedit.WorldEdit;
|
||||
import com.sk89q.worldedit.WorldEditException;
|
||||
@ -30,22 +27,36 @@ import com.sk89q.worldedit.command.util.CommandPermissions;
|
||||
import com.sk89q.worldedit.command.util.CommandPermissionsConditionGenerator;
|
||||
import com.sk89q.worldedit.command.util.Logging;
|
||||
import com.sk89q.worldedit.entity.Player;
|
||||
import com.sk89q.worldedit.internal.anvil.ChunkDeleter;
|
||||
import com.sk89q.worldedit.internal.anvil.ChunkDeletionInfo;
|
||||
import com.sk89q.worldedit.math.BlockVector2;
|
||||
import com.sk89q.worldedit.math.MathUtils;
|
||||
import com.sk89q.worldedit.regions.CuboidRegion;
|
||||
import com.sk89q.worldedit.regions.Region;
|
||||
import com.sk89q.worldedit.util.Location;
|
||||
import com.sk89q.worldedit.util.formatting.component.PaginationBox;
|
||||
import com.sk89q.worldedit.util.formatting.text.TextComponent;
|
||||
import com.sk89q.worldedit.util.formatting.text.event.ClickEvent;
|
||||
import com.sk89q.worldedit.util.formatting.text.format.TextColor;
|
||||
import com.sk89q.worldedit.world.storage.LegacyChunkStore;
|
||||
import com.sk89q.worldedit.world.storage.McRegionChunkStore;
|
||||
import org.enginehub.piston.annotation.Command;
|
||||
import org.enginehub.piston.annotation.CommandContainer;
|
||||
import org.enginehub.piston.annotation.param.ArgFlag;
|
||||
import org.enginehub.piston.exception.StopExecutionException;
|
||||
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
import static com.sk89q.worldedit.command.util.Logging.LogMode.REGION;
|
||||
import static com.sk89q.worldedit.internal.anvil.ChunkDeleter.DELCHUNKS_FILE_NAME;
|
||||
|
||||
/**
|
||||
* Commands for working with chunks.
|
||||
*/
|
||||
@ -69,15 +80,10 @@ public class ChunkCommands {
|
||||
int chunkX = (int) Math.floor(pos.getBlockX() / 16.0);
|
||||
int chunkZ = (int) Math.floor(pos.getBlockZ() / 16.0);
|
||||
|
||||
String folder1 = Integer.toString(MathUtils.divisorMod(chunkX, 64), 36);
|
||||
String folder2 = Integer.toString(MathUtils.divisorMod(chunkZ, 64), 36);
|
||||
String filename = "c." + Integer.toString(chunkX, 36)
|
||||
+ "." + Integer.toString(chunkZ, 36) + ".dat";
|
||||
|
||||
final BlockVector2 chunkPos = BlockVector2.at(chunkX, chunkZ);
|
||||
player.print("Chunk: " + chunkX + ", " + chunkZ);
|
||||
player.print("Old format: " + folder1 + "/" + folder2 + "/" + filename);
|
||||
player.print("McRegion: region/" + McRegionChunkStore.getFilename(
|
||||
BlockVector2.at(chunkX, chunkZ)));
|
||||
player.print("Old format: " + LegacyChunkStore.getFilename(chunkPos));
|
||||
player.print("McRegion: region/" + McRegionChunkStore.getFilename(chunkPos));
|
||||
}
|
||||
|
||||
@Command(
|
||||
@ -86,7 +92,7 @@ public class ChunkCommands {
|
||||
)
|
||||
@CommandPermissions("worldedit.listchunks")
|
||||
public void listChunks(Player player, LocalSession session,
|
||||
@ArgFlag(name = 'p', desc = "Page number.", def = "1") int page) throws WorldEditException {
|
||||
@ArgFlag(name = 'p', desc = "Page number.", def = "1") int page) throws WorldEditException {
|
||||
Set<BlockVector2> chunks = session.getSelection(player.getWorld()).getChunks();
|
||||
|
||||
PaginationBox paginationBox = PaginationBox.fromStrings("Selected Chunks", "/listchunks -p %page%",
|
||||
@ -100,82 +106,64 @@ public class ChunkCommands {
|
||||
)
|
||||
@CommandPermissions("worldedit.delchunks")
|
||||
@Logging(REGION)
|
||||
public void deleteChunks(Player player, LocalSession session) throws WorldEditException {
|
||||
player.print("Note that this command does not yet support the mcregion format.");
|
||||
LocalConfiguration config = worldEdit.getConfiguration();
|
||||
|
||||
Set<BlockVector2> chunks = session.getSelection(player.getWorld()).getChunks();
|
||||
FileOutputStream out = null;
|
||||
|
||||
if (config.shellSaveType == null) {
|
||||
player.printError("Shell script type must be configured: 'bat' or 'bash' expected.");
|
||||
} else if (config.shellSaveType.equalsIgnoreCase("bat")) {
|
||||
try {
|
||||
out = new FileOutputStream("worldedit-delchunks.bat");
|
||||
OutputStreamWriter writer = new OutputStreamWriter(out, "UTF-8");
|
||||
writer.write("@ECHO off\r\n");
|
||||
writer.write("ECHO This batch file was generated by WorldEdit.\r\n");
|
||||
writer.write("ECHO It contains a list of chunks that were in the selected region\r\n");
|
||||
writer.write("ECHO at the time that the /delchunks command was used. Run this file\r\n");
|
||||
writer.write("ECHO in order to delete the chunk files listed in this file.\r\n");
|
||||
writer.write("ECHO.\r\n");
|
||||
writer.write("PAUSE\r\n");
|
||||
|
||||
for (BlockVector2 chunk : chunks) {
|
||||
String filename = LegacyChunkStore.getFilename(chunk);
|
||||
writer.write("ECHO " + filename + "\r\n");
|
||||
writer.write("DEL \"world/" + filename + "\"\r\n");
|
||||
}
|
||||
|
||||
writer.write("ECHO Complete.\r\n");
|
||||
writer.write("PAUSE\r\n");
|
||||
writer.close();
|
||||
player.print("worldedit-delchunks.bat written. Run it when no one is near the region.");
|
||||
} catch (IOException e) {
|
||||
player.printError("Error occurred: " + e.getMessage());
|
||||
} finally {
|
||||
if (out != null) {
|
||||
try {
|
||||
out.close();
|
||||
} catch (IOException ignored) { }
|
||||
}
|
||||
}
|
||||
} else if (config.shellSaveType.equalsIgnoreCase("bash")) {
|
||||
try {
|
||||
out = new FileOutputStream("worldedit-delchunks.sh");
|
||||
OutputStreamWriter writer = new OutputStreamWriter(out, "UTF-8");
|
||||
writer.write("#!/bin/bash\n");
|
||||
writer.write("echo This shell file was generated by WorldEdit.\n");
|
||||
writer.write("echo It contains a list of chunks that were in the selected region\n");
|
||||
writer.write("echo at the time that the /delchunks command was used. Run this file\n");
|
||||
writer.write("echo in order to delete the chunk files listed in this file.\n");
|
||||
writer.write("echo\n");
|
||||
writer.write("read -p \"Press any key to continue...\"\n");
|
||||
|
||||
for (BlockVector2 chunk : chunks) {
|
||||
String filename = LegacyChunkStore.getFilename(chunk);
|
||||
writer.write("echo " + filename + "\n");
|
||||
writer.write("rm \"world/" + filename + "\"\n");
|
||||
}
|
||||
|
||||
writer.write("echo Complete.\n");
|
||||
writer.write("read -p \"Press any key to continue...\"\n");
|
||||
writer.close();
|
||||
player.print("worldedit-delchunks.sh written. Run it when no one is near the region.");
|
||||
player.print("You will have to chmod it to be executable.");
|
||||
} catch (IOException e) {
|
||||
player.printError("Error occurred: " + e.getMessage());
|
||||
} finally {
|
||||
if (out != null) {
|
||||
try {
|
||||
out.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
player.printError("Shell script type must be configured: 'bat' or 'bash' expected.");
|
||||
public void deleteChunks(Player player, LocalSession session,
|
||||
@ArgFlag(name = 'o', desc = "Only delete chunks older than the specified time.", def = "")
|
||||
ZonedDateTime beforeTime) throws WorldEditException {
|
||||
Path worldDir = player.getWorld().getStoragePath();
|
||||
if (worldDir == null) {
|
||||
throw new StopExecutionException(TextComponent.of("Couldn't find world folder for this world."));
|
||||
}
|
||||
|
||||
File chunkFile = worldEdit.getWorkingDirectoryFile(DELCHUNKS_FILE_NAME);
|
||||
Path chunkPath = chunkFile.toPath();
|
||||
ChunkDeletionInfo currentInfo = null;
|
||||
if (Files.exists(chunkPath)) {
|
||||
try {
|
||||
currentInfo = ChunkDeleter.readInfo(chunkFile.toPath());
|
||||
} catch (IOException e) {
|
||||
throw new StopExecutionException(TextComponent.of("Error reading existing chunk file."));
|
||||
}
|
||||
}
|
||||
if (currentInfo == null) {
|
||||
currentInfo = new ChunkDeletionInfo();
|
||||
currentInfo.batches = new ArrayList<>();
|
||||
}
|
||||
|
||||
ChunkDeletionInfo.ChunkBatch newBatch = new ChunkDeletionInfo.ChunkBatch();
|
||||
newBatch.worldPath = worldDir.toAbsolutePath().normalize().toString();
|
||||
newBatch.backup = true;
|
||||
final Region selection = session.getSelection(player.getWorld());
|
||||
int chunkCount;
|
||||
if (selection instanceof CuboidRegion) {
|
||||
newBatch.minChunk = BlockVector2.at(selection.getMinimumPoint().getBlockX() >> 4, selection.getMinimumPoint().getBlockZ() >> 4);
|
||||
newBatch.maxChunk = BlockVector2.at(selection.getMaximumPoint().getBlockX() >> 4, selection.getMaximumPoint().getBlockZ() >> 4);
|
||||
final BlockVector2 dist = newBatch.maxChunk.subtract(newBatch.minChunk).add(1, 1);
|
||||
chunkCount = dist.getBlockX() * dist.getBlockZ();
|
||||
} else {
|
||||
// this has a possibility to OOM for very large selections still
|
||||
Set<BlockVector2> chunks = selection.getChunks();
|
||||
newBatch.chunks = new ArrayList<>(chunks);
|
||||
chunkCount = chunks.size();
|
||||
}
|
||||
if (beforeTime != null) {
|
||||
newBatch.deletionPredicates = new ArrayList<>();
|
||||
ChunkDeletionInfo.DeletionPredicate timePred = new ChunkDeletionInfo.DeletionPredicate();
|
||||
timePred.property = "modification";
|
||||
timePred.comparison = "<";
|
||||
timePred.value = String.valueOf((int) beforeTime.toOffsetDateTime().toEpochSecond());
|
||||
newBatch.deletionPredicates.add(timePred);
|
||||
}
|
||||
currentInfo.batches.add(newBatch);
|
||||
|
||||
try {
|
||||
ChunkDeleter.writeInfo(currentInfo, chunkPath);
|
||||
} catch (IOException | JsonIOException e) {
|
||||
throw new StopExecutionException(TextComponent.of("Failed to write chunk list: " + e.getMessage()));
|
||||
}
|
||||
|
||||
player.print(String.format("%d chunk(s) have been marked for deletion and will be deleted the next time the server starts.", chunkCount));
|
||||
player.print(TextComponent.of("You can mark more chunks for deletion, or to stop the server now, run: ", TextColor.LIGHT_PURPLE)
|
||||
.append(TextComponent.of("/stop", TextColor.AQUA).clickEvent(ClickEvent.of(ClickEvent.Action.SUGGEST_COMMAND, "/stop"))));
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,350 @@
|
||||
/*
|
||||
* WorldEdit, a Minecraft world manipulation toolkit
|
||||
* Copyright (C) sk89q <http://www.sk89q.com>
|
||||
* Copyright (C) WorldEdit team and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU Lesser General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
|
||||
* for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.sk89q.worldedit.internal.anvil;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonIOException;
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
import com.google.gson.TypeAdapter;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import com.sk89q.worldedit.math.BlockVector2;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiPredicate;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class ChunkDeleter {
|
||||
|
||||
public static final String DELCHUNKS_FILE_NAME = "delete_chunks.json";
|
||||
private static final Logger logger = LoggerFactory.getLogger(ChunkDeleter.class);
|
||||
|
||||
private static final Comparator<BlockVector2> chunkSorter = Comparator.comparing(
|
||||
pos -> (pos.getBlockX() & 31) + (pos.getBlockZ() & 31) * 32);
|
||||
|
||||
private static Gson chunkDeleterGson = new GsonBuilder()
|
||||
.registerTypeAdapter(BlockVector2.class, new BlockVector2Adapter())
|
||||
.setPrettyPrinting()
|
||||
.create();
|
||||
|
||||
public static ChunkDeletionInfo readInfo(Path chunkFile) throws IOException, JsonSyntaxException {
|
||||
String json = new String(Files.readAllBytes(chunkFile), StandardCharsets.UTF_8);
|
||||
return chunkDeleterGson.fromJson(json, ChunkDeletionInfo.class);
|
||||
}
|
||||
|
||||
public static void writeInfo(ChunkDeletionInfo info, Path chunkFile) throws IOException, JsonIOException {
|
||||
String json = chunkDeleterGson.toJson(info, new TypeToken<ChunkDeletionInfo>() {}.getType());
|
||||
try (BufferedWriter writer = Files.newBufferedWriter(chunkFile, StandardOpenOption.CREATE)) {
|
||||
writer.write(json);
|
||||
}
|
||||
}
|
||||
|
||||
public static void runFromFile(Path chunkFile, boolean deleteOnSuccess) {
|
||||
ChunkDeleter chunkDeleter;
|
||||
try {
|
||||
chunkDeleter = createFromFile(chunkFile);
|
||||
} catch (JsonSyntaxException | IOException e) {
|
||||
logger.error("Could not parse chunk deletion file. Invalid file?", e);
|
||||
return;
|
||||
}
|
||||
logger.info("Found chunk deletions. Proceeding with deletion...");
|
||||
long start = System.currentTimeMillis();
|
||||
if (chunkDeleter.runDeleter()) {
|
||||
logger.info("Successfully deleted {} matching chunks (out of {}, taking {} ms).",
|
||||
chunkDeleter.getDeletedChunkCount(), chunkDeleter.getDeletionsRequested(),
|
||||
System.currentTimeMillis() - start);
|
||||
if (deleteOnSuccess) {
|
||||
boolean deletedFile = false;
|
||||
try {
|
||||
deletedFile = Files.deleteIfExists(chunkFile);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
if (!deletedFile) {
|
||||
logger.warn("Chunk deletion file could not be cleaned up. This may have unintended consequences" +
|
||||
" on next startup, or if /delchunks is used again.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.error("Error occurred while deleting chunks. " +
|
||||
"If world errors occur, stop the server and restore the *.bak backup files.");
|
||||
}
|
||||
}
|
||||
|
||||
private ChunkDeleter(ChunkDeletionInfo chunkDeletionInfo) {
|
||||
this.chunkDeletionInfo = chunkDeletionInfo;
|
||||
}
|
||||
|
||||
private static ChunkDeleter createFromFile(Path chunkFile) throws IOException {
|
||||
ChunkDeletionInfo info = readInfo(chunkFile);
|
||||
if (info == null) {
|
||||
throw new IOException("Read null json. Empty file?");
|
||||
}
|
||||
return new ChunkDeleter(info);
|
||||
}
|
||||
|
||||
private final ChunkDeletionInfo chunkDeletionInfo;
|
||||
private Set<Path> backedUpRegions = new HashSet<>();
|
||||
private boolean shouldPreload;
|
||||
private int debugRate = 100;
|
||||
private int totalChunksDeleted = 0;
|
||||
private int deletionsRequested = 0;
|
||||
|
||||
private boolean runDeleter() {
|
||||
return chunkDeletionInfo.batches.stream().allMatch(this::runBatch);
|
||||
}
|
||||
|
||||
private boolean runBatch(ChunkDeletionInfo.ChunkBatch chunkBatch) {
|
||||
logger.debug("Processing deletion batch.");
|
||||
final Map<Path, Stream<BlockVector2>> regionToChunkList = groupChunks(chunkBatch);
|
||||
BiPredicate<RegionAccess, BlockVector2> predicate = createPredicates(chunkBatch.deletionPredicates);
|
||||
shouldPreload = chunkBatch.chunks == null;
|
||||
return regionToChunkList.entrySet().stream().allMatch(entry -> {
|
||||
Path regionPath = entry.getKey();
|
||||
if (!Files.exists(regionPath)) return true;
|
||||
if (chunkBatch.backup && !backedUpRegions.contains(regionPath)) {
|
||||
try {
|
||||
backupRegion(regionPath);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Error backing up region file: " + regionPath + ". Aborting the process.", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return deleteChunks(regionPath, entry.getValue(), predicate);
|
||||
});
|
||||
}
|
||||
|
||||
private Map<Path, Stream<BlockVector2>> groupChunks(ChunkDeletionInfo.ChunkBatch chunkBatch) {
|
||||
Path worldPath = Paths.get(chunkBatch.worldPath);
|
||||
if (chunkBatch.chunks != null) {
|
||||
deletionsRequested += chunkBatch.chunks.size();
|
||||
debugRate = chunkBatch.chunks.size() / 10;
|
||||
return chunkBatch.chunks.stream()
|
||||
.collect(Collectors.groupingBy(RegionFilePos::new))
|
||||
.entrySet().stream().collect(Collectors.toMap(
|
||||
e -> worldPath.resolve("region").resolve(e.getKey().getFileName()),
|
||||
e -> e.getValue().stream().sorted(chunkSorter)));
|
||||
} else {
|
||||
final BlockVector2 minChunk = chunkBatch.minChunk;
|
||||
final BlockVector2 maxChunk = chunkBatch.maxChunk;
|
||||
final RegionFilePos minRegion = new RegionFilePos(minChunk);
|
||||
final RegionFilePos maxRegion = new RegionFilePos(maxChunk);
|
||||
Map<Path, Stream<BlockVector2>> groupedChunks = new HashMap<>();
|
||||
for (int regX = minRegion.getX(); regX <= maxRegion.getX(); regX++) {
|
||||
for (int regZ = minRegion.getZ(); regZ <= maxRegion.getZ(); regZ++) {
|
||||
final Path regionPath = worldPath.resolve("region").resolve(new RegionFilePos(regX, regZ).getFileName());
|
||||
if (!Files.exists(regionPath)) continue;
|
||||
int startX = regX << 5;
|
||||
int endX = (regX << 5) + 31;
|
||||
int startZ = regZ << 5;
|
||||
int endZ = (regZ << 5) + 31;
|
||||
|
||||
int minX = Math.max(Math.min(startX, endX), minChunk.getBlockX());
|
||||
int minZ = Math.max(Math.min(startZ, endZ), minChunk.getBlockZ());
|
||||
int maxX = Math.min(Math.max(startX, endX), maxChunk.getBlockX());
|
||||
int maxZ = Math.min(Math.max(startZ, endZ), maxChunk.getBlockZ());
|
||||
Stream<BlockVector2> stream = Stream.iterate(BlockVector2.at(minX, minZ),
|
||||
bv2 -> {
|
||||
int nextX = bv2.getBlockX();
|
||||
int nextZ = bv2.getBlockZ();
|
||||
if (++nextX > maxX) {
|
||||
nextX = minX;
|
||||
if (++nextZ > maxZ) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return BlockVector2.at(nextX, nextZ);
|
||||
});
|
||||
groupedChunks.put(regionPath, stream);
|
||||
}
|
||||
}
|
||||
final BlockVector2 dist = maxChunk.subtract(minChunk).add(1, 1);
|
||||
final int batchSize = dist.getBlockX() * dist.getBlockZ();
|
||||
debugRate = batchSize / 10;
|
||||
this.deletionsRequested += batchSize;
|
||||
return groupedChunks;
|
||||
}
|
||||
}
|
||||
|
||||
private BiPredicate<RegionAccess, BlockVector2> createPredicates(List<ChunkDeletionInfo.DeletionPredicate> deletionPredicates) {
|
||||
if (deletionPredicates == null) return (r, p) -> true;
|
||||
return deletionPredicates.stream()
|
||||
.map(this::createPredicate)
|
||||
.reduce(BiPredicate::and)
|
||||
.orElse((r, p) -> true);
|
||||
}
|
||||
|
||||
private BiPredicate<RegionAccess, BlockVector2> createPredicate(ChunkDeletionInfo.DeletionPredicate deletionPredicate) {
|
||||
if ("modification".equals(deletionPredicate.property)) {
|
||||
int time;
|
||||
try {
|
||||
time = Integer.parseInt(deletionPredicate.value);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalStateException("Modification time predicate specified invalid time: " + deletionPredicate.value);
|
||||
}
|
||||
switch (deletionPredicate.comparison) {
|
||||
case "<":
|
||||
return (r, p) -> {
|
||||
try {
|
||||
return r.getModificationTime(p) < time;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
case ">":
|
||||
return (r, p) -> {
|
||||
try {
|
||||
return r.getModificationTime(p) > time;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
default:
|
||||
throw new IllegalStateException("Unexpected comparison value: " + deletionPredicate.comparison);
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("Unexpected property value: " + deletionPredicate.property);
|
||||
}
|
||||
|
||||
private void backupRegion(Path regionFile) throws IOException {
|
||||
Path backupFile = regionFile.resolveSibling(regionFile.getFileName() + ".bak");
|
||||
Files.copy(regionFile, backupFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
backedUpRegions.add(backupFile);
|
||||
}
|
||||
|
||||
private boolean deleteChunks(Path regionFile, Stream<BlockVector2> chunks,
|
||||
BiPredicate<RegionAccess, BlockVector2> deletionPredicate) {
|
||||
try (RegionAccess region = new RegionAccess(regionFile, shouldPreload)) {
|
||||
for (Iterator<BlockVector2> iterator = chunks.iterator(); iterator.hasNext();) {
|
||||
BlockVector2 chunk = iterator.next();
|
||||
if (chunk == null) break;
|
||||
if (deletionPredicate.test(region, chunk)) {
|
||||
region.deleteChunk(chunk);
|
||||
totalChunksDeleted++;
|
||||
if (totalChunksDeleted % debugRate == 0) {
|
||||
logger.debug("Deleted {} chunks so far.", totalChunksDeleted);
|
||||
}
|
||||
} else {
|
||||
logger.debug("Chunk did not match predicates: " + chunk);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
logger.warn("Error deleting chunks from region: " + regionFile + ". Aborting the process.", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public int getDeletedChunkCount() {
|
||||
return totalChunksDeleted;
|
||||
}
|
||||
|
||||
public int getDeletionsRequested() {
|
||||
return deletionsRequested;
|
||||
}
|
||||
|
||||
private static class BlockVector2Adapter extends TypeAdapter<BlockVector2> {
|
||||
@Override
|
||||
public void write(JsonWriter out, BlockVector2 value) throws IOException {
|
||||
out.beginArray();
|
||||
out.value(value.getBlockX());
|
||||
out.value(value.getBlockZ());
|
||||
out.endArray();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockVector2 read(JsonReader in) throws IOException {
|
||||
in.beginArray();
|
||||
int x = in.nextInt();
|
||||
int z = in.nextInt();
|
||||
in.endArray();
|
||||
return BlockVector2.at(x, z);
|
||||
}
|
||||
}
|
||||
|
||||
private static class RegionFilePos {
|
||||
private final int x;
|
||||
private final int z;
|
||||
|
||||
RegionFilePos(BlockVector2 chunk) {
|
||||
this.x = chunk.getBlockX() >> 5;
|
||||
this.z = chunk.getBlockZ() >> 5;
|
||||
}
|
||||
|
||||
RegionFilePos(int regX, int regZ) {
|
||||
this.x = regX;
|
||||
this.z = regZ;
|
||||
}
|
||||
|
||||
public int getX() {
|
||||
return x;
|
||||
}
|
||||
|
||||
public int getZ() {
|
||||
return z;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return "r." + x + "." + z + ".mca";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
RegionFilePos that = (RegionFilePos) o;
|
||||
|
||||
if (x != that.x) return false;
|
||||
return z == that.z;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = x;
|
||||
result = 31 * result + z;
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "(" + x + ", " + z + ")";
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
/*
|
||||
* WorldEdit, a Minecraft world manipulation toolkit
|
||||
* Copyright (C) sk89q <http://www.sk89q.com>
|
||||
* Copyright (C) WorldEdit team and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU Lesser General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
|
||||
* for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.sk89q.worldedit.internal.anvil;
|
||||
|
||||
import com.sk89q.worldedit.math.BlockVector2;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Internal class. Subject to changes.
|
||||
*/
|
||||
public class ChunkDeletionInfo {
|
||||
|
||||
public List<ChunkBatch> batches;
|
||||
|
||||
public static class ChunkBatch {
|
||||
public String worldPath;
|
||||
public boolean backup;
|
||||
public List<DeletionPredicate> deletionPredicates;
|
||||
// specify either list of chunks, or min-max
|
||||
public List<BlockVector2> chunks;
|
||||
public BlockVector2 minChunk;
|
||||
public BlockVector2 maxChunk;
|
||||
}
|
||||
|
||||
public static class DeletionPredicate {
|
||||
public String property;
|
||||
public String comparison;
|
||||
public String value;
|
||||
}
|
||||
}
|
@ -0,0 +1,101 @@
|
||||
/*
|
||||
* WorldEdit, a Minecraft world manipulation toolkit
|
||||
* Copyright (C) sk89q <http://www.sk89q.com>
|
||||
* Copyright (C) WorldEdit team and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU Lesser General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
|
||||
* for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.sk89q.worldedit.internal.anvil;
|
||||
|
||||
import com.sk89q.worldedit.math.BlockVector2;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Internal class. Subject to changes.
|
||||
*/
|
||||
class RegionAccess implements AutoCloseable {
|
||||
|
||||
private RandomAccessFile raf;
|
||||
private int[] offsets;
|
||||
private int[] timestamps;
|
||||
|
||||
RegionAccess(Path file) throws IOException {
|
||||
this(file, false);
|
||||
}
|
||||
|
||||
RegionAccess(Path file, boolean preload) throws IOException {
|
||||
raf = new RandomAccessFile(file.toFile(), "rw");
|
||||
if (preload) {
|
||||
readHeaders();
|
||||
}
|
||||
}
|
||||
|
||||
private void readHeaders() throws IOException {
|
||||
offsets = new int[1024];
|
||||
timestamps = new int[1024];
|
||||
for (int idx = 0; idx < 1024; ++idx) {
|
||||
offsets[idx] = raf.readInt();
|
||||
}
|
||||
for (int idx = 0; idx < 1024; ++idx) {
|
||||
timestamps[idx] = raf.readInt();
|
||||
}
|
||||
}
|
||||
|
||||
private static int indexChunk(BlockVector2 pos) {
|
||||
int x = pos.getBlockX() & 31;
|
||||
int z = pos.getBlockZ() & 31;
|
||||
return x + z * 32;
|
||||
}
|
||||
|
||||
int getModificationTime(BlockVector2 pos) throws IOException {
|
||||
int idx = indexChunk(pos);
|
||||
if (timestamps != null) {
|
||||
return timestamps[idx];
|
||||
}
|
||||
raf.seek(idx * 4L + 4096);
|
||||
return raf.readInt();
|
||||
}
|
||||
|
||||
int getChunkSize(BlockVector2 pos) throws IOException {
|
||||
int idx = indexChunk(pos);
|
||||
if (offsets != null) {
|
||||
return offsets[idx] & 0xFF;
|
||||
}
|
||||
raf.seek(idx * 4L);
|
||||
// 3 bytes for offset
|
||||
raf.read();
|
||||
raf.read();
|
||||
raf.read();
|
||||
// one byte for size - note, yes, could do raf.readInt() & 0xFF but that does extra checks
|
||||
return raf.read();
|
||||
}
|
||||
|
||||
void deleteChunk(BlockVector2 pos) throws IOException {
|
||||
int idx = indexChunk(pos);
|
||||
raf.seek(idx * 4L);
|
||||
raf.writeInt(0);
|
||||
if (offsets != null) {
|
||||
offsets[idx] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
raf.close();
|
||||
}
|
||||
}
|
@ -34,6 +34,7 @@ import com.sk89q.worldedit.world.block.BlockStateHolder;
|
||||
import com.sk89q.worldedit.world.block.BlockType;
|
||||
import com.sk89q.worldedit.world.block.BlockTypes;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.PriorityQueue;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
@ -56,6 +57,11 @@ public abstract class AbstractWorld implements World {
|
||||
return setBlock(pt, block, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getStoragePath() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMaxY() {
|
||||
return getMaximumPoint().getBlockY();
|
||||
|
@ -38,6 +38,9 @@ import com.sk89q.worldedit.world.block.BlockStateHolder;
|
||||
import com.sk89q.worldedit.world.block.BlockType;
|
||||
import com.sk89q.worldedit.world.weather.WeatherType;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Represents a world (dimension).
|
||||
*/
|
||||
@ -50,6 +53,15 @@ public interface World extends Extent {
|
||||
*/
|
||||
String getName();
|
||||
|
||||
/**
|
||||
* Get the folder in which this world is stored. May return null if unknown
|
||||
* or if this world is not serialized to disk.
|
||||
*
|
||||
* @return world storage path
|
||||
*/
|
||||
@Nullable
|
||||
Path getStoragePath();
|
||||
|
||||
/**
|
||||
* Get the maximum Y.
|
||||
*
|
||||
|
@ -100,10 +100,9 @@ public class McRegionReader {
|
||||
/**
|
||||
* Read the header.
|
||||
*
|
||||
* @throws DataException
|
||||
* @throws IOException
|
||||
*/
|
||||
private void readHeader() throws DataException, IOException {
|
||||
private void readHeader() throws IOException {
|
||||
offsets = new int[SECTOR_INTS];
|
||||
|
||||
for (int i = 0; i < SECTOR_INTS; ++i) {
|
||||
@ -124,10 +123,6 @@ public class McRegionReader {
|
||||
int x = position.getBlockX() & 31;
|
||||
int z = position.getBlockZ() & 31;
|
||||
|
||||
if (x < 0 || x >= 32 || z < 0 || z >= 32) {
|
||||
throw new DataException("MCRegion file does not contain " + x + "," + z);
|
||||
}
|
||||
|
||||
int offset = getOffset(x, z);
|
||||
|
||||
// The chunk hasn't been generated
|
||||
@ -138,7 +133,7 @@ public class McRegionReader {
|
||||
int sectorNumber = offset >> 8;
|
||||
int numSectors = offset & 0xFF;
|
||||
|
||||
stream.seek(sectorNumber * SECTOR_BYTES);
|
||||
stream.seek((long) sectorNumber * SECTOR_BYTES);
|
||||
int length = dataStream.readInt();
|
||||
|
||||
if (length > SECTOR_BYTES * numSectors) {
|
||||
|
@ -97,6 +97,7 @@ import net.minecraft.world.storage.WorldInfo;
|
||||
import javax.annotation.Nullable;
|
||||
import java.io.File;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@ -167,6 +168,15 @@ public class ForgeWorld extends AbstractWorld {
|
||||
return getWorld().getWorldInfo().getWorldName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getStoragePath() {
|
||||
final World world = getWorld();
|
||||
if (world instanceof ServerWorld) {
|
||||
return ((ServerWorld) world).getSaveHandler().getWorldDirectory().toPath();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <B extends BlockStateHolder<B>> boolean setBlock(BlockVector3 position, B block, boolean notifyAndLight) throws WorldEditException {
|
||||
checkNotNull(position);
|
||||
|
@ -31,6 +31,7 @@ import com.sk89q.worldedit.forge.net.packet.LeftClickAirEventMessage;
|
||||
import com.sk89q.worldedit.forge.proxy.ClientProxy;
|
||||
import com.sk89q.worldedit.forge.proxy.CommonProxy;
|
||||
import com.sk89q.worldedit.forge.proxy.ServerProxy;
|
||||
import com.sk89q.worldedit.internal.anvil.ChunkDeleter;
|
||||
import com.sk89q.worldedit.util.Location;
|
||||
import com.sk89q.worldedit.world.biome.BiomeType;
|
||||
import com.sk89q.worldedit.world.block.BlockCategory;
|
||||
@ -45,7 +46,6 @@ import net.minecraft.tags.ItemTags;
|
||||
import net.minecraft.util.Hand;
|
||||
import net.minecraft.util.ResourceLocation;
|
||||
import net.minecraft.world.World;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.common.MinecraftForge;
|
||||
import net.minecraftforge.event.CommandEvent;
|
||||
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
|
||||
@ -58,11 +58,10 @@ import net.minecraftforge.fml.ModContainer;
|
||||
import net.minecraftforge.fml.ModLoadingContext;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent;
|
||||
import net.minecraftforge.fml.event.lifecycle.FMLLoadCompleteEvent;
|
||||
import net.minecraftforge.fml.event.server.FMLServerAboutToStartEvent;
|
||||
import net.minecraftforge.fml.event.server.FMLServerStartedEvent;
|
||||
import net.minecraftforge.fml.event.server.FMLServerStoppingEvent;
|
||||
import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext;
|
||||
import net.minecraftforge.fml.loading.FMLLoader;
|
||||
import net.minecraftforge.fml.loading.FMLPaths;
|
||||
import net.minecraftforge.registries.ForgeRegistries;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
@ -76,6 +75,7 @@ import java.nio.file.Path;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
import static com.sk89q.worldedit.forge.ForgeAdapter.adaptPlayer;
|
||||
import static com.sk89q.worldedit.internal.anvil.ChunkDeleter.DELCHUNKS_FILE_NAME;
|
||||
|
||||
/**
|
||||
* The Forge implementation of WorldEdit.
|
||||
@ -180,6 +180,14 @@ public class ForgeWorldEdit {
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void serverAboutToStart(FMLServerAboutToStartEvent event) {
|
||||
final Path delChunks = workingDir.resolve(DELCHUNKS_FILE_NAME);
|
||||
if (Files.exists(delChunks)) {
|
||||
ChunkDeleter.runFromFile(delChunks, true);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void serverStopping(FMLServerStoppingEvent event) {
|
||||
WorldEdit worldEdit = WorldEdit.getInstance();
|
||||
|
@ -57,6 +57,7 @@ import org.spongepowered.api.world.World;
|
||||
import org.spongepowered.api.world.weather.Weather;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -116,6 +117,11 @@ public abstract class SpongeWorld extends AbstractWorld {
|
||||
return getWorld().getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getStoragePath() {
|
||||
return getWorld().getDirectory();
|
||||
}
|
||||
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
protected BlockState getBlockState(BlockStateHolder<?> block) {
|
||||
if (block instanceof com.sk89q.worldedit.world.block.BlockState) {
|
||||
|
@ -20,6 +20,7 @@
|
||||
package com.sk89q.worldedit.sponge;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
import static com.sk89q.worldedit.internal.anvil.ChunkDeleter.DELCHUNKS_FILE_NAME;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
import com.sk89q.worldedit.LocalSession;
|
||||
@ -29,6 +30,7 @@ import com.sk89q.worldedit.event.platform.PlatformReadyEvent;
|
||||
import com.sk89q.worldedit.extension.platform.Actor;
|
||||
import com.sk89q.worldedit.extension.platform.Capability;
|
||||
import com.sk89q.worldedit.extension.platform.Platform;
|
||||
import com.sk89q.worldedit.internal.anvil.ChunkDeleter;
|
||||
import com.sk89q.worldedit.sponge.adapter.AdapterLoadException;
|
||||
import com.sk89q.worldedit.sponge.adapter.SpongeImplAdapter;
|
||||
import com.sk89q.worldedit.sponge.adapter.SpongeImplLoader;
|
||||
@ -62,6 +64,8 @@ import org.spongepowered.api.world.World;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@ -134,6 +138,11 @@ public class SpongeWorldEdit {
|
||||
WorldEdit.getInstance().getPlatformManager().unregister(platform);
|
||||
}
|
||||
|
||||
final Path delChunks = workingDir.toPath().resolve(DELCHUNKS_FILE_NAME);
|
||||
if (Files.exists(delChunks)) {
|
||||
ChunkDeleter.runFromFile(delChunks, true);
|
||||
}
|
||||
|
||||
this.platform = new SpongePlatform(this);
|
||||
this.provider = new SpongePermissionsProvider();
|
||||
|
||||
|
Laden…
In neuem Issue referenzieren
Einen Benutzer sperren