diff --git a/src/main/java/cpw/mods/fml/common/ZipperUtil.java b/src/main/java/cpw/mods/fml/common/ZipperUtil.java index 4878e83..2655e29 100644 --- a/src/main/java/cpw/mods/fml/common/ZipperUtil.java +++ b/src/main/java/cpw/mods/fml/common/ZipperUtil.java @@ -61,8 +61,14 @@ public static void backupWorld() throws IOException { - for(String dirName: FMLCommonHandler.instance().getMinecraftServerInstance().getMultiWorld().getDirsForBackup()) - backupWorld(dirName); + if(FMLCommonHandler.instance().getSide().isServer()) + { + FMLCommonHandler.instance().getMinecraftServerInstance().getBackupManager().backupWorldsSyncUnsafe(); + } + else + { + backupWorld(FMLCommonHandler.instance().getMinecraftServerInstance().getFolderName()); + } } @Deprecated diff --git a/src/main/java/net/minecraft/server/MinecraftServer.java b/src/main/java/net/minecraft/server/MinecraftServer.java index e34377b..1be706b 100644 --- a/src/main/java/net/minecraft/server/MinecraftServer.java +++ b/src/main/java/net/minecraft/server/MinecraftServer.java @@ -34,6 +34,7 @@ import java.util.Random; import java.util.UUID; import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; import javax.imageio.ImageIO; @@ -80,9 +81,11 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.ultramine.permission.IPermissionManager; +import org.ultramine.server.BackupManager; import org.ultramine.server.ConfigurationHandler; import org.ultramine.server.MultiWorld; import org.ultramine.server.WatchdogThread; +import org.ultramine.server.util.GlobalExecutors; import net.minecraftforge.common.DimensionManager; import net.minecraftforge.common.MinecraftForge; @@ -375,6 +378,14 @@ { this.usageSnooper.stopSnooper(); } + + logger.info("Saving other data"); + try + { + GlobalExecutors.writingIOExecutor().shutdown(); + GlobalExecutors.writingIOExecutor().awaitTermination(10000, TimeUnit.MILLISECONDS); + } + catch(InterruptedException ignored){} } } @@ -1497,6 +1508,11 @@ { return multiworld; } + + public BackupManager getBackupManager() + { + return null; + } public IPermissionManager getPermissionManager() { @@ -1517,4 +1533,11 @@ { return getDataDirectory(); } + + public File getBackupDir() + { + File file = new File(getHomeDirectory(), "backup"); + file.mkdir(); + return file; + } } diff --git a/src/main/java/net/minecraft/server/dedicated/DedicatedServer.java b/src/main/java/net/minecraft/server/dedicated/DedicatedServer.java index a2b4a65..453775c 100644 --- a/src/main/java/net/minecraft/server/dedicated/DedicatedServer.java +++ b/src/main/java/net/minecraft/server/dedicated/DedicatedServer.java @@ -43,6 +43,7 @@ import org.ultramine.permission.MinecraftPermissions; import org.ultramine.permission.PermissionRepository; import org.ultramine.permission.internal.ServerPermissionManager; +import org.ultramine.server.BackupManager; import org.ultramine.server.ConfigurationHandler; import org.ultramine.server.PermissionHandler; import org.ultramine.server.UltramineServerConfig; @@ -570,6 +571,8 @@ /* ======================================== ULTRAMINE START =====================================*/ + private final BackupManager backupMgr = new BackupManager(this); + @Override protected void loadAllWorlds(String par1Str, String par2Str, long par3, WorldType par5WorldType, String par6Str) { @@ -593,4 +596,10 @@ { return FMLLaunchHandler.getMinecraftHome(); } + + @Override + public BackupManager getBackupManager() + { + return backupMgr; + } } \ No newline at end of file diff --git a/src/main/java/net/minecraft/world/World.java b/src/main/java/net/minecraft/world/World.java index bab96ef..7395ebf 100644 --- a/src/main/java/net/minecraft/world/World.java +++ b/src/main/java/net/minecraft/world/World.java @@ -4160,4 +4160,16 @@ } return count; } + + @SuppressWarnings("unchecked") + public void forceUnloadTileEntities() + { + if (!this.field_147483_b.isEmpty()) + { + for (Object tile : field_147483_b) + ((TileEntity)tile).onChunkUnload(); + this.loadedTileEntityList.removeAll(this.field_147483_b); + this.field_147483_b.clear(); + } + } } diff --git a/src/main/java/net/minecraft/world/chunk/storage/AnvilChunkLoader.java b/src/main/java/net/minecraft/world/chunk/storage/AnvilChunkLoader.java index 95c979f..b05508e 100644 --- a/src/main/java/net/minecraft/world/chunk/storage/AnvilChunkLoader.java +++ b/src/main/java/net/minecraft/world/chunk/storage/AnvilChunkLoader.java @@ -511,6 +511,14 @@ } } + public void unsafeRemoveAll() + { + synchronized(syncLockObject) + { + pendingSaves.clear(); + } + } + static class PendingChunk { public final ChunkCoordIntPair chunkCoordinate; diff --git a/src/main/java/net/minecraft/world/gen/ChunkProviderServer.java b/src/main/java/net/minecraft/world/gen/ChunkProviderServer.java index 327d8db..a98ba56 100644 --- a/src/main/java/net/minecraft/world/gen/ChunkProviderServer.java +++ b/src/main/java/net/minecraft/world/gen/ChunkProviderServer.java @@ -152,6 +152,8 @@ // We can only use the queue for already generated chunks if (chunk == null && loader != null && loader.chunkExists(this.worldObj, par1, par2)) { + if(isWorldUnloaded()) + return defaultEmptyChunk; if (runnable != null) { ChunkIOExecutor.queueChunkLoad(this.worldObj, loader, this, par1, par2, runnable); @@ -353,7 +355,7 @@ public boolean unloadQueuedChunks() { -// if (!this.worldObj.levelSaving) + if (!preventSaving) { if(isServer) chunkGC.onTick(); @@ -452,8 +454,11 @@ private static final int FULL_SAVE_INTERVAL = 10*60*20; //10 min private static final boolean isServer = FMLCommonHandler.instance().getSide().isServer(); private static final boolean debugSyncLoad = Boolean.parseBoolean(System.getProperty("ultramine.debug.chunksyncload")); + private final TIntSet possibleSaves = new TIntHashSet(); private int lastFullSaveTick; + private boolean preventSaving; + private boolean isWorldUnloaded; @SideOnly(Side.SERVER) private ChunkGC chunkGC; @@ -546,6 +551,9 @@ public void saveOneChunk(int tick) { + if(preventSaving) + return; + if(tick - lastFullSaveTick >= FULL_SAVE_INTERVAL) { for(TIntObjectIterator it = loadedChunkHashMap.iterator(); it.hasNext();) @@ -586,4 +594,38 @@ for(int z = cz - radius; z <= cz + radius; z++) populate(this, x, z); } + + public void preventSaving() + { + preventSaving = true; + } + + public void resumeSaving() + { + preventSaving = false; + } + + public void setWorldUnloaded() + { + isWorldUnloaded = true; + } + + public boolean isWorldUnloaded() + { + return isWorldUnloaded; + } + + public void unloadAllWithoutSave() + { + for(Chunk chunk : loadedChunkHashMap.valueCollection()) + { + chunk.onChunkUnload(); + MinecraftForge.EVENT_BUS.post(new ChunkDataEvent.Save(chunk, new NBTTagCompound())); + } + + loadedChunkHashMap.clear(); + chunksToUnload.clear(); + possibleSaves.clear(); + ((AnvilChunkLoader)currentChunkLoader).unsafeRemoveAll(); + } } \ No newline at end of file diff --git a/src/main/java/net/minecraftforge/common/chunkio/ChunkIOProvider.java b/src/main/java/net/minecraftforge/common/chunkio/ChunkIOProvider.java index ed8da13..6be3a7e 100644 --- a/src/main/java/net/minecraftforge/common/chunkio/ChunkIOProvider.java +++ b/src/main/java/net/minecraftforge/common/chunkio/ChunkIOProvider.java @@ -17,6 +17,8 @@ // async stuff public net.minecraft.world.chunk.Chunk callStage1(QueuedChunk queuedChunk) throws RuntimeException { + if(queuedChunk.provider.isWorldUnloaded()) + return null; net.minecraft.world.chunk.storage.AnvilChunkLoader loader = queuedChunk.loader; Object[] data = null; // try { @@ -35,6 +37,8 @@ // sync stuff public void callStage2(QueuedChunk queuedChunk, net.minecraft.world.chunk.Chunk chunk) throws RuntimeException { + if(queuedChunk.provider.isWorldUnloaded()) + return; if(chunk == null) { // If the chunk loading failed just do it synchronously (may generate) queuedChunk.provider.originalLoadChunk(queuedChunk.x, queuedChunk.z); @@ -55,6 +59,8 @@ } public void callStage3(QueuedChunk queuedChunk, net.minecraft.world.chunk.Chunk chunk, IChunkLoadCallback callback) throws RuntimeException { + if(queuedChunk.provider.isWorldUnloaded()) + return; callback.onChunkLoaded(chunk != null ? chunk : queuedChunk.provider.getChunkIfExists(queuedChunk.x, queuedChunk.z)); } diff --git a/src/main/java/org/ultramine/commands/CommandContext.java b/src/main/java/org/ultramine/commands/CommandContext.java index 0c9b4e1..8f17710 100644 --- a/src/main/java/org/ultramine/commands/CommandContext.java +++ b/src/main/java/org/ultramine/commands/CommandContext.java @@ -23,9 +23,13 @@ import org.ultramine.server.data.player.PlayerData; import org.ultramine.server.util.BasicTypeParser; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; public class CommandContext { @@ -194,6 +198,11 @@ throw new CommandException(msg); } + public void failure(String msg, Object... params) + { + throw new CommandException(msg, params); + } + public void check(boolean flag, String msg) { if(!flag) @@ -279,6 +288,44 @@ result[i-num] = new Argument(i, false); return result; } + + public String[] asStringArray() + { + if(num >= 0 && last && args.length > num+1) + return Arrays.copyOfRange(args, num, args.length); + return new String[]{value}; + } + + public Map> asFlags(String... allowedArr) + { + Set allowed = allowedArr.length == 0 ? null : new HashSet(Arrays.asList(allowedArr)); + Map> map = new HashMap>(); + String curFlag = null; + List curList = new ArrayList(0); + for(String s : asStringArray()) + { + if(s.startsWith("-")) + { + if(curFlag != null) + { + map.put(curFlag, curList); + curList = new ArrayList(0); + } + + curFlag = s.substring(1); + if(allowed != null && !allowed.contains(curFlag)) + failure("###unknown flag %s", curFlag); //TODO + } + else + { + curList.add(s); + } + } + if(curFlag != null) + map.put(curFlag, curList); + + return map; + } public int asInt() { diff --git a/src/main/java/org/ultramine/commands/basic/TechCommands.java b/src/main/java/org/ultramine/commands/basic/TechCommands.java index 06026be..5ec4f10 100644 --- a/src/main/java/org/ultramine/commands/basic/TechCommands.java +++ b/src/main/java/org/ultramine/commands/basic/TechCommands.java @@ -1,5 +1,11 @@ package org.ultramine.commands.basic; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + import net.minecraft.entity.Entity; import net.minecraft.entity.EnumCreatureType; import net.minecraft.entity.item.EntityItem; @@ -20,17 +26,22 @@ import org.apache.logging.log4j.Logger; import org.ultramine.commands.Command; import org.ultramine.commands.CommandContext; +import org.ultramine.server.BackupManager; import org.ultramine.server.MultiWorld; import org.ultramine.server.Restarter; import org.ultramine.server.Teleporter; +import org.ultramine.server.BackupManager.BackupDescriptor; import org.ultramine.server.WorldsConfig.WorldConfig.Border; import org.ultramine.server.chunk.ChunkProfiler; import org.ultramine.server.chunk.IChunkLoadCallback; +import org.ultramine.server.util.BasicTypeParser; import cpw.mods.fml.common.FMLCommonHandler; import cpw.mods.fml.common.eventhandler.SubscribeEvent; import cpw.mods.fml.common.functions.GenericIterableFactory; import cpw.mods.fml.common.gameevent.TickEvent; +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; public class TechCommands { @@ -151,7 +162,7 @@ for(int dim : DimensionManager.getStaticDimensionIDs()) { String name = mw.getNameByID(dim); - ctx.sendMessage(GOLD, " - %s - %s - %s", dim, name != null ? name : "unnamed", mw.getWorldByID(dim) != null ? "loaded" : "unloaded"); + ctx.sendMessage(GOLD, " - [%s](%s) - %s", dim, name != null ? name : "unnamed", mw.getWorldByID(dim) != null ? "loaded" : "unloaded"); } return; } @@ -622,4 +633,60 @@ } } } + + @SideOnly(Side.SERVER) + @Command( + name = "backup", + group = "technical", + permissions = {"command.backup"}, + syntax = { + "[make now]", + "[make now] <%world>...", + "[list]", + "[apply] ", + "[apply] ..." + } + ) + public static void backup(CommandContext ctx) + { + BackupManager mgr = ctx.getServer().getBackupManager(); + MultiWorld mw = ctx.getServer().getMultiWorld(); + if(ctx.getAction().equals("make") || ctx.getAction().equals("now")) + { + Collection worlds = ctx.contains("world") ? mw.resolveSaveDirs(Arrays.asList(ctx.get("world").asStringArray())) : mw.getDirsForBackup(); + ctx.check(worlds.size() != 0, "command.backup.make.fail"); + ctx.sendMessage("command.backup.make.started", worlds); + mgr.backup(worlds); + } + else if(ctx.getAction().equals("list")) + { + List list = mgr.getBackupList(); + ctx.sendMessage("command.backup.list.head"); + for(int i = 0, s = list.size(); i < s; i++) + { + ctx.sendMessage(" - #%s %s", s-i, list.get(i).getName()); + } + } + else if(ctx.getAction().equals("apply")) + { + String path = ctx.get("number").asString(); + Map> flags = ctx.contains("flags") ? ctx.get("flags").asFlags("worlds", "noplayers", "temp", "restart") : new HashMap>(); + if(BasicTypeParser.isInt(path)) + { + int num = ctx.get("number").asInt(1); + List list = mgr.getBackupList(); + ctx.check(num <= list.size(), "command.backup.apply.fail.none"); + path = list.get(list.size()-num).getName(); + } + + if(flags.containsKey("restart")) + for(EntityPlayerMP player : GenericIterableFactory.newCastingIterable(ctx.getServer().getConfigurationManager().playerEntityList, EntityPlayerMP.class)) + player.playerNetServerHandler.kickPlayerFromServer("\u00a75Выполняется бэкап мира\n\u00a7dВы сможете войти через несколько минут"); + + mgr.applyBackup(path, ctx, flags.get("worlds"), !flags.containsKey("noplayers"), flags.containsKey("temp")); + + if(flags.containsKey("restart")) + ctx.getServer().initiateShutdown(); + } + } } diff --git a/src/main/java/org/ultramine/server/BackupManager.java b/src/main/java/org/ultramine/server/BackupManager.java new file mode 100644 index 0000000..c0c5515 --- /dev/null +++ b/src/main/java/org/ultramine/server/BackupManager.java @@ -0,0 +1,417 @@ +package org.ultramine.server; + +import gnu.trove.iterator.TIntObjectIterator; +import gnu.trove.map.TIntObjectMap; +import gnu.trove.map.hash.TIntObjectHashMap; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +import net.minecraft.command.CommandException; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.network.play.server.S00PacketKeepAlive; +import net.minecraft.network.play.server.S1DPacketEntityEffect; +import net.minecraft.potion.PotionEffect; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.ChatComponentTranslation; +import net.minecraft.util.ChatStyle; +import net.minecraft.util.EnumChatFormatting; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.storage.RegionFileCache; +import net.minecraft.world.storage.ThreadedFileIOBase; +import net.minecraftforge.common.DimensionManager; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.world.WorldEvent; + +import org.apache.commons.io.FileUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ultramine.commands.CommandContext; +import org.ultramine.server.UltramineServerConfig.SettingsConf.AutoBackupConf; +import org.ultramine.server.data.ServerDataLoader; +import org.ultramine.server.util.GlobalExecutors; +import org.ultramine.server.util.ZipUtil; + +import com.google.common.base.Function; + +import cpw.mods.fml.common.FMLCommonHandler; +import cpw.mods.fml.common.functions.GenericIterableFactory; +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; + +@SideOnly(Side.SERVER) +public class BackupManager +{ + private static final Logger log = LogManager.getLogger(); + + private final MinecraftServer server; + + private long lastBackupTime; + private boolean isBackuping; + private AtomicBoolean backupCompleted = new AtomicBoolean(false); + + public BackupManager(MinecraftServer server) + { + this.server = server; + this.lastBackupTime = server.startTime; + } + + public void tick() + { + if(backupCompleted.get()) + { + backupCompleted.set(false); + for(WorldServer world : server.getMultiWorld().getLoadedWorlds()) + world.theChunkProviderServer.resumeSaving(); + + isBackuping = false; + } + + AutoBackupConf conf = ConfigurationHandler.getServerConfig().settings.autobackup; + if(conf.enabled && (System.currentTimeMillis() - lastBackupTime >= conf.interval*60*1000) && !isBackuping) + { + lastBackupTime = System.currentTimeMillis(); + File dir = server.getBackupDir(); + List list = getBackupList(); + + if(conf.maxBackups != -1) + while(list.size() >= conf.maxBackups) + FileUtils.deleteQuietly(new File(dir, list.remove(0).getName())); + while(conf.maxDirSize != -1 && conf.maxDirSize > FileUtils.sizeOfDirectory(dir) && list.size() != 0) + FileUtils.deleteQuietly(new File(dir, list.remove(0).getName())); + + if(conf.notifyPlayers) + server.getConfigurationManager().sendChatMsg(new ChatComponentTranslation("ultramine.autobackup.start") + .setChatStyle(new ChatStyle().setColor(EnumChatFormatting.GOLD))); + + if(conf.worlds == null) + backupAll(); + else + backup(conf.worlds); + } + } + + public List getBackupList() + { + List list = new ArrayList(); + for(File file : server.getBackupDir().listFiles()) + { + if(file.getName().endsWith(".zip")) + list.add(new BackupDescriptor(file)); + } + Collections.sort(list); + return list; + } + + public void backupWorldsSyncUnsafe() throws IOException + { + String filename = backupWorldDirs(server.getMultiWorld().getDirsForBackup()); + log.info("World backup created {}", filename); + } + + private String backupWorldDirs(Collection worlds) throws IOException + { + String zipname = String.format("%1$tY.%1$tm.%1$td_%1$tH-%1$tM-%1$tS.zip", System.currentTimeMillis()); + File zip = new File(server.getBackupDir(), zipname); + File worldsDir = FMLCommonHandler.instance().getSavesDirectory(); + ZipUtil.zipAll(zip, worldsDir, worlds); + return zip.getName(); + } + + public void backupAll() + { + backup(server.getMultiWorld().getDirsForBackup()); + } + + public void backup(final Collection dirs) + { + if(isBackuping) + throw new IllegalStateException("Already backuping"); + isBackuping = true; + log.info("Starting backup, saving worlds"); + server.getConfigurationManager().saveAllPlayerData(); + for(WorldServer world : server.getMultiWorld().getLoadedWorlds()) + { + if(dirs.contains(server.getMultiWorld().getSaveDirName(world))) + { + world.theChunkProviderServer.saveChunks(true, null); + MinecraftForge.EVENT_BUS.post(new WorldEvent.Save(world)); + } + world.theChunkProviderServer.preventSaving(); + } + + GlobalExecutors.writingIOExecutor().execute(new Runnable() + { + @Override + public void run() + { + try + { + ThreadedFileIOBase.threadedIOInstance.waitForFinish(); + } catch (InterruptedException ignored){} + + RegionFileCache.clearRegionFileReferences(); + + log.info("Worlds saved, making backup"); + + try + { + String filename = backupWorldDirs(dirs); + log.info("World backup completed {}", filename); + } + catch(IOException e) + { + log.error("Failed to make backup", e); + } + + backupCompleted.set(true); + } + }); + } + + public void applyBackup(String path) throws CommandException + { + applyBackup(path, null, null, true, false); + } + + //Адовы костыли с CommandContext и CommandException. Нужна универсальность без привязки к системе команд + public void applyBackup(String path, CommandContext ctx, List moveOnlyList, boolean movePlayersP, final boolean makeTemp) throws CommandException + { + final boolean movePlayers = movePlayersP && server.getConfigurationManager().getDataLoader().getDataProvider().isUsingWorldPlayerDir(); + File zipFile = new File(server.getBackupDir(), path); + if(!zipFile.exists()) + throw new CommandException("command.backup.apply.fail.nofile", path); + + final Set moveOnly; + try + { + Set available = ZipUtil.getRootFiles(zipFile); + if(moveOnlyList == null) + { + moveOnly = new HashSet(available); + } + else + { + moveOnly = new HashSet(moveOnlyList); + moveOnly.retainAll(available); + } + } + catch(IOException e) + { + log.error("Failed to apply backup (read zip file)", e); + throw new CommandException("command.backup.apply.fail.zip.read", path); + } + + if(moveOnly.size() == 0) + throw new RuntimeException("command.backup.apply.fail.nothing"); + else if(ctx != null) + ctx.sendMessage("command.backup.apply.started", moveOnly); + + TIntObjectMap> dimToPlayerMap = new TIntObjectHashMap>(); + if(!makeTemp) + { + List worlds = new ArrayList(server.getMultiWorld().getLoadedWorlds()); + for(WorldServer world : worlds) + { + if(!moveOnly.contains(server.getMultiWorld().getSaveDirName(world))) + continue; + List players = server.getMultiWorld().destroyWorld(world); + dimToPlayerMap.put(world.provider.dimensionId, players); + } + + try + { + ThreadedFileIOBase.threadedIOInstance.waitForFinish(); + } catch (InterruptedException ignored){} + + RegionFileCache.clearRegionFileReferences(); + + for(WorldServer world : worlds) + { + String saveDir = server.getMultiWorld().getSaveDirName(world); + if(!moveOnly.contains(saveDir)) + continue; + try + { + if(movePlayers) + { + FileUtils.deleteDirectory(new File(server.getWorldsDir(), saveDir)); + } + else + { + for(File file : new File(server.getWorldsDir(), saveDir).listFiles()) + { + if(!file.getName().equals("playerdata")) + FileUtils.forceDelete(file); + } + } + } + catch(IOException e) + { + log.warn("Failed to delete world directory ("+saveDir+") on backup apply", e); + if(ctx != null) + ctx.sendMessage(EnumChatFormatting.RED, "command.backup.apply.fail.rmdir", saveDir); + } + } + + for(EntityPlayerMP player : GenericIterableFactory.newCastingIterable(server.getConfigurationManager().playerEntityList, EntityPlayerMP.class)) + player.playerNetServerHandler.sendPacket(new S00PacketKeepAlive((int)(System.nanoTime() / 1000000L))); //prevent disconnect + } + + try + { + ZipUtil.unzip(zipFile, server.getWorldsDir(), new Function() + { + @Override + public String apply(String name) + { + for(String s : moveOnly) + if(!name.startsWith(s)) + return null; + if(name.endsWith("/session.lock")) + return null; + if(!movePlayers && name.contains("/playerdata/")) + return null; + if(makeTemp) + name = "unpack_" + name; + return name; + } + }); + } + catch(IOException e) + { + log.error("Failed to apply backup (unpack or write files)! May lead to major bugs!", e); + if(ctx != null) + ctx.sendMessage(EnumChatFormatting.RED, "command.backup.apply.fail.zip.unpack"); + } + + for(EntityPlayerMP player : GenericIterableFactory.newCastingIterable(server.getConfigurationManager().playerEntityList, EntityPlayerMP.class)) + player.playerNetServerHandler.sendPacket(new S00PacketKeepAlive((int)(System.nanoTime() / 1000000L))); //prevent disconnect + + if(makeTemp) + { + if(ctx != null) + ctx.sendMessage("command.backup.apply.success.temp"); + for(File file : server.getWorldsDir().listFiles()) + { + String name = file.getName(); + if(name.startsWith("unpack_")) + { + int dim = server.getMultiWorld().allocTempDim(); + name = "temp_"+dim+"_"+name.substring(7); + file.renameTo(new File(server.getWorldsDir(), name)); + server.getMultiWorld().makeTempWorld(name, dim); + if(ctx != null) + ctx.sendMessage(" - [%s](%s)", dim, name); + } + } + } + else + { + boolean backOverworld = dimToPlayerMap.containsKey(0); + if(backOverworld) + server.getMultiWorld().initDimension(0); //overworld first + for(TIntObjectIterator> it = dimToPlayerMap.iterator(); it.hasNext();) + { + it.advance(); + int dim = it.key(); + if(dim != 0) + DimensionManager.initDimension(dim); + } + + ServerDataLoader loader = server.getConfigurationManager().getDataLoader(); + if(movePlayers && backOverworld) //global player data reload + { + loader.reloadPlayerCache(); + for(EntityPlayerMP player : GenericIterableFactory.newCastingIterable(server.getConfigurationManager().playerEntityList, EntityPlayerMP.class)) + reloadPlayer(player); + } + + for(TIntObjectIterator> it = dimToPlayerMap.iterator(); it.hasNext();) + { + it.advance(); + int dim = it.key(); + WorldServer world = server.getMultiWorld().getWorldByID(dim); + for(EntityPlayerMP player : it.value()) + { + player.setWorld(world); + if(movePlayers) + { + if(server.getMultiWorld().getIsolatedDataDims().contains(dim)) + reloadPlayer(player); + } + else + { + world.spawnEntityInWorld(player); + world.getPlayerManager().addPlayer(player); + player.theItemInWorldManager.setWorld(world); + } + } + } + + if(ctx != null) + ctx.sendMessage("command.backup.apply.success"); + } + } + + private void reloadPlayer(EntityPlayerMP player) + { + int curdim = player.dimension; + WorldServer world = server.getMultiWorld().getWorldByID(curdim); + player.setWorld(world); + server.getConfigurationManager().getDataLoader().syncReloadPlayer(player); + int newdim = player.dimension; + if(newdim != curdim) + { + player.dimension = curdim; + player.transferToDimension(newdim); + } + else + { + world.spawnEntityInWorld(player); + world.getPlayerManager().addPlayer(player); + player.theItemInWorldManager.setWorld(world); + player.playerNetServerHandler.setPlayerLocation(player.posX, player.posY, player.posZ, player.rotationYaw, player.rotationPitch); + server.getConfigurationManager().updateTimeAndWeatherForPlayer(player, world); + server.getConfigurationManager().syncPlayerInventory(player); + + for(PotionEffect eff : GenericIterableFactory.newCastingIterable(player.getActivePotionEffects(), PotionEffect.class)) + player.playerNetServerHandler.sendPacket(new S1DPacketEntityEffect(player.getEntityId(), eff)); + } + } + + public static class BackupDescriptor implements Comparable + { + private final String name; + private final long time; + + private BackupDescriptor(String name, long time) + { + this.name = name; + this.time = time; + } + + private BackupDescriptor(File file) + { + this(file.getName(), file.lastModified()); + } + + @Override + public int compareTo(BackupDescriptor b) + { + return Long.compare(time, b.time); + } + + public String getName() + { + return name; + } + } +} diff --git a/src/main/java/org/ultramine/server/MultiWorld.java b/src/main/java/org/ultramine/server/MultiWorld.java index e4f01e4..059bd73 100644 --- a/src/main/java/org/ultramine/server/MultiWorld.java +++ b/src/main/java/org/ultramine/server/MultiWorld.java @@ -1,9 +1,11 @@ package org.ultramine.server; import java.io.File; +import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; @@ -28,7 +30,9 @@ import gnu.trove.map.hash.TIntObjectHashMap; import gnu.trove.set.TIntSet; import gnu.trove.set.hash.TIntHashSet; +import net.minecraft.entity.player.EntityPlayerMP; import net.minecraft.server.MinecraftServer; +import net.minecraft.tileentity.TileEntity; import net.minecraft.world.WorldManager; import net.minecraft.world.WorldServer; import net.minecraft.world.WorldServerMulti; @@ -59,6 +63,13 @@ this.server = server; } + private void sendDimensionToAll(int dim, int pid) + { + FMLEmbeddedChannel channel = NetworkRegistry.INSTANCE.getChannel("FORGE", Side.SERVER); + channel.attr(FMLOutboundHandler.FML_MESSAGETARGET).set(FMLOutboundHandler.OutboundTarget.ALL); + channel.writeAndFlush(new ForgeMessage.DimensionRegisterMessage(dim, pid == -10 ? 0 : pid)); + } + @SubscribeEvent public void onPlayerLoggedIn(FMLNetworkEvent.ServerConnectionFromClientEvent event) { @@ -79,6 +90,7 @@ { dimToWorldMap.remove(event.world.provider.dimensionId); nameToWorldMap.remove(resolveNameForDim(event.world.provider.dimensionId)); + ((WorldServer)event.world).theChunkProviderServer.setWorldUnloaded(); } } @@ -193,7 +205,15 @@ } WorldServer world; - if(ConfigurationHandler.getServerConfig().settings.other.splitWorldDirs) + if(dim == 0) + { + ISaveHandler mainSaveHandler = format.getSaveLoader(name, true); + WorldInfo mainWorldInfo = mainSaveHandler.loadWorldInfo(); + WorldSettings mainSettings = makeSettings(mainWorldInfo, conf); + + world = new WorldServer(server, mainSaveHandler, name, dim, mainSettings, server.theProfiler); + } + else if(ConfigurationHandler.getServerConfig().settings.other.splitWorldDirs) { ISaveHandler save = format.getSaveLoader(name, false); ((AnvilSaveHandler)save).setSingleStorage(); @@ -292,6 +312,79 @@ backupDirs.add(name); } + @SideOnly(Side.SERVER) + public int allocTempDim() + { + return DimensionManager.getNextFreeDimId(); + } + + @SideOnly(Side.SERVER) + public WorldServer makeTempWorld() + { + int dim = allocTempDim(); + return makeTempWorld("temp_"+dim, dim); + } + + @SideOnly(Side.SERVER) + public WorldServer makeTempWorld(String name) + { + return makeTempWorld(name, allocTempDim()); + } + + @SideOnly(Side.SERVER) + public WorldServer makeTempWorld(String name, int dim) + { + if(DimensionManager.isDimensionRegistered(dim)) + throw new RuntimeException("Dimension "+dim+" already registered (on making temp world)"); + + DimensionManager.registerDimension(dim, 0); + dimToNameMap.put(dim, name); + ISaveFormat format = server.getActiveAnvilConverter(); + ISaveHandler save = format.getSaveLoader(name, false); + ((AnvilSaveHandler)save).setSingleStorage(); + WorldInfo wi = save.loadWorldInfo(); + if(wi != null) + wi.setWorldName(name); + WorldServer world = new WorldServer(server, save, name, dim, makeSettings(wi, ConfigurationHandler.getWorldsConfig().global), server.theProfiler); + initWorld(world, ConfigurationHandler.getWorldsConfig().global); + backupDirs.remove(name); + sendDimensionToAll(dim, 0); + return world; + } + + @SideOnly(Side.SERVER) + public List destroyWorld(WorldServer world) + { + @SuppressWarnings("unchecked") + List players = new ArrayList(world.playerEntities); + for(EntityPlayerMP player : players) + { + world.removePlayerEntityDangerously(player); + player.isDead = false; + world.getEntityTracker().removePlayerFromTrackers(player); + world.getPlayerManager().removePlayer(player); + player.getChunkMgr().setWorldDestroyed(); + player.setWorld(null); + player.theItemInWorldManager.setWorld(null); + } + world.playerEntities.clear(); + + world.theChunkProviderServer.unloadAllWithoutSave(); + world.forceUnloadTileEntities(); + world.theChunkProviderServer.setWorldUnloaded(); + world.theChunkProviderServer.unloadAllWithoutSave(); + world.forceUnloadTileEntities(); + + MinecraftForge.EVENT_BUS.post(new WorldEvent.Unload(world)); + DimensionManager.setWorld(world.provider.dimensionId, null); + world.theChunkProviderServer.loadedChunkHashMap.clear(); + for(Object o : world.loadedTileEntityList) + ((TileEntity)o).setWorldObj(null); + world.loadedTileEntityList.clear(); + + return players; + } + public WorldServer getWorldByID(int dim) { return dimToWorldMap.get(dim); @@ -345,6 +438,46 @@ return backupDirs; } + public Collection resolveSaveDirs(Collection names) + { + if(backupDirs.size() == 1) + return backupDirs; + List dirs = new ArrayList(); + for(String name : names) + { + WorldServer world = getWorldByNameOrID(name); + if(world != null) + { + if(!(world instanceof WorldServerMulti)) + dirs.add(resolveNameForDim(world.provider.dimensionId)); + } + else + { + if(BasicTypeParser.isInt(name)) + { + int dim = Integer.parseInt(name); + if(DimensionManager.isDimensionRegistered(dim)) + dirs.add(resolveNameForDim(dim)); + } + else + { + if(new File(server.getWorldsDir(), name).isDirectory()) + dirs.add(name); + } + } + } + + return dirs; + } + + public String getSaveDirName(WorldServer world) + { + if(world instanceof WorldServerMulti) + return dimToNameMap.get(0); + + return dimToNameMap.get(world.provider.dimensionId); + } + public TIntSet getIsolatedDataDims() { return isolatedDataDims; diff --git a/src/main/java/org/ultramine/server/UMEventHandler.java b/src/main/java/org/ultramine/server/UMEventHandler.java index b17433e..2236a27 100644 --- a/src/main/java/org/ultramine/server/UMEventHandler.java +++ b/src/main/java/org/ultramine/server/UMEventHandler.java @@ -10,6 +10,8 @@ import cpw.mods.fml.common.functions.GenericIterableFactory; import cpw.mods.fml.common.gameevent.PlayerEvent.PlayerChangedDimensionEvent; import cpw.mods.fml.common.gameevent.TickEvent; +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; import net.minecraft.entity.Entity; import net.minecraft.entity.item.EntityItem; import net.minecraft.entity.player.EntityPlayerMP; @@ -45,7 +47,7 @@ } @SubscribeEvent - public void onServerTick(TickEvent.ServerTickEvent e) + public void onServerTickCommon(TickEvent.ServerTickEvent e) { if(e.phase == TickEvent.Phase.START) { @@ -53,6 +55,17 @@ Teleporter.tick(); ChunkProfiler.instance().tick(server.getTickCounter()); + } + } + + @SubscribeEvent + @SideOnly(Side.SERVER) + public void onServerTickServer(TickEvent.ServerTickEvent e) + { + if(e.phase == TickEvent.Phase.START) + { + MinecraftServer server = MinecraftServer.getServer(); + server.getBackupManager().tick(); AutoBroacastConf cfg = ConfigurationHandler.getServerConfig().settings.messages.autobroadcast; if(cfg.enabled && server.getTickCounter() % (cfg.intervalSeconds*20) == 0) diff --git a/src/main/java/org/ultramine/server/UltramineServerConfig.java b/src/main/java/org/ultramine/server/UltramineServerConfig.java index 20e103f..e8a2297 100644 --- a/src/main/java/org/ultramine/server/UltramineServerConfig.java +++ b/src/main/java/org/ultramine/server/UltramineServerConfig.java @@ -2,6 +2,7 @@ import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; public class UltramineServerConfig @@ -45,6 +46,7 @@ public SpawnLocationsConf spawnLocations = new SpawnLocationsConf(); public TeleportationConf teleportation = new TeleportationConf(); public MessagesConf messages = new MessagesConf(); + public AutoBackupConf autobackup = new AutoBackupConf(); public WatchdogThreadConf watchdogThread = new WatchdogThreadConf(); public SQLServerStorageConf inSQLServerStorage = new SQLServerStorageConf(); public SecurityConf security = new SecurityConf(); @@ -101,6 +103,16 @@ public boolean showAllMessages = false; } } + + public static class AutoBackupConf + { + public boolean enabled = false; + public int interval = 60; //minutes + public int maxBackups = 10; + public int maxDirSize = 50000; //megabytes + public List worlds = null; + public boolean notifyPlayers = true; + } public static class WatchdogThreadConf { diff --git a/src/main/java/org/ultramine/server/chunk/ChunkMap.java b/src/main/java/org/ultramine/server/chunk/ChunkMap.java index 90d027f..f718ec5 100644 --- a/src/main/java/org/ultramine/server/chunk/ChunkMap.java +++ b/src/main/java/org/ultramine/server/chunk/ChunkMap.java @@ -105,6 +105,15 @@ return map.size(); } + public void clear() + { + for(Chunk chunk : valueCollection()) + if(isFlatMapable(chunk.xPosition, chunk.zPosition)) + removeFlat(chunk.xPosition, chunk.zPosition); + + map.clear(); + } + private void put(int x, int z, int hash, Chunk chunk) diff --git a/src/main/java/org/ultramine/server/chunk/ChunkSendManager.java b/src/main/java/org/ultramine/server/chunk/ChunkSendManager.java index b68136c..4afa06e 100644 --- a/src/main/java/org/ultramine/server/chunk/ChunkSendManager.java +++ b/src/main/java/org/ultramine/server/chunk/ChunkSendManager.java @@ -178,6 +178,11 @@ removeFrom(manager); } + public void setWorldDestroyed() + { + sendingQueueSize.set(0); + } + public void update() { if(!toSend.isEmpty()) diff --git a/src/main/java/org/ultramine/server/data/ServerDataLoader.java b/src/main/java/org/ultramine/server/data/ServerDataLoader.java index ec1d634..6d62726 100644 --- a/src/main/java/org/ultramine/server/data/ServerDataLoader.java +++ b/src/main/java/org/ultramine/server/data/ServerDataLoader.java @@ -138,6 +138,19 @@ fastWarps.addAll(dataProvider.loadFastWarps()); } + public void reloadPlayerCache() + { + if(!dataProvider.isUsingWorldPlayerDir()) + return; //Database backup is not support now + playerDataCache.clear(); + namedPlayerDataCache.clear(); + for(PlayerData data : dataProvider.loadAllPlayerData()) + { + playerDataCache.put(data.getProfile().getId(), data); + namedPlayerDataCache.put(data.getProfile().getName(), data); + } + } + public void addDefaultWarps() { if(!warps.containsKey("spawn")) @@ -259,6 +272,36 @@ getDataProvider().savePlayerData(player.getData()); } + public void syncReloadPlayer(EntityPlayerMP player) + { + GameProfile profile = player.getGameProfile(); + NBTTagCompound nbt = getDataProvider().loadPlayer(profile); + if(nbt != null) + { + int dim = nbt.getInteger("Dimension"); + if(dim != 0 && mgr.getServerInstance().getMultiWorld().getIsolatedDataDims().contains(dim)) + nbt = getDataProvider().loadPlayer(dim, profile); + } + PlayerData data = playerDataCache.get(player.getGameProfile().getId()); + if(data == null) + { + data = getDataProvider().loadPlayerData(profile); + playerDataCache.put(data.getProfile().getId(), data); + namedPlayerDataCache.put(data.getProfile().getName(), data); + } + StatisticsFile stats = mgr.func_152602_a(player); + if(stats == null) + { + stats = mgr.loadStatisticsFile_Async(profile); + mgr.addStatFile(player.getGameProfile(), stats); + } + + player.readFromNBT(nbt); + player.setData(data); + player.setStatisticsFile(stats); + ForgeEventFactory.firePlayerLoadingEvent(player, ((SaveHandler)mgr.getPlayerNBTLoader()).getPlayerSaveDir(), player.getUniqueID().toString()); + } + public void handlePlayerDimensionChange(EntityPlayerMP player, int fromDim, int toDim) { WorldServer from = mgr.getServerInstance().getMultiWorld().getWorldByID(fromDim); diff --git a/src/main/java/org/ultramine/server/util/ZipUtil.java b/src/main/java/org/ultramine/server/util/ZipUtil.java new file mode 100644 index 0000000..71b7bba --- /dev/null +++ b/src/main/java/org/ultramine/server/util/ZipUtil.java @@ -0,0 +1,152 @@ +package org.ultramine.server.util; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URI; +import java.util.Arrays; +import java.util.Collection; +import java.util.Deque; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +import org.apache.commons.io.IOUtils; + +import com.google.common.base.Function; + +public class ZipUtil +{ + public static void zip(File zipfile, File directory) throws IOException + { + zipAll(zipfile, directory.getParentFile(), Arrays.asList(directory.getName())); + } + + public static void zipAll(File zipfile, File parent, Collection directories) throws IOException + { + if(directories.size() == 0) + return; + URI base = parent.toURI(); + Deque queue = new LinkedList(); + for(String dir : directories) + queue.push(new File(parent, dir)); + OutputStream out = new BufferedOutputStream(new FileOutputStream(zipfile), 65536); + Closeable res = null; + try + { + ZipOutputStream zout = new ZipOutputStream(out); + res = zout; + byte[] buffer = new byte[65536]; + while (!queue.isEmpty()) + { + File directory = queue.pop(); + for (File kid : directory.listFiles()) + { + String name = base.relativize(kid.toURI()).getPath(); + if (kid.isDirectory()) + { + queue.push(kid); + name = name.endsWith("/") ? name : name + "/"; + zout.putNextEntry(new ZipEntry(name)); + } else + { + zout.putNextEntry(new ZipEntry(name)); + FileInputStream fin = null; + try + { + fin = new FileInputStream(kid); + IOUtils.copyLarge(fin, zout, buffer); + } + finally + { + IOUtils.closeQuietly(fin); + zout.closeEntry(); + } + } + } + } + } + finally + { + IOUtils.closeQuietly(res); + } + } + + public static void unzip(File zipfile, File outDir) throws IOException + { + unzip(zipfile, outDir, null); + } + + public static void unzip(File zipfile, File outDir, Function filter) throws IOException + { + ZipInputStream zip = null; + try + { + zip = new ZipInputStream(new BufferedInputStream(new FileInputStream(zipfile))); + + byte[] buffer = new byte[65536]; + for(ZipEntry ze = zip.getNextEntry(); ze != null; ze = zip.getNextEntry()) + { + String name = ze.getName(); + if(filter != null) + name = filter.apply(name); + if(name == null) + continue; + File target = new File(outDir, name); + if(ze.isDirectory()) + { + target.mkdirs(); + } + else + { + FileOutputStream fout = null; + try + { + fout = new FileOutputStream(target); + IOUtils.copyLarge(zip, fout, buffer); + } + finally + { + IOUtils.closeQuietly(fout); + } + } + } + } + finally + { + IOUtils.closeQuietly(zip); + } + } + + public static Set getRootFiles(File zipfile) throws IOException + { + Set set = new HashSet(); + ZipFile zip = new ZipFile(zipfile); + try + { + for (Enumeration e = zip.entries(); e.hasMoreElements();) + { + ZipEntry ze = e.nextElement(); + String name = ze.getName(); + if(ze.isDirectory()) + set.add(name.substring(0, name.indexOf('/'))); + } + } + finally + { + IOUtils.closeQuietly(zip); + } + + return set; + } +} diff --git a/src/main/resources/assets/ultramine/lang/en_US.lang b/src/main/resources/assets/ultramine/lang/en_US.lang index 8a4a484..7cf57a4 100644 --- a/src/main/resources/assets/ultramine/lang/en_US.lang +++ b/src/main/resources/assets/ultramine/lang/en_US.lang @@ -4,6 +4,7 @@ teleporter.canceled=Teleportation canceled ultramine.autobroadcast.debugmsg=Server load: %s (Peak: %s), TPS: %s/20, Mobs: %s, Items: %s +ultramine.autobackup.start=Autobackup started #Command generic commands.generic.world.invalid=Can't find world '%s' @@ -195,7 +196,7 @@ command.custmsg.usage=/custmsg command.custmsg.description=Sends custom formatted message to player (support colors, use &) -command.genworld.usage=/genworld [chunks per tick] OR /genworld [chunks per tick] OR /genworld stop +command.genworld.usage=/genworld [chunks per tick] OR /genworld radius [chunks per tick] OR /genworld stop command.genworld.description=Generateas area radially or inside world border command.genworld.already=World is now generating. Type "/genworld stop" before command.genworld.noborder=The world don't contains any borders; add border or use radially generation @@ -210,3 +211,18 @@ command.chunkdebug.stop=Chunk profiling stopped command.chunkdebug.notstart=Chunk profiling has not started yet. Wait for statistics collection command.chunkdebug.top.head=Chunk top: + +command.backup.usage=/backup make [worlds...] OR /backup list OR /backup apply [flags: -noplayers -temp -restart -worlds ...] +command.backup.description=Makes on applies backup fo all or specified worlds +command.backup.make.started=Backup started for worlds: %s +command.backup.make.fail=Nothing to backup +command.backup.list.head=Backup list +command.backup.apply.started=Started backup applying for world: %s +command.backup.apply.fail.none=backup with id %s not found +command.backup.apply.fail.nofile=Backup file not found for path: %s +command.backup.apply.fail.zip.read=Failed to read zip file: %s +command.backup.apply.fail.nothing=Nothing to apply +command.backup.apply.fail.rmdir=Failed to remove directory of world: %s +command.backup.apply.fail.zip.unpack=Failed to unpack zip file. It is very VERY bad. May lead to the complete breaking of the world. Please, apply other backup. +command.backup.apply.success.temp=Backup successfuly applied! Created temp worlds: +command.backup.apply.success=Backup successfuly applied! diff --git a/src/main/resources/assets/ultramine/lang/ru_RU.lang b/src/main/resources/assets/ultramine/lang/ru_RU.lang index a1f709e..8a85b74 100644 --- a/src/main/resources/assets/ultramine/lang/ru_RU.lang +++ b/src/main/resources/assets/ultramine/lang/ru_RU.lang @@ -4,6 +4,7 @@ teleporter.canceled=Телепортация отменена ultramine.autobroadcast.debugmsg=Сервер нагружен на %s (В пике - %s), Тиков в секунду: %s/20, Мобов: %s, Предметов: %s +ultramine.autobackup.start=Выполняется автоматический бэкап мира #Command generic commands.generic.world.invalid=Указанный мир не существует или неинициализирован '%s' @@ -195,7 +196,7 @@ command.custmsg.usage=/custmsg <игрок/ALL> <сообщение> command.custmsg.description=Отправляет кастомное сообщение игроку (поддерживает цвета) -command.genworld.usage=/genworld [чанков за тик] ИЛИ /genworld <радиус> [чанков за тик] ИЛИ /genworld stop +command.genworld.usage=/genworld [чанков за тик] ИЛИ /genworld radius <радиус> [чанков за тик] ИЛИ /genworld stop command.genworld.description=Генерирует мир по радиусу или в пределах мирового барьера command.genworld.already=Генерауия мира уже запущена. Сначала введите "/genworld stop" command.genworld.noborder=Мир не содержит барьера; добавьте барьер или используйте генерацию по радиусу @@ -210,3 +211,18 @@ command.chunkdebug.stop=Почанковое профилирование остановлено command.chunkdebug.notstart=Почанковое профилирование еще не запущено. Подождите, пока будет собрана статистика command.chunkdebug.top.head=Топ чанков: + +command.backup.usage=/backup make [миры...] ИЛИ /backup list ИЛИ /backup apply <номер/путь> [flags: -noplayers -temp -restart -worlds ...] +command.backup.description=Создает или применяет бэкап всех или указанных миров +command.backup.make.started=Запущен бэкап для миров: %s +command.backup.make.fail=Не найдено ни одного мира для бэкапа +command.backup.list.head=Список бэкапов +command.backup.apply.started=Запущено применение бэкапа миров: %s +command.backup.apply.fail.none=Бэкап с номером %s не найден +command.backup.apply.fail.nofile=Файл бжкапа не найден для пути: %s +command.backup.apply.fail.zip.read=Не удается прочитать zip архив: %s +command.backup.apply.fail.nothing=Не найдено ни одного мира для применения бэкапа +command.backup.apply.fail.rmdir=Не удалось удалить директорию мира: %s +command.backup.apply.fail.zip.unpack=Не удалось распаковать zip файл. это очень ОЧЕНЬ полохо. Может привести к полному разрушению миров. Пожалуйста, применить другой бэкап. +command.backup.apply.success.temp=Бэкап успешно применен! Созданы временные миры: +command.backup.apply.success=Бэкап успешно применен!