diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000..cdcb23caa --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,48 @@ +# ServerUtilities Architecture + +ServerUtilities is a Minecraft Forge 1.7.10 mod and a compatibility-oriented backport of FTB Utilities, FTB +Library, and Aurora. Changes must preserve existing worlds, NBT data, network packet layouts, configuration keys, +and integration entry points unless a migration is explicitly designed. + +## Lifecycle and state ownership + +`Universe` is the server-lifecycle facade. Forge creates it before the server starts, loads persistent state after +the primary world is available, and closes it during shutdown. The object is registered on the Forge event buses, +so lifecycle and annotated event methods remain on the facade even when implementation details are delegated to +package-private components. + +Players and teams belong to one `Universe`. Mutations must use their domain methods so cache invalidation, events, +and dirty-state propagation happen together. Read-only views are provided for inspection. Deprecated public +collections remain only as compatibility bridges and must not be used by project code. + +## Persistence compatibility + +Universe, player, and team data is stored as compressed NBT. Existing key names, event ordering, team UIDs, and +save order are compatibility contracts. Loading/import code may hydrate state without producing normal gameplay +events; ordinary mutations must use the public domain operations and mark affected objects dirty. NBT writes use a +sibling temporary file and atomic replacement where the platform supports it; dirty state is cleared only after a +successful replacement. + +Backup restoration validates every archive entry against the chosen restore root. Both ZIP implementations share +the same path and filtering policy, while backend-specific code only adapts archive entry access. Restores extract +into an isolated staging directory before live files move, and a rollback journal restores every moved path if the +install does not complete. The journal is forced to disk before live mutations and recovered on the next restore if +the process previously stopped mid-transaction. Entry-count, extracted-size, free-space, link, and selected-world +boundaries are enforced by the shared archive layer. + +## Threads and networking + +Minecraft and Forge state is owned by the server thread unless a class explicitly documents otherwise. Background +backup, image, web, and file-I/O tasks must not mutate game state directly. Interrupted operations restore the +thread interrupt flag and stop or roll back their work. Backup runs share a single lifecycle token across threaded +and synchronous modes, and each run owns its original world save-state snapshot. Packet field order and NBT field +names are wire contracts. + +## Supported integrations + +New integrations should use `serverutils.api.ServerUtilitiesRegistry`. Its checked registration, duplicate-ID, +lookup, and read-only-view behavior are supported facade contracts. Extension value types retain their existing +compatibility guarantees; merely appearing in a facade signature does not make all of their implementation details a +stability promise. The facade is distributed in the main mod artifact, not as a standalone API-only artifact. +Supported extension points include reload handlers, team and admin actions, configuration value providers, +synchronized data, invsee inventories, and posted player/team/universe events. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..8277dca18 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,33 @@ +# Contributing + +## Development requirements + +- Preserve Java 8 bytecode compatibility. Modern source syntax is provided by Jabel and does not raise the runtime + requirement. +- Build against the repository's Gradle wrapper and pinned GTNH dependencies. +- Keep changes focused and preserve existing NBT keys, configuration names, packet layouts, and public descriptors. + +## Verification + +Run these commands before submitting a change: + +```powershell +.\gradlew.bat test +.\gradlew.bat spotlessCheck +.\gradlew.bat build +``` + +Add focused unit tests for pure logic and regression tests for corrected behavior. Forge lifecycle behavior that +cannot run in a plain unit test should be isolated behind a small package-private seam and covered with a server +smoke test. + +## Code conventions + +- Use domain mutation methods instead of writing public compatibility fields or collections. +- Use `Locale.ROOT` for identifiers, persisted names, permission nodes, and deterministic formatting. +- Missing optional data may fall back silently; malformed persisted/configuration data should log a warning; + unexpected failures should log an error with an ID or path. Do not use `printStackTrace` or empty catch blocks. +- Restore the interrupt flag when catching `InterruptedException`, and stop or roll back the interrupted operation. +- Use try-with-resources for streams, readers, archive handles, and other closeable objects. +- Add stable integration entry points under `serverutils.api`; retain deprecated bridges when compatibility requires + them. diff --git a/README.md b/README.md index c26892a88..f94b7cf41 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,11 @@ Utilizes a permission system to handle whether a player can use certain commands * [GTNHLib](https://www.curseforge.com/minecraft/mc-mods/gtnhlib) * Optionally [Navigator](https://github.com/GTNewHorizons/Navigator) which enables claims and chunkload integration for JourneyMap or Xaeros World & Minimap +## Developer documentation + +See [ARCHITECTURE.md](ARCHITECTURE.md) for lifecycle, persistence, threading, and API boundaries, and +[CONTRIBUTING.md](CONTRIBUTING.md) for setup, compatibility rules, and verification commands. + ## Quick Install Download the latest JAR from [releases](https://github.com/GTNewHorizons/ServerUtilities/releases) - you want the one named ServerUtilities-number.jar, not the `dev` or `sources`. Place it in the `/mods` folder on your server AND client (if single player, on client only). diff --git a/build.gradle b/build.gradle index e57a16f9f..ad8da6926 100644 --- a/build.gradle +++ b/build.gradle @@ -3,3 +3,7 @@ plugins { id 'com.gtnewhorizons.gtnhconvention' } + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/dependencies.gradle b/dependencies.gradle index c37114f66..a87f35e08 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -17,4 +17,8 @@ dependencies { compileOnly(rfg.deobf("curse.maven:witchery-69673:2234410")) {transitive = false} runtimeOnlyNonPublishable("com.github.GTNewHorizons:waila:1.19.30:dev") + + testImplementation('org.junit.jupiter:junit-jupiter:5.10.2') + testRuntimeOnly('it.unimi.dsi:fastutil:8.5.18') + testRuntimeOnly('org.junit.platform:junit-platform-launcher:1.10.2') } diff --git a/src/main/java/serverutils/ServerUtilities.java b/src/main/java/serverutils/ServerUtilities.java index c6d0a25c7..72791a4a2 100644 --- a/src/main/java/serverutils/ServerUtilities.java +++ b/src/main/java/serverutils/ServerUtilities.java @@ -1,6 +1,5 @@ package serverutils; -import java.util.Locale; import java.util.Map; import javax.annotation.Nullable; @@ -65,7 +64,6 @@ public static CommandException errorFeatureDisabledServer(@Nullable ICommandSend @Mod.EventHandler public void onPreInit(FMLPreInitializationEvent event) { - Locale.setDefault(Locale.US); PROXY.preInit(event); } diff --git a/src/main/java/serverutils/ServerUtilitiesCommon.java b/src/main/java/serverutils/ServerUtilitiesCommon.java index 9ec0d623d..4660ce44a 100644 --- a/src/main/java/serverutils/ServerUtilitiesCommon.java +++ b/src/main/java/serverutils/ServerUtilitiesCommon.java @@ -104,7 +104,7 @@ public void init(FMLInitializationEvent event) { ServerUtilitiesRegistry.registerDefaults(); ServerUtilitiesPermissions.init(); CHAT_FORMATTING_SUBSTITUTES.put("name", ForgePlayer::getDisplayName); - CHAT_FORMATTING_SUBSTITUTES.put("team", player -> player.team.getTitle()); + CHAT_FORMATTING_SUBSTITUTES.put("team", player -> player.getTeam().getTitle()); } public void postInit(FMLPostInitializationEvent event) { @@ -146,7 +146,7 @@ private static void registerBrigadierCommands(LiteralCommandNode literalNode, ? Rank.NODE_COMMAND + '.' + cmdPerm.serverutilities$getModId() + "." + literalNode.getLiteral() : parentNode + "." + literalNode.getLiteral(); - cmdPerm.serverutilities$setPermissionNode(node.toLowerCase()); + cmdPerm.serverutilities$setPermissionNode(node.toLowerCase(java.util.Locale.ROOT)); cmdPerm.serverUtilities$registerPermissions(); for (CommandNode child : literalNode.getChildren()) { diff --git a/src/main/java/serverutils/ServerUtilitiesLeaderboards.java b/src/main/java/serverutils/ServerUtilitiesLeaderboards.java index 3a56c8b72..195872159 100644 --- a/src/main/java/serverutils/ServerUtilitiesLeaderboards.java +++ b/src/main/java/serverutils/ServerUtilitiesLeaderboards.java @@ -43,7 +43,8 @@ static void loadLeaderboards() { new ChatComponentTranslation("serverutilities.stat.dph"), player -> { double d = getDPH(player); - return new ChatComponentText(d < 0D ? "-" : String.format("%.2f", d)); + return new ChatComponentText( + d < 0D ? "-" : String.format(java.util.Locale.ROOT, "%.2f", d)); }, Comparator.comparingDouble(ServerUtilitiesLeaderboards::getDPH).reversed(), player -> getDPH(player) >= 0D)); @@ -71,7 +72,7 @@ static void loadLeaderboards() { component.getChatStyle().setColor(EnumChatFormatting.GREEN); return component; } else { - long worldTime = player.team.universe.world.getTotalWorldTime(); + long worldTime = player.getUniverse().world.getTotalWorldTime(); long time = worldTime - player.getLastTimeSeen(); return Leaderboard.FromStat.LONG_TIME.apply(time); } @@ -99,7 +100,7 @@ private static long getRelativeLastSeen(ForgePlayer player) { return 0; } - return player.team.universe.ticks.ticks() - player.getLastTimeSeen(); + return player.getUniverse().ticks.ticks() - player.getLastTimeSeen(); } private static double getDPH(ForgePlayer player) { diff --git a/src/main/java/serverutils/ServerUtilitiesPermissions.java b/src/main/java/serverutils/ServerUtilitiesPermissions.java index 33b90cf87..a8f75e5ae 100644 --- a/src/main/java/serverutils/ServerUtilitiesPermissions.java +++ b/src/main/java/serverutils/ServerUtilitiesPermissions.java @@ -384,12 +384,14 @@ public static void registerPrefix(String node, DefaultPermissionLevel level, Str public static String formatId(@Nullable Block item) { return (item == null || GameData.getBlockRegistry().getNameForObject(item) == null) ? "minecraft.air" - : GameData.getBlockRegistry().getNameForObject(item).toLowerCase().replace(':', '.'); + : GameData.getBlockRegistry().getNameForObject(item).toLowerCase(java.util.Locale.ROOT) + .replace(':', '.'); } public static String formatId(@Nullable Item item) { return (item == null || GameData.getItemRegistry().getNameForObject(item) == null) ? "minecraft.air" - : GameData.getItemRegistry().getNameForObject(item).toLowerCase().replace(':', '.'); + : GameData.getItemRegistry().getNameForObject(item).toLowerCase(java.util.Locale.ROOT) + .replace(':', '.'); } public static boolean hasBlockEditingPermission(EntityPlayer player, Block block) { diff --git a/src/main/java/serverutils/ServerUtilitiesRegistry.java b/src/main/java/serverutils/ServerUtilitiesRegistry.java index 23dd99ac4..070f01c59 100644 --- a/src/main/java/serverutils/ServerUtilitiesRegistry.java +++ b/src/main/java/serverutils/ServerUtilitiesRegistry.java @@ -61,32 +61,63 @@ @SuppressWarnings("unused") public class ServerUtilitiesRegistry { + @Deprecated public static final Map RELOAD_IDS = new HashMap<>(); + @Deprecated public static final Map TEAM_GUI_ACTIONS = new HashMap<>(); + @Deprecated public static final Map ADMIN_PANEL_ACTIONS = new HashMap<>(); + @Deprecated public static final Map CONFIG_VALUE_PROVIDERS = new HashMap<>(); + @Deprecated public static final Map SYNCED_DATA = new HashMap<>(); + /** + * @deprecated Use + * {@link serverutils.api.ServerUtilitiesRegistry#registerConfigValueProvider(String, ConfigValueProvider)}. + */ + @Deprecated public static void registerConfigValueProvider(String id, ConfigValueProvider provider) { CONFIG_VALUE_PROVIDERS.put(id, provider); } + /** + * @deprecated Use {@link serverutils.api.ServerUtilitiesRegistry#registerSyncData(String, ISyncData)}. + */ + @Deprecated public static void registerSyncData(String mod, ISyncData data) { SYNCED_DATA.put(mod, data); } + /** + * @deprecated Use + * {@link serverutils.api.ServerUtilitiesRegistry#registerServerReloadHandler(ResourceLocation, IReloadHandler)}. + */ + @Deprecated public static void registerServerReloadHandler(ResourceLocation id, IReloadHandler handler) { RELOAD_IDS.put(id, handler); } + /** + * @deprecated Use {@link serverutils.api.ServerUtilitiesRegistry#registerAdminPanelAction(AdminPanelAction)}. + */ + @Deprecated public static void registerAdminPanelAction(AdminPanelAction action) { ADMIN_PANEL_ACTIONS.put(action.getId(), action); } + /** + * @deprecated Use {@link serverutils.api.ServerUtilitiesRegistry#registerTeamAction(TeamAction)}. + */ + @Deprecated public static void registerTeamAction(TeamAction action) { TEAM_GUI_ACTIONS.put(action.getId(), action); } + /** + * @deprecated Use {@link serverutils.api.ServerUtilitiesRegistry#registerInvseeInventory(IModdedInventory)}. + */ + @Deprecated @ApiStatus.AvailableSince("2.4.0") public static void registerInvseeInventory(IModdedInventory inventory) { InvSeeRegistry.registerInventory(inventory); @@ -153,7 +184,7 @@ public Type getType(ForgePlayer player, NBTTagCompound data) { @Override public void onAction(ForgePlayer player, NBTTagCompound data) { ServerUtilitiesAPI.reloadServer( - player.team.universe, + player.getUniverse(), player.getPlayer(), EnumReloadType.RELOAD_COMMAND, ServerReloadEvent.ALL); @@ -169,7 +200,7 @@ public Type getType(ForgePlayer player, NBTTagCompound data) { @Override public void onAction(ForgePlayer player, NBTTagCompound data) { - new MessageViewCrashList(player.team.universe.server.getFile("crash-reports")) + new MessageViewCrashList(player.getUniverse().server.getFile("crash-reports")) .sendTo(player.getPlayer()); } }); @@ -190,7 +221,7 @@ public void onAction(ForgePlayer player, NBTTagCompound data) { ConfigGroup gamerules = main.getGroup("gamerules"); gamerules.setDisplayName(new ChatComponentTranslation("gamerules")); - GameRules rules = player.team.universe.world.getGameRules(); + GameRules rules = player.getUniverse().world.getGameRules(); for (String key : rules.getRules()) { String value = rules.getGameRuleStringValue(key); diff --git a/src/main/java/serverutils/api/ServerUtilitiesRegistry.java b/src/main/java/serverutils/api/ServerUtilitiesRegistry.java new file mode 100644 index 000000000..2cb3538f5 --- /dev/null +++ b/src/main/java/serverutils/api/ServerUtilitiesRegistry.java @@ -0,0 +1,124 @@ +package serverutils.api; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; + +import javax.annotation.Nullable; + +import net.minecraft.util.ResourceLocation; + +import serverutils.events.IReloadHandler; +import serverutils.invsee.inventories.IModdedInventory; +import serverutils.invsee.inventories.InvSeeRegistry; +import serverutils.lib.config.ConfigValueProvider; +import serverutils.lib.data.AdminPanelAction; +import serverutils.lib.data.ISyncData; +import serverutils.lib.data.TeamAction; + +/** + * Checked access to ServerUtilities extension registries. + * + *

+ * Registration, duplicate-ID handling, lookup, and read-only-view behavior are supported facade contracts. Types in the + * method signatures retain their existing compatibility guarantees; exposing one here does not make every member of + * that type part of this facade's stability contract. + */ +public final class ServerUtilitiesRegistry { + + private static final Map RELOAD_HANDLERS = Collections + .unmodifiableMap(serverutils.ServerUtilitiesRegistry.RELOAD_IDS); + private static final Map TEAM_ACTIONS = Collections + .unmodifiableMap(serverutils.ServerUtilitiesRegistry.TEAM_GUI_ACTIONS); + private static final Map ADMIN_ACTIONS = Collections + .unmodifiableMap(serverutils.ServerUtilitiesRegistry.ADMIN_PANEL_ACTIONS); + private static final Map CONFIG_PROVIDERS = Collections + .unmodifiableMap(serverutils.ServerUtilitiesRegistry.CONFIG_VALUE_PROVIDERS); + private static final Map SYNC_DATA = Collections + .unmodifiableMap(serverutils.ServerUtilitiesRegistry.SYNCED_DATA); + + private ServerUtilitiesRegistry() {} + + public static void registerConfigValueProvider(String id, ConfigValueProvider provider) { + registerUnique(serverutils.ServerUtilitiesRegistry.CONFIG_VALUE_PROVIDERS, id, provider, "config provider"); + } + + public static void registerSyncData(String modId, ISyncData data) { + registerUnique(serverutils.ServerUtilitiesRegistry.SYNCED_DATA, modId, data, "sync data"); + } + + public static void registerServerReloadHandler(ResourceLocation id, IReloadHandler handler) { + registerUnique(serverutils.ServerUtilitiesRegistry.RELOAD_IDS, id, handler, "reload handler"); + } + + public static void registerAdminPanelAction(AdminPanelAction action) { + Objects.requireNonNull(action, "action"); + registerUnique( + serverutils.ServerUtilitiesRegistry.ADMIN_PANEL_ACTIONS, + action.getId(), + action, + "admin panel action"); + } + + public static void registerTeamAction(TeamAction action) { + Objects.requireNonNull(action, "action"); + registerUnique(serverutils.ServerUtilitiesRegistry.TEAM_GUI_ACTIONS, action.getId(), action, "team action"); + } + + public static void registerInvseeInventory(IModdedInventory inventory) { + InvSeeRegistry.registerInventory(Objects.requireNonNull(inventory, "inventory")); + } + + public static Map reloadHandlersView() { + return RELOAD_HANDLERS; + } + + public static Map teamActionsView() { + return TEAM_ACTIONS; + } + + public static Map adminPanelActionsView() { + return ADMIN_ACTIONS; + } + + public static Map configValueProvidersView() { + return CONFIG_PROVIDERS; + } + + public static Map syncDataView() { + return SYNC_DATA; + } + + @Nullable + public static IReloadHandler findReloadHandler(ResourceLocation id) { + return serverutils.ServerUtilitiesRegistry.RELOAD_IDS.get(id); + } + + @Nullable + public static TeamAction findTeamAction(ResourceLocation id) { + return serverutils.ServerUtilitiesRegistry.TEAM_GUI_ACTIONS.get(id); + } + + @Nullable + public static AdminPanelAction findAdminPanelAction(ResourceLocation id) { + return serverutils.ServerUtilitiesRegistry.ADMIN_PANEL_ACTIONS.get(id); + } + + @Nullable + public static ConfigValueProvider findConfigValueProvider(String id) { + return serverutils.ServerUtilitiesRegistry.CONFIG_VALUE_PROVIDERS.get(id); + } + + @Nullable + public static ISyncData findSyncData(String id) { + return serverutils.ServerUtilitiesRegistry.SYNCED_DATA.get(id); + } + + private static void registerUnique(Map registry, K key, V value, String registryName) { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(value, "value"); + if (registry.putIfAbsent(key, value) != null) { + throw new IllegalArgumentException("Duplicate " + registryName + " ID: " + key); + } + } +} diff --git a/src/main/java/serverutils/api/package-info.java b/src/main/java/serverutils/api/package-info.java new file mode 100644 index 000000000..3a8d38525 --- /dev/null +++ b/src/main/java/serverutils/api/package-info.java @@ -0,0 +1,11 @@ +/** + * Supported registry entry points for integrations with ServerUtilities. + * + *

+ * New integrations should enter through the checked registry facade in this package. Its registration and lookup + * behavior is a supported contract; callback and action types referenced by that facade retain their existing + * compatibility status and are not automatically promoted to a blanket stability guarantee. This facade ships in the + * main ServerUtilities artifact rather than as a standalone API-only artifact. + */ +@javax.annotation.ParametersAreNonnullByDefault +package serverutils.api; diff --git a/src/main/java/serverutils/aurora/Aurora.java b/src/main/java/serverutils/aurora/Aurora.java index 061b58f96..c610e0de7 100644 --- a/src/main/java/serverutils/aurora/Aurora.java +++ b/src/main/java/serverutils/aurora/Aurora.java @@ -9,8 +9,10 @@ public class Aurora { public static void start(MinecraftServer s) { if (AuroraConfig.general.enable) { if (server == null) { - server = new AuroraServer(s, AuroraConfig.general.port); - server.start(); + AuroraServer candidate = new AuroraServer(s, AuroraConfig.general.port); + if (candidate.start()) { + server = candidate; + } } } } diff --git a/src/main/java/serverutils/aurora/AuroraServer.java b/src/main/java/serverutils/aurora/AuroraServer.java index 58c1b07a7..a4e9142d1 100644 --- a/src/main/java/serverutils/aurora/AuroraServer.java +++ b/src/main/java/serverutils/aurora/AuroraServer.java @@ -2,6 +2,7 @@ import java.io.PrintWriter; import java.io.StringWriter; +import java.nio.charset.StandardCharsets; import net.minecraft.server.MinecraftServer; import net.minecraftforge.common.MinecraftForge; @@ -9,11 +10,12 @@ import io.netty.bootstrap.ServerBootstrap; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelOption; import io.netty.channel.EventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioServerSocketChannel; @@ -25,6 +27,7 @@ import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpServerCodec; import io.netty.handler.codec.http.HttpVersion; +import serverutils.ServerUtilities; import serverutils.aurora.page.HomePage; import serverutils.aurora.page.WebPage; import serverutils.aurora.page.WebPageNotFound; @@ -37,6 +40,8 @@ public class AuroraServer { private ChannelFuture channel; private final EventLoopGroup masterGroup; private final EventLoopGroup slaveGroup; + private Thread shutdownHook; + private boolean stopped; private byte[] iconBytes = null; @@ -51,9 +56,7 @@ public MinecraftServer getServer() { return server; } - void start() { - Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown)); - + boolean start() { try { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(masterGroup, slaveGroup); @@ -64,15 +67,11 @@ void start() { public void initChannel(final SocketChannel ch) { ch.pipeline().addLast("codec", new HttpServerCodec()); ch.pipeline().addLast("aggregator", new HttpObjectAggregator(512 * 1024)); - ch.pipeline().addLast("request", new ChannelInboundHandlerAdapter() { + ch.pipeline().addLast("request", new SimpleChannelInboundHandler() { @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - if (msg instanceof FullHttpRequest) { - handleRequest(ch, ctx, (FullHttpRequest) msg); - } else { - super.channelRead(ctx, msg); - } + protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) { + handleRequest(ctx, request); } @Override @@ -82,11 +81,20 @@ public void channelReadComplete(ChannelHandlerContext ctx) { @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { - ctx.writeAndFlush( - new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.INTERNAL_SERVER_ERROR, - Unpooled.copiedBuffer(cause.getMessage().getBytes()))); + String message = cause.getMessage(); + if (message == null || message.isEmpty()) { + message = cause.getClass().getName(); + } + + byte[] content = message.getBytes(StandardCharsets.UTF_8); + FullHttpResponse response = new DefaultFullHttpResponse( + HttpVersion.HTTP_1_1, + HttpResponseStatus.INTERNAL_SERVER_ERROR, + Unpooled.wrappedBuffer(content)); + response.headers().set(HttpHeaders.Names.CONTENT_TYPE, "text/plain; charset=UTF-8"); + response.headers().set(HttpHeaders.Names.CONTENT_LENGTH, content.length); + response.headers().set(HttpHeaders.Names.CONNECTION, HttpHeaders.Values.CLOSE); + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); } }); } @@ -95,19 +103,71 @@ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { bootstrap.option(ChannelOption.SO_BACKLOG, 128); bootstrap.childOption(ChannelOption.SO_KEEPALIVE, true); channel = bootstrap.bind(port).sync(); - } catch (InterruptedException ignored) {} + shutdownHook = new Thread(this::shutdown, "ServerUtilities-Aurora-Shutdown"); + Runtime.getRuntime().addShutdownHook(shutdownHook); + return true; + } catch (InterruptedException ex) { + ServerUtilities.LOGGER.warn("Interrupted while starting Aurora", ex); + stopAfterFailedStart(); + Thread.currentThread().interrupt(); + return false; + } catch (Exception ex) { + ServerUtilities.LOGGER.error("Failed to start Aurora on port " + port, ex); + stopAfterFailedStart(); + return false; + } } - void shutdown() { + synchronized void shutdown() { + if (stopped) { + return; + } + stopped = true; + try { + if (channel != null) { + channel.channel().close().sync(); + } + } catch (InterruptedException ex) { + ServerUtilities.LOGGER.warn("Interrupted while stopping Aurora", ex); + Thread.currentThread().interrupt(); + } finally { + slaveGroup.shutdownGracefully(); + masterGroup.shutdownGracefully(); + removeShutdownHook(); + } + } + + private synchronized void stopAfterFailedStart() { + if (stopped) { + return; + } + stopped = true; + if (channel != null && channel.channel() != null) { + channel.channel().close(); + } slaveGroup.shutdownGracefully(); masterGroup.shutdownGracefully(); + removeShutdownHook(); + } + private void removeShutdownHook() { + if (shutdownHook == null || Thread.currentThread() == shutdownHook) { + return; + } try { - channel.channel().closeFuture().sync(); - } catch (InterruptedException ignored) {} + Runtime.getRuntime().removeShutdownHook(shutdownHook); + } catch (IllegalStateException ex) { + // The JVM is already shutting down, so the hook no longer needs removal. + } finally { + shutdownHook = null; + } + } + + boolean eventLoopsAreShuttingDown() { + return masterGroup.isShuttingDown() && slaveGroup.isShuttingDown(); } - private void handleRequest(SocketChannel channel, ChannelHandlerContext ctx, FullHttpRequest request) { + private void handleRequest(ChannelHandlerContext ctx, FullHttpRequest request) { String uri = request.getUri(); WebPage page; @@ -137,7 +197,7 @@ private void handleRequest(SocketChannel channel, ChannelHandlerContext ctx, Ful } } catch (Exception ex) { page = new WebPageNotFound("errored"); - ex.printStackTrace(); + ServerUtilities.LOGGER.error("Failed to resolve Aurora page " + uri, ex); } } @@ -148,7 +208,7 @@ private void handleRequest(SocketChannel channel, ChannelHandlerContext ctx, Ful content = page.getContent(); contentType = page.getContentType(); } catch (Exception ex) { - ex.printStackTrace(); + ServerUtilities.LOGGER.error("Failed to render Aurora page " + uri, ex); StringWriter writer = new StringWriter(); PrintWriter printWriter = new PrintWriter(writer); printWriter.println("Error!"); @@ -158,19 +218,26 @@ private void handleRequest(SocketChannel channel, ChannelHandlerContext ctx, Ful contentType = "text/plain"; } + byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8); FullHttpResponse response = new DefaultFullHttpResponse( HttpVersion.HTTP_1_1, page.getStatus(), - Unpooled.copiedBuffer(content.getBytes())); + Unpooled.wrappedBuffer(contentBytes)); - if (HttpHeaders.isKeepAlive(request)) { + boolean keepAlive = HttpHeaders.isKeepAlive(request); + if (keepAlive) { response.headers().set(HttpHeaders.Names.CONNECTION, HttpHeaders.Values.KEEP_ALIVE); + } else { + response.headers().set(HttpHeaders.Names.CONNECTION, HttpHeaders.Values.CLOSE); } response.headers().set(HttpHeaders.Names.CONTENT_TYPE, contentType); - response.headers().set(HttpHeaders.Names.CONTENT_LENGTH, content.length()); + response.headers().set(HttpHeaders.Names.CONTENT_LENGTH, contentBytes.length); response.headers().set(HttpHeaders.Names.ACCESS_CONTROL_ALLOW_ORIGIN, "*"); - ctx.writeAndFlush(response); + ChannelFuture responseFuture = ctx.writeAndFlush(response); + if (!keepAlive) { + responseFuture.addListener(ChannelFutureListener.CLOSE); + } } public boolean allow(String uri) { diff --git a/src/main/java/serverutils/aurora/mc/PermissionListPage.java b/src/main/java/serverutils/aurora/mc/PermissionListPage.java index 733d632b9..a678452f9 100644 --- a/src/main/java/serverutils/aurora/mc/PermissionListPage.java +++ b/src/main/java/serverutils/aurora/mc/PermissionListPage.java @@ -141,6 +141,7 @@ public void body(Tag body) { int max = configInt.getMax(); variants.add( String.format( + java.util.Locale.ROOT, "%s to %s", min == Integer.MIN_VALUE ? "-∞" : String.valueOf(min), max == Integer.MAX_VALUE ? "∞" : String.valueOf(max))); @@ -150,12 +151,14 @@ public void body(Tag body) { variants.add( String.format( + java.util.Locale.ROOT, "%s to %s", min == Double.NEGATIVE_INFINITY ? "-∞" : StringUtils.formatDouble(min), max == Double.POSITIVE_INFINITY ? "∞" : StringUtils.formatDouble(max))); } else if (entry.player instanceof ConfigTimer configTimer) { Ticks max = configTimer.getMax(); - variants.add(String.format("0s to %s", !max.hasTicks() ? "∞" : max.toString())); + variants.add( + String.format(java.util.Locale.ROOT, "0s to %s", !max.hasTicks() ? "∞" : max.toString())); } else { variants = new ArrayList<>(entry.player.getVariants()); variants.sort(StringUtils.IGNORE_CASE_COMPARATOR); @@ -222,7 +225,7 @@ public void body(Tag body) { Files.write(Paths.get("server-utilities-permissions.txt"), export2); } catch (IOException e) { - e.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to export the permission list", e); } } } diff --git a/src/main/java/serverutils/aurora/page/HTTPWebPage.java b/src/main/java/serverutils/aurora/page/HTTPWebPage.java index 81bf3de39..a31681f25 100644 --- a/src/main/java/serverutils/aurora/page/HTTPWebPage.java +++ b/src/main/java/serverutils/aurora/page/HTTPWebPage.java @@ -64,7 +64,7 @@ public void head(Tag head) { css = StringUtils.readString(is); } } catch (Exception e) { - e.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to load Aurora stylesheet", e); } if (!css.isEmpty()) { diff --git a/src/main/java/serverutils/client/NotificationHandler.java b/src/main/java/serverutils/client/NotificationHandler.java index f8b2e9095..ac91cb8b0 100644 --- a/src/main/java/serverutils/client/NotificationHandler.java +++ b/src/main/java/serverutils/client/NotificationHandler.java @@ -124,8 +124,10 @@ static void loadNotifications() { if (!entry.getValue().isJsonObject()) continue; JsonObject obj = entry.getValue().getAsJsonObject(); - group.getValue(entry.getKey()) - .setValueFromString(null, obj.getAsJsonPrimitive("location").getAsString().toLowerCase(), false); + group.getValue(entry.getKey()).setValueFromString( + null, + obj.getAsJsonPrimitive("location").getAsString().toLowerCase(java.util.Locale.ROOT), + false); if (obj.has("lastReceived")) { IChatComponent last = JsonUtils.deserializeTextComponent(obj.getAsJsonObject("lastReceived")); lastMessages.put(entry.getKey(), last); diff --git a/src/main/java/serverutils/client/ServerUtilitiesClient.java b/src/main/java/serverutils/client/ServerUtilitiesClient.java index 5b09bc0bb..715d4eb49 100644 --- a/src/main/java/serverutils/client/ServerUtilitiesClient.java +++ b/src/main/java/serverutils/client/ServerUtilitiesClient.java @@ -1,5 +1,6 @@ package serverutils.client; +import java.io.IOException; import java.util.Map; import net.minecraft.client.Minecraft; @@ -20,6 +21,7 @@ import serverutils.ServerUtilitiesCommon; import serverutils.ServerUtilitiesConfig; import serverutils.client.gui.BuiltinChunkMap; +import serverutils.client.gui.RestoreRecovery; import serverutils.client.gui.SidebarButtonManager; import serverutils.client.tab.TabChannelHandler; import serverutils.client.tab.TabDisplayHandler; @@ -52,6 +54,13 @@ public class ServerUtilitiesClient extends ServerUtilitiesCommon { @Override public void preInit(FMLPreInitializationEvent event) { + try { + RestoreRecovery.recoverPendingFromWorkingDirectory(); + } catch (IOException ex) { + throw new IllegalStateException( + "An interrupted ServerUtilities restore could not be recovered; refusing to load worlds", + ex); + } super.preInit(event); ClientUtils.localPlayerHead = new PlayerHeadIcon(Minecraft.getMinecraft().getSession().func_148256_e().getId()); ((IReloadableResourceManager) Minecraft.getMinecraft().getResourceManager()) diff --git a/src/main/java/serverutils/client/gui/GuiLeaderboard.java b/src/main/java/serverutils/client/gui/GuiLeaderboard.java index 6e091d6d5..3fbd809cb 100644 --- a/src/main/java/serverutils/client/gui/GuiLeaderboard.java +++ b/src/main/java/serverutils/client/gui/GuiLeaderboard.java @@ -82,6 +82,6 @@ public void addButtons(Panel panel) { @Override public String getFilterText(Widget widget) { - return ((LeaderboardEntry) widget).value.username.toLowerCase(); + return ((LeaderboardEntry) widget).value.username.toLowerCase(java.util.Locale.ROOT); } } diff --git a/src/main/java/serverutils/client/gui/GuiRestoreBackup.java b/src/main/java/serverutils/client/gui/GuiRestoreBackup.java index 3591b23ca..40d5d7225 100644 --- a/src/main/java/serverutils/client/gui/GuiRestoreBackup.java +++ b/src/main/java/serverutils/client/gui/GuiRestoreBackup.java @@ -6,16 +6,17 @@ import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.nio.file.FileSystems; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.PathMatcher; import java.nio.file.Paths; -import java.text.DateFormat; -import java.text.SimpleDateFormat; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; -import java.util.Calendar; import java.util.Comparator; import java.util.List; +import java.util.Locale; import java.util.Set; import java.util.function.Consumer; @@ -50,6 +51,7 @@ import serverutils.lib.gui.WidgetLayout; import serverutils.lib.gui.misc.GuiButtonListBase; import serverutils.lib.icon.Icon; +import serverutils.lib.util.BackupGlobUtils; import serverutils.lib.util.FileUtils; import serverutils.lib.util.compression.ICompress; import serverutils.lib.util.misc.MouseButton; @@ -58,7 +60,8 @@ @EventBusSubscriber(side = Side.CLIENT) public class GuiRestoreBackup extends GuiButtonListBase { - private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss"); + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter + .ofPattern("yyyy-MM-dd-HH-mm-ss", Locale.ROOT); private static final Set allBackupFiles = new ObjectOpenHashSet<>(); private static Object2ObjectMap> worldBackups; private final List backupFiles; @@ -147,14 +150,19 @@ private static void preProcess() { File[] files = BackupTask.BACKUP_FOLDER.listFiles(); if (files == null) return; - ICompress compressor = ICompress.createCompressor(); - for (File file : files) { - allBackupFiles.add(file); - try { - String worldName = compressor.getWorldName(file); - if (worldName == null) continue; - worldBackups.computeIfAbsent(worldName, k -> new ObjectArrayList<>()).add(file); - } catch (IOException ignored) {} + try (ICompress compressor = ICompress.createCompressor()) { + for (File file : files) { + allBackupFiles.add(file); + try { + String worldName = compressor.getWorldName(file); + if (worldName == null) continue; + worldBackups.computeIfAbsent(worldName, k -> new ObjectArrayList<>()).add(file); + } catch (IOException ex) { + serverutils.ServerUtilities.LOGGER.warn("Failed to inspect backup " + file.getAbsolutePath(), ex); + } + } + } catch (Exception ex) { + serverutils.ServerUtilities.LOGGER.warn("Failed to close the backup reader", ex); } } @@ -221,28 +229,22 @@ public void addButtons(Panel panel) { } } - @SuppressWarnings("ResultOfMethodCallIgnored") - private void renameAdditionalFiles(File previousRoot, boolean includeGlobal) { + private void moveAdditionalFiles(RestoreTransaction transaction, boolean includeGlobal) throws IOException { for (String pattern : backups.additional_backup_files) { if (!pattern.contains("$WORLDNAME") && !includeGlobal) { continue; } - pattern = pattern.replace("$WORLDNAME", worldName); + String resolvedPattern = BackupGlobUtils.substituteLiteralPath(pattern, worldName); // Gather list of all old files List previousFiles; int firstWildcardIndex = pattern.indexOf('*'); if (firstWildcardIndex == -1) { - previousFiles = FileUtils.listTree(new File(pattern)); + previousFiles = FileUtils.listTree(new File(resolvedPattern)); } else { - Path rootFolder = Paths.get(pattern.substring(0, firstWildcardIndex)); - - // If wildcard was not at the start of a directory, get the parent - if (firstWildcardIndex != 0 && (pattern.charAt(firstWildcardIndex - 1) != '/')) { - rootFolder = rootFolder.getParent(); - } - - PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern); + Path rootFolder = BackupGlobUtils.searchRoot(pattern, worldName); + PathMatcher matcher = FileSystems.getDefault() + .getPathMatcher("glob:" + BackupGlobUtils.substituteGlob(pattern, worldName)); List fileCandidates = FileUtils.listTree(rootFolder.toFile()); previousFiles = new ArrayList<>(); for (File file : fileCandidates) { @@ -252,12 +254,11 @@ private void renameAdditionalFiles(File previousRoot, boolean includeGlobal) { } } - // Move all old files into backup + // Move all old files into the rollback journal. for (File file : previousFiles) { - String pathRelative = FileUtils.getRelativePath(file); - File destFile = new File(previousRoot, pathRelative); - destFile.getParentFile().mkdirs(); - file.renameTo(destFile); + if (!transaction.isProtectedPath(file.toPath())) { + transaction.moveAside(file.toPath()); + } } } } @@ -276,35 +277,81 @@ private void loadBackupGlobal(File file) { () -> { loadBackup(file, true); }); } - @SuppressWarnings("ResultOfMethodCallIgnored") private void loadBackup(File file, boolean includeGlobal) { - File savesDir = new File("saves/"); - File worldDir = new File(savesDir, worldName); - File saveCopy = new File(savesDir, worldName + "_old"); + Path serverRoot = Paths.get("").toAbsolutePath().normalize(); + Path stagingRoot = null; + RestoreTransaction transaction = null; + + try { + RestoreTransaction.recoverPending(serverRoot); + Path worldRelative = Paths.get(worldName); + if (worldName.isEmpty() || worldName.indexOf('/') >= 0 + || worldName.indexOf('\\') >= 0 + || worldName.indexOf(':') >= 0 + || worldRelative.isAbsolute() + || worldRelative.getNameCount() != 1 + || worldName.equals(".") + || worldName.equals("..")) { + throw new IOException("Backup has an invalid world name: " + worldName); + } - while (saveCopy.exists()) { - saveCopy = new File(savesDir, saveCopy.getName() + "_old"); - } + Path savesDir = serverRoot.resolve("saves"); + Path worldDir = savesDir.resolve(worldRelative).normalize(); + Path saveCopy = savesDir.resolve(worldName + "_old"); + while (Files.exists(saveCopy)) { + saveCopy = savesDir.resolve(saveCopy.getFileName() + "_old"); + } - worldDir.renameTo(saveCopy); + Path stagingBase = serverRoot.resolve("serverutilities/restore-staging"); + Files.createDirectories(stagingBase); + stagingRoot = Files.createTempDirectory(stagingBase, "restore-"); - try (ICompress compressor = ICompress.createCompressor()) { - boolean isOldBackup = compressor.isOldBackup(file); + Path previousRoot = serverRoot.resolve("backups_before_restore") + .resolve(DATE_FORMAT.format(LocalDateTime.now())); + while (Files.exists(previousRoot)) { + previousRoot = previousRoot.resolveSibling(previousRoot.getFileName() + "_old"); + } + + transaction = new RestoreTransaction(serverRoot, previousRoot); + transaction.protect(stagingBase); + transaction.protect(serverRoot.resolve("backups_before_restore")); + Path archivePath = file.toPath().toAbsolutePath().normalize(); + if (archivePath.startsWith(serverRoot)) { + transaction.protect(archivePath); + } + + boolean isOldBackup; + try (ICompress compressor = ICompress.createCompressor()) { + isOldBackup = compressor.isOldBackup(file); + compressor.extractArchiveTo(stagingRoot.toFile(), file, includeGlobal, isOldBackup, worldName); + } + + transaction.moveAside(worldDir, saveCopy); if (!isOldBackup) { - File previousRoot = new File("backups_before_restore/"); - previousRoot = new File(previousRoot, DATE_FORMAT.format(Calendar.getInstance().getTime())); - renameAdditionalFiles(previousRoot, includeGlobal); + moveAdditionalFiles(transaction, includeGlobal); } - compressor.extractArchive(file, includeGlobal, isOldBackup); + + transaction.install(stagingRoot); + transaction.commit(); closeGui(); } catch (Exception e) { ServerUtilities.LOGGER.error("Failed to restore backup", e); - FileUtils.delete(worldDir); - saveCopy.renameTo(worldDir); + if (transaction != null) { + try { + transaction.rollback(); + } catch (IOException rollbackError) { + e.addSuppressed(rollbackError); + ServerUtilities.LOGGER.error("Failed to roll back the backup restore", rollbackError); + } + } Minecraft.getMinecraft().displayGuiScreen( new GuiErrorScreen( StatCollector.translateToLocal("serverutilities.gui.backup.error"), EnumChatFormatting.RED + e.getMessage())); + } finally { + if (stagingRoot != null) { + FileUtils.delete(stagingRoot.toFile()); + } } } diff --git a/src/main/java/serverutils/client/gui/GuiToggleCheatsButton.java b/src/main/java/serverutils/client/gui/GuiToggleCheatsButton.java index 709e8056f..ffd6b3778 100644 --- a/src/main/java/serverutils/client/gui/GuiToggleCheatsButton.java +++ b/src/main/java/serverutils/client/gui/GuiToggleCheatsButton.java @@ -3,6 +3,14 @@ import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import net.minecraft.client.AnvilConverterException; import net.minecraft.client.Minecraft; @@ -62,13 +70,13 @@ public boolean mousePressed(Minecraft mc, int mouseX, int mouseY) { } try { - toggleCheats(currentWorld); + writeCheatsSetting(currentWorld); SaveFormatComparator saveformatcomparator = (SaveFormatComparator) gui.field_146639_s .get(gui.field_146640_r); ((ISaveFormatComparatorWithCheatSetter) saveformatcomparator) .serverutilities$setCheatsEnabled(!saveformatcomparator.getCheatsEnabled()); - } catch (AnvilConverterException e) { - e.printStackTrace(); + } catch (AnvilConverterException | IOException e) { + serverutils.ServerUtilities.LOGGER.error("Failed to toggle cheats for the selected world", e); } return true; @@ -79,6 +87,14 @@ public boolean mousePressed(Minecraft mc, int mouseX, int mouseY) { */ @SideOnly(Side.CLIENT) public void toggleCheats(String worldName) throws AnvilConverterException { + try { + writeCheatsSetting(worldName); + } catch (IOException ex) { + serverutils.ServerUtilities.LOGGER.error("Failed to toggle cheats for world " + worldName, ex); + } + } + + private void writeCheatsSetting(String worldName) throws AnvilConverterException, IOException { File saveFolder = new File( ((SaveFormatOld) Minecraft.getMinecraft().getSaveLoader()).savesDirectory, worldName); @@ -89,17 +105,65 @@ public void toggleCheats(String worldName) throws AnvilConverterException { if (!levelDataFile.exists()) return; + NBTTagCompound parentTag; + try (FileInputStream input = new FileInputStream(levelDataFile)) { + parentTag = CompressedStreamTools.readCompressed(input); + } + + NBTTagCompound dataTag = parentTag.getCompoundTag("Data"); + byte allowCommands = dataTag.getByte("allowCommands"); + dataTag.setByte("allowCommands", allowCommands == 0 ? (byte) 1 : (byte) 0); + replaceLevelData(levelDataFile, parentTag); + } + + static void replaceLevelData(File levelDataFile, NBTTagCompound parentTag) throws IOException { + Path target = levelDataFile.toPath().toAbsolutePath().normalize(); + Path parent = target.getParent(); + if (parent == null) { + throw new IOException("World metadata has no parent directory: " + target); + } + + Path replacement = Files.createTempFile(parent, "level", ".dat_new"); + Path oldReplacement = null; try { - NBTTagCompound parentTag = CompressedStreamTools.readCompressed(new FileInputStream(levelDataFile)); - NBTTagCompound dataTag = parentTag.getCompoundTag("Data"); + try (FileOutputStream output = new FileOutputStream(replacement.toFile())) { + OutputStream nonClosing = new FilterOutputStream(output) { + + @Override + public void close() throws IOException { + flush(); + } + }; + CompressedStreamTools.writeCompressed(parentTag, nonClosing); + output.getChannel().force(true); + } - byte allowCommands = dataTag.getByte("allowCommands"); - allowCommands = allowCommands == 0 ? (byte) 1 : (byte) 0; - dataTag.setByte("allowCommands", allowCommands); + Path old = parent.resolve("level.dat_old"); + oldReplacement = Files.createTempFile(parent, "level", ".dat_old"); + Files.copy(target, oldReplacement, StandardCopyOption.REPLACE_EXISTING); + try (FileChannel oldOutput = FileChannel.open(oldReplacement, java.nio.file.StandardOpenOption.WRITE)) { + oldOutput.force(true); + } + moveReplacing(oldReplacement, old); + oldReplacement = null; + + moveReplacing(replacement, target); + replacement = null; + } finally { + if (replacement != null) { + Files.deleteIfExists(replacement); + } + if (oldReplacement != null) { + Files.deleteIfExists(oldReplacement); + } + } + } - CompressedStreamTools.writeCompressed(parentTag, new FileOutputStream(levelDataFile)); - } catch (Exception exception) { - exception.printStackTrace(); + private static void moveReplacing(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); } } diff --git a/src/main/java/serverutils/client/gui/GuiViewCrash.java b/src/main/java/serverutils/client/gui/GuiViewCrash.java index d36d2e69e..13a14a161 100644 --- a/src/main/java/serverutils/client/gui/GuiViewCrash.java +++ b/src/main/java/serverutils/client/gui/GuiViewCrash.java @@ -74,7 +74,7 @@ public void run() { .addChatMessage(new ChatComponentTranslation("serverutilities.lang.uploaded_crash", link)); } } catch (Exception ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to upload the crash report", ex); } } } diff --git a/src/main/java/serverutils/client/gui/RestoreRecovery.java b/src/main/java/serverutils/client/gui/RestoreRecovery.java new file mode 100644 index 000000000..aff45232e --- /dev/null +++ b/src/main/java/serverutils/client/gui/RestoreRecovery.java @@ -0,0 +1,19 @@ +package serverutils.client.gui; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** Recovers an interrupted backup restore before Minecraft can load a partially installed world. */ +public final class RestoreRecovery { + + private RestoreRecovery() {} + + public static void recoverPendingFromWorkingDirectory() throws IOException { + recoverPending(Paths.get("")); + } + + static void recoverPending(Path serverRoot) throws IOException { + RestoreTransaction.recoverPending(serverRoot); + } +} diff --git a/src/main/java/serverutils/client/gui/RestoreTransaction.java b/src/main/java/serverutils/client/gui/RestoreTransaction.java new file mode 100644 index 000000000..9f06496c4 --- /dev/null +++ b/src/main/java/serverutils/client/gui/RestoreTransaction.java @@ -0,0 +1,394 @@ +package serverutils.client.gui; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +final class RestoreTransaction { + + private static final String JOURNAL_NAME = ".restore-journal"; + private static final String JOURNAL_HEADER = "SERVERUTILITIES_RESTORE_V1"; + + @FunctionalInterface + interface InstallObserver { + + void afterInstall(Path destination) throws IOException; + } + + private static final class Move { + + private final Path original; + private final Path backup; + + private Move(Path original, Path backup) { + this.original = original; + this.backup = backup; + } + } + + private final Path root; + private final Path backupRoot; + private final Path journal; + private final List protectedPaths = new ArrayList<>(); + private final List moves = new ArrayList<>(); + private final List installedFiles = new ArrayList<>(); + private final List createdDirectories = new ArrayList<>(); + + RestoreTransaction(Path root, Path backupRoot) throws IOException { + this.root = root.toAbsolutePath().normalize(); + this.backupRoot = requireInsideRoot(backupRoot); + Files.createDirectories(this.backupRoot); + journal = this.backupRoot.resolve(JOURNAL_NAME); + try (FileChannel channel = FileChannel.open(journal, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + writeAndForce(channel, JOURNAL_HEADER + '\n'); + } + protect(this.backupRoot); + } + + void protect(Path path) throws IOException { + protectedPaths.add(requireInsideRoot(path)); + } + + void moveAside(Path original) throws IOException { + Path normalizedOriginal = requireInsideRoot(original); + Path relative = root.relativize(normalizedOriginal); + moveAside(normalizedOriginal, backupRoot.resolve(relative)); + } + + void moveAside(Path original, Path backup) throws IOException { + Path normalizedOriginal = requireInsideRoot(original); + Path normalizedBackup = requireInsideRoot(backup); + if (!Files.exists(normalizedOriginal, LinkOption.NOFOLLOW_LINKS)) { + return; + } + rejectLinkedPath(normalizedOriginal); + rejectLinkedPath(normalizedBackup); + if (isProtected(normalizedOriginal)) { + throw new IOException("Refusing to move protected restore path " + normalizedOriginal); + } + if (Files.exists(normalizedBackup, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Restore rollback path already exists: " + normalizedBackup); + } + + Path parent = normalizedBackup.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Move move = new Move(normalizedOriginal, normalizedBackup); + appendJournal("M " + encode(normalizedOriginal) + ' ' + encode(normalizedBackup)); + moves.add(move); + Files.move(normalizedOriginal, normalizedBackup); + protect(normalizedBackup); + } + + void install(Path stagingRoot) throws IOException { + install(stagingRoot, destination -> {}); + } + + void install(Path stagingRoot, InstallObserver observer) throws IOException { + Path staging = stagingRoot.toAbsolutePath().normalize(); + List stagedPaths; + try (Stream paths = Files.walk(staging)) { + stagedPaths = paths.filter(path -> !path.equals(staging)) + .sorted(Comparator.comparingInt(Path::getNameCount)).collect(Collectors.toList()); + } + + validateInstall(staging, stagedPaths); + for (Path source : stagedPaths) { + Path destination = destinationFor(staging, source); + if (Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) { + appendJournal("D " + encode(destination)); + createdDirectories.add(destination); + Files.createDirectory(destination); + } + continue; + } + + if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) { + moveAside(destination); + } + appendJournal("F " + encode(destination)); + installedFiles.add(destination); + Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); + observer.afterInstall(destination); + } + } + + private void validateInstall(Path staging, List stagedPaths) throws IOException { + for (Path source : stagedPaths) { + if (Files.isSymbolicLink(source)) { + throw new IOException("Restore staging contains a linked path: " + source); + } + + Path destination = destinationFor(staging, source); + rejectLinkedPath(destination); + if (!Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS) && isProtected(destination)) { + throw new IOException("Backup attempts to overwrite restore transaction data: " + destination); + } + + if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) { + if (Files.isSymbolicLink(destination)) { + throw new IOException("Restore target is a linked path: " + destination); + } + + boolean sourceDirectory = Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS); + boolean destinationDirectory = Files.isDirectory(destination, LinkOption.NOFOLLOW_LINKS); + if (sourceDirectory != destinationDirectory) { + throw new IOException("Restore target has a conflicting file type: " + destination); + } + } + } + } + + private Path destinationFor(Path staging, Path source) throws IOException { + Path destination = root.resolve(staging.relativize(source)).normalize(); + if (!destination.startsWith(root)) { + throw new IOException("Restore target escapes the server directory: " + destination); + } + return destination; + } + + void rollback() throws IOException { + IOException failure = null; + + for (int i = installedFiles.size() - 1; i >= 0; i--) { + try { + Files.deleteIfExists(installedFiles.get(i)); + } catch (IOException ex) { + failure = append(failure, ex); + } + } + + for (int i = createdDirectories.size() - 1; i >= 0; i--) { + try { + Files.deleteIfExists(createdDirectories.get(i)); + } catch (IOException ex) { + failure = append(failure, ex); + } + } + + for (int i = moves.size() - 1; i >= 0; i--) { + Move move = moves.get(i); + try { + if (!Files.exists(move.backup, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + Path parent = move.original.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.move(move.backup, move.original, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException ex) { + failure = append(failure, ex); + } + } + + if (failure != null) { + throw failure; + } + + deactivateJournal(); + } + + void commit() throws IOException { + deactivateJournal(); + } + + static void recoverPending(Path serverRoot) throws IOException { + Path root = serverRoot.toAbsolutePath().normalize(); + Path backupBase = root.resolve("backups_before_restore"); + if (!Files.isDirectory(backupBase, LinkOption.NOFOLLOW_LINKS)) { + return; + } + + List journals; + try (Stream paths = Files.walk(backupBase)) { + journals = paths.filter(path -> path.getFileName().toString().equals(JOURNAL_NAME)) + .collect(Collectors.toList()); + } + for (Path journal : journals) { + recoverJournal(root, journal); + } + } + + private boolean isProtected(Path path) { + Path normalized = path.toAbsolutePath().normalize(); + for (Path protectedPath : protectedPaths) { + if (normalized.startsWith(protectedPath) || protectedPath.startsWith(normalized)) { + return true; + } + } + return false; + } + + boolean isProtectedPath(Path path) { + return isProtected(path); + } + + private Path requireInsideRoot(Path path) throws IOException { + Path normalized = path.toAbsolutePath().normalize(); + if (!normalized.startsWith(root)) { + throw new IOException("Restore path escapes the server directory: " + path); + } + return normalized; + } + + private String encode(Path path) throws IOException { + Path relative = root.relativize(requireInsideRoot(path)); + String value = relative.toString().replace('\\', '/'); + return Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private void appendJournal(String line) throws IOException { + try (FileChannel channel = FileChannel.open(journal, StandardOpenOption.WRITE, StandardOpenOption.APPEND)) { + writeAndForce(channel, line + '\n'); + } + } + + private void deactivateJournal() throws IOException { + if (!Files.exists(journal, LinkOption.NOFOLLOW_LINKS)) { + return; + } + Path inactive = journal.resolveSibling(JOURNAL_NAME + ".inactive"); + Files.deleteIfExists(inactive); + moveReplacing(journal, inactive); + Files.deleteIfExists(inactive); + } + + private static void recoverJournal(Path root, Path journal) throws IOException { + if (Files.isSymbolicLink(journal)) { + throw new IOException("Restore journal is a symbolic link: " + journal); + } + List lines = Files.readAllLines(journal, StandardCharsets.UTF_8); + if (lines.isEmpty() || !JOURNAL_HEADER.equals(lines.get(0))) { + throw new IOException("Unrecognized restore journal: " + journal); + } + + List moves = new ArrayList<>(); + List files = new ArrayList<>(); + List directories = new ArrayList<>(); + for (int i = 1; i < lines.size(); i++) { + String[] parts = lines.get(i).split(" "); + if (parts.length == 0 || parts[0].isEmpty()) { + continue; + } + if (parts[0].equals("M") && parts.length == 3) { + moves.add(new Move(decode(root, parts[1]), decode(root, parts[2]))); + } else if (parts[0].equals("F") && parts.length == 2) { + files.add(decode(root, parts[1])); + } else if (parts[0].equals("D") && parts.length == 2) { + directories.add(decode(root, parts[1])); + } else { + throw new IOException("Invalid restore journal entry in " + journal); + } + } + + for (int i = files.size() - 1; i >= 0; i--) { + Path path = files.get(i); + if (shouldRemoveInstalledPath(path, moves)) { + Files.deleteIfExists(path); + } + } + for (int i = directories.size() - 1; i >= 0; i--) { + Path path = directories.get(i); + if (shouldRemoveInstalledPath(path, moves)) { + Files.deleteIfExists(path); + } + } + for (int i = moves.size() - 1; i >= 0; i--) { + Move move = moves.get(i); + if (!Files.exists(move.backup, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + Path parent = move.original.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.move(move.backup, move.original, StandardCopyOption.REPLACE_EXISTING); + } + + Path inactive = journal.resolveSibling(JOURNAL_NAME + ".inactive"); + Files.deleteIfExists(inactive); + moveReplacing(journal, inactive); + Files.deleteIfExists(inactive); + } + + private static boolean shouldRemoveInstalledPath(Path path, List moves) { + for (Move move : moves) { + if (path.startsWith(move.original)) { + return Files.exists(move.backup, LinkOption.NOFOLLOW_LINKS); + } + } + return true; + } + + private static Path decode(Path root, String encoded) throws IOException { + final String value; + try { + value = new String(Base64.getUrlDecoder().decode(encoded), StandardCharsets.UTF_8); + } catch (IllegalArgumentException ex) { + throw new IOException("Restore journal contains invalid path data", ex); + } + Path relative = java.nio.file.Paths.get(value).normalize(); + Path resolved = root.resolve(relative).normalize(); + if (relative.isAbsolute() || relative.startsWith("..") || !resolved.startsWith(root)) { + throw new IOException("Restore journal path escapes the server directory"); + } + return resolved; + } + + private static void writeAndForce(FileChannel channel, String value) throws IOException { + ByteBuffer buffer = StandardCharsets.UTF_8.encode(value); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + + private static void moveReplacing(Path source, Path destination) throws IOException { + try { + Files.move(source, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + + private void rejectLinkedPath(Path path) throws IOException { + Path current = root; + for (Path segment : root.relativize(path)) { + current = current.resolve(segment); + if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + + BasicFileAttributes attributes = Files + .readAttributes(current, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (Files.isSymbolicLink(current) || attributes.isOther()) { + throw new IOException("Restore path traverses a link: " + current); + } + } + } + + private static IOException append(IOException current, IOException next) { + if (current == null) { + return next; + } + current.addSuppressed(next); + return current; + } +} diff --git a/src/main/java/serverutils/client/gui/SidebarButtonManager.java b/src/main/java/serverutils/client/gui/SidebarButtonManager.java index cfdf9490c..f046c15d4 100644 --- a/src/main/java/serverutils/client/gui/SidebarButtonManager.java +++ b/src/main/java/serverutils/client/gui/SidebarButtonManager.java @@ -80,7 +80,7 @@ public void onResourceManagerReload(IResourceManager manager) { } } catch (Exception ex) { if (!(ex instanceof FileNotFoundException)) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to load sidebar button groups", ex); } } } @@ -134,7 +134,7 @@ public void onResourceManagerReload(IResourceManager manager) { } } catch (Exception ex) { if (!(ex instanceof FileNotFoundException)) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to load sidebar buttons", ex); } } } diff --git a/src/main/java/serverutils/client/gui/ThreadReloadChunkSelector.java b/src/main/java/serverutils/client/gui/ThreadReloadChunkSelector.java index d99483e34..501643535 100644 --- a/src/main/java/serverutils/client/gui/ThreadReloadChunkSelector.java +++ b/src/main/java/serverutils/client/gui/ThreadReloadChunkSelector.java @@ -272,7 +272,7 @@ public void run() { pixelBuffer = PIXELS.toByteBuffer(false); } } catch (Exception ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to render the chunk selector", ex); } pixelBuffer = PIXELS.toByteBuffer(false); diff --git a/src/main/java/serverutils/client/gui/ranks/GuiAddRank.java b/src/main/java/serverutils/client/gui/ranks/GuiAddRank.java index 0ecfbaabe..62c5a551a 100644 --- a/src/main/java/serverutils/client/gui/ranks/GuiAddRank.java +++ b/src/main/java/serverutils/client/gui/ranks/GuiAddRank.java @@ -28,7 +28,7 @@ public GuiAddRank(GuiRanks prev) { @Override public void onClicked(MouseButton button) { GuiHelper.playClickSound(); - String text = textBoxId.getText().toLowerCase(); + String text = textBoxId.getText().toLowerCase(java.util.Locale.ROOT); if (!text.isEmpty()) { getGui().closeGui(true); ClientUtils.execClientCommand("/ranks create " + text); diff --git a/src/main/java/serverutils/client/gui/ranks/GuiPlayerRanks.java b/src/main/java/serverutils/client/gui/ranks/GuiPlayerRanks.java index c40f8586a..6dd6886ce 100644 --- a/src/main/java/serverutils/client/gui/ranks/GuiPlayerRanks.java +++ b/src/main/java/serverutils/client/gui/ranks/GuiPlayerRanks.java @@ -105,6 +105,6 @@ public void addButtons(Panel panel) { @Override public String getFilterText(Widget widget) { - return ((PlayerEntry) widget).username.toLowerCase(); + return ((PlayerEntry) widget).username.toLowerCase(java.util.Locale.ROOT); } } diff --git a/src/main/java/serverutils/client/gui/ranks/GuiRanks.java b/src/main/java/serverutils/client/gui/ranks/GuiRanks.java index 5202fb551..4b232a3b7 100644 --- a/src/main/java/serverutils/client/gui/ranks/GuiRanks.java +++ b/src/main/java/serverutils/client/gui/ranks/GuiRanks.java @@ -108,8 +108,8 @@ public void onClicked(MouseButton button) { } public void removeRank(SimpleTextButton btn) { - ClientUtils.execClientCommand("/ranks delete " + btn.getTitle().toLowerCase()); - ranks.remove(btn.getTitle().toLowerCase()); + ClientUtils.execClientCommand("/ranks delete " + btn.getTitle().toLowerCase(java.util.Locale.ROOT)); + ranks.remove(btn.getTitle().toLowerCase(java.util.Locale.ROOT)); panelButtons.widgets.remove(btn); refreshWidgets(); } diff --git a/src/main/java/serverutils/client/gui/teams/GuiCreateTeam.java b/src/main/java/serverutils/client/gui/teams/GuiCreateTeam.java index e88d4b023..eea3a52c8 100644 --- a/src/main/java/serverutils/client/gui/teams/GuiCreateTeam.java +++ b/src/main/java/serverutils/client/gui/teams/GuiCreateTeam.java @@ -77,7 +77,8 @@ public void onTextChanged() { }; textBoxId.setPosAndSize(8, 8, width - 16, 16); - textBoxId.writeText(Minecraft.getMinecraft().thePlayer.getGameProfile().getName().toLowerCase()); + textBoxId.writeText( + Minecraft.getMinecraft().thePlayer.getGameProfile().getName().toLowerCase(java.util.Locale.ROOT)); textBoxId.ghostText = "Enter ID"; // LANG textBoxId.textColor = color.getColor(); textBoxId.setFocused(true); diff --git a/src/main/java/serverutils/client/gui/teams/GuiManageAllies.java b/src/main/java/serverutils/client/gui/teams/GuiManageAllies.java index 1bd66ff71..bbea041aa 100644 --- a/src/main/java/serverutils/client/gui/teams/GuiManageAllies.java +++ b/src/main/java/serverutils/client/gui/teams/GuiManageAllies.java @@ -43,7 +43,7 @@ public void addMouseOverText(List list) { public void onClicked(MouseButton button) { GuiHelper.playClickSound(); NBTTagCompound data = new NBTTagCompound(); - data.setString("player", entry.name); + data.setString("player", entry.uuid.toString()); if (entry.status.isEqualOrGreaterThan(EnumTeamStatus.ALLY)) { data.setBoolean("add", false); diff --git a/src/main/java/serverutils/client/gui/teams/GuiManageEnemies.java b/src/main/java/serverutils/client/gui/teams/GuiManageEnemies.java index 81ffe2c51..2f433904f 100644 --- a/src/main/java/serverutils/client/gui/teams/GuiManageEnemies.java +++ b/src/main/java/serverutils/client/gui/teams/GuiManageEnemies.java @@ -42,7 +42,7 @@ public void addMouseOverText(List list) { public void onClicked(MouseButton button) { GuiHelper.playClickSound(); NBTTagCompound data = new NBTTagCompound(); - data.setString("player", entry.name); + data.setString("player", entry.uuid.toString()); if (entry.status == EnumTeamStatus.ENEMY) { data.setBoolean("add", false); diff --git a/src/main/java/serverutils/client/gui/teams/GuiManageMembers.java b/src/main/java/serverutils/client/gui/teams/GuiManageMembers.java index 45391b097..ec5a8d53b 100644 --- a/src/main/java/serverutils/client/gui/teams/GuiManageMembers.java +++ b/src/main/java/serverutils/client/gui/teams/GuiManageMembers.java @@ -76,7 +76,7 @@ public void addMouseOverText(List list) { public void onClicked(MouseButton button) { GuiHelper.playClickSound(); NBTTagCompound data = new NBTTagCompound(); - data.setString("player", entry.name); + data.setString("player", entry.uuid.toString()); if (entry.requestingInvite) { if (button.isLeft()) { diff --git a/src/main/java/serverutils/client/gui/teams/GuiManageModerators.java b/src/main/java/serverutils/client/gui/teams/GuiManageModerators.java index da926c48d..491e04661 100644 --- a/src/main/java/serverutils/client/gui/teams/GuiManageModerators.java +++ b/src/main/java/serverutils/client/gui/teams/GuiManageModerators.java @@ -43,7 +43,7 @@ public void addMouseOverText(List list) { public void onClicked(MouseButton button) { GuiHelper.playClickSound(); NBTTagCompound data = new NBTTagCompound(); - data.setString("player", entry.name); + data.setString("player", entry.uuid.toString()); if (entry.status.isEqualOrGreaterThan(EnumTeamStatus.MOD)) { data.setBoolean("add", false); diff --git a/src/main/java/serverutils/client/gui/teams/GuiTransferOwnership.java b/src/main/java/serverutils/client/gui/teams/GuiTransferOwnership.java index 9025fd107..6a41a8060 100644 --- a/src/main/java/serverutils/client/gui/teams/GuiTransferOwnership.java +++ b/src/main/java/serverutils/client/gui/teams/GuiTransferOwnership.java @@ -35,7 +35,7 @@ public void onClicked(MouseButton button) { () -> { getGui().closeGui(false); NBTTagCompound data = new NBTTagCompound(); - data.setString("player", entry.name); + data.setString("player", entry.uuid.toString()); new MessageMyTeamAction(ServerUtilitiesTeamGuiActions.TRANSFER_OWNERSHIP.getId(), data) .sendToServer(); }); diff --git a/src/main/java/serverutils/command/CmdAddFakePlayer.java b/src/main/java/serverutils/command/CmdAddFakePlayer.java index 0363be0b7..45700643d 100644 --- a/src/main/java/serverutils/command/CmdAddFakePlayer.java +++ b/src/main/java/serverutils/command/CmdAddFakePlayer.java @@ -37,7 +37,7 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE } ForgePlayer p = new ForgePlayer(Universe.get(), id, args[1]); - p.team.universe.players.put(p.getId(), p); + p.getUniverse().registerPlayer(p); p.clearCache(); sender.addChatMessage( ServerUtilities.lang(sender, "serverutilities.lang.add_fake_player.added", p.getDisplayName())); diff --git a/src/main/java/serverutils/command/CmdBackup.java b/src/main/java/serverutils/command/CmdBackup.java index d5aacaf88..b33169ee4 100644 --- a/src/main/java/serverutils/command/CmdBackup.java +++ b/src/main/java/serverutils/command/CmdBackup.java @@ -34,7 +34,7 @@ public void processCommand(ICommandSender sender, String[] args) { final BackupTask task = new BackupTask(sender, target, oc); - if (BackupTask.thread == null) { + if (!BackupTask.isBackupInProgress()) { task.execute(Universe.get()); sender.addChatMessage( ServerUtilities @@ -53,9 +53,7 @@ public CmdBackupStop(String s) { @Override public void processCommand(ICommandSender sender, String[] args) { - if (BackupTask.thread != null) { - BackupTask.thread.interrupt(); - BackupTask.thread = null; + if (BackupTask.cancelRunningBackup()) { sender.addChatMessage(ServerUtilities.lang(sender, "cmd.backup_stop")); } else { sender.addChatMessage(ServerUtilities.lang(sender, "cmd.backup_not_running")); diff --git a/src/main/java/serverutils/command/CmdDumpChunkloaders.java b/src/main/java/serverutils/command/CmdDumpChunkloaders.java index 1f58b7467..55efee7dd 100644 --- a/src/main/java/serverutils/command/CmdDumpChunkloaders.java +++ b/src/main/java/serverutils/command/CmdDumpChunkloaders.java @@ -171,7 +171,7 @@ private void processCommandDumpRegions(ICommandSender sender, Iterable wo ChunkCoordIntPair headChunk = chunks.get(0); IChatComponent regionDescription = new ChatComponentText( - String.format(" * %s (size: %d)", headChunk, regionSize)); + String.format(java.util.Locale.ROOT, " * %s (size: %d)", headChunk, regionSize)); int x = headChunk.chunkXPos * 16 + 8; int z = headChunk.chunkZPos * 16 + 8; @@ -201,7 +201,7 @@ private IChatComponent buildWorldDescription(World world) { @NotNull private IChatComponent buildTicketDescription(ForgeChunkManager.Ticket ticket) { - IChatComponent title = new ChatComponentText(String.format("#%08x", ticket.hashCode())); + IChatComponent title = new ChatComponentText(String.format(java.util.Locale.ROOT, "#%08x", ticket.hashCode())); title.getChatStyle().setChatHoverEvent( new HoverEvent( HoverEvent.Action.SHOW_TEXT, diff --git a/src/main/java/serverutils/command/CmdDumpPermissions.java b/src/main/java/serverutils/command/CmdDumpPermissions.java index 1811ad259..19c7315a8 100644 --- a/src/main/java/serverutils/command/CmdDumpPermissions.java +++ b/src/main/java/serverutils/command/CmdDumpPermissions.java @@ -93,6 +93,7 @@ public void processCommand(ICommandSender sender, String[] args) { int max = configInt.getMax(); variants.add( String.format( + java.util.Locale.ROOT, "%s to %s", min == Integer.MIN_VALUE ? "∞" : String.valueOf(min), max == Integer.MAX_VALUE ? "∞" : String.valueOf(max))); @@ -101,12 +102,13 @@ public void processCommand(ICommandSender sender, String[] args) { double max = configDouble.getMax(); variants.add( String.format( + java.util.Locale.ROOT, "%s to %s", min == Double.NEGATIVE_INFINITY ? "∞" : StringUtils.formatDouble(min), max == Double.POSITIVE_INFINITY ? '∞' : StringUtils.formatDouble(max))); } else if (entry.player instanceof ConfigTimer configTimer) { Ticks max = configTimer.getMax(); - variants.add(String.format("0s to %s", !max.hasTicks() ? "∞" : max.toString())); + variants.add(String.format(java.util.Locale.ROOT, "0s to %s", !max.hasTicks() ? "∞" : max.toString())); } else { variants = new ArrayList<>(entry.player.getVariants()); variants.sort(StringUtils.IGNORE_CASE_COMPARATOR); diff --git a/src/main/java/serverutils/command/CmdEditNBT.java b/src/main/java/serverutils/command/CmdEditNBT.java index b5cd3fd6f..26062d320 100644 --- a/src/main/java/serverutils/command/CmdEditNBT.java +++ b/src/main/java/serverutils/command/CmdEditNBT.java @@ -243,7 +243,7 @@ public NBTTagCompound editNBT(EntityPlayerMP player, NBTTagCompound info, String addInfo(list, new ChatComponentText("Name"), new ChatComponentText(player.getGameProfile().getName())); addInfo(list, new ChatComponentText("Display Name"), new ChatComponentText(player.getDisplayName())); addInfo(list, new ChatComponentText("UUID"), new ChatComponentText(player.getUniqueID().toString())); - addInfo(list, new ChatComponentText("Team"), new ChatComponentText(p.team.getId())); + addInfo(list, new ChatComponentText("Team"), new ChatComponentText(p.getTeam().getId())); info.setTag("text", list); info.setString( "title", diff --git a/src/main/java/serverutils/command/CmdReload.java b/src/main/java/serverutils/command/CmdReload.java index 6f1196502..0c4af2442 100644 --- a/src/main/java/serverutils/command/CmdReload.java +++ b/src/main/java/serverutils/command/CmdReload.java @@ -9,7 +9,7 @@ import net.minecraft.command.ICommandSender; import net.minecraft.util.ResourceLocation; -import serverutils.ServerUtilitiesRegistry; +import serverutils.api.ServerUtilitiesRegistry; import serverutils.events.ServerReloadEvent; import serverutils.lib.EnumReloadType; import serverutils.lib.command.CmdBase; @@ -26,7 +26,7 @@ public CmdReload(String id, Level l) { tab = new HashSet<>(); tab.add("*"); - for (ResourceLocation r : ServerUtilitiesRegistry.RELOAD_IDS.keySet()) { + for (ResourceLocation r : ServerUtilitiesRegistry.reloadHandlersView().keySet()) { tab.add(r.toString()); tab.add(r.getResourceDomain() + ":*"); } diff --git a/src/main/java/serverutils/command/CmdVanish.java b/src/main/java/serverutils/command/CmdVanish.java index cff49e2ce..0beb868af 100644 --- a/src/main/java/serverutils/command/CmdVanish.java +++ b/src/main/java/serverutils/command/CmdVanish.java @@ -63,13 +63,13 @@ public void processCommand(ICommandSender sender, String[] args) { if (toVanish) { tag.setBoolean("vanish", true); - universe.vanishedPlayers.add(forgePlayer); + universe.setVanished(forgePlayer, true); player.capabilities.disableDamage = true; player.addChatMessage(new ChatComponentTranslation("commands.vanish.on")); updateVanishStatus(player, true); } else { tag.removeTag("vanish"); - universe.vanishedPlayers.remove(forgePlayer); + universe.setVanished(forgePlayer, false); player.capabilities.disableDamage = false; player.addChatMessage(new ChatComponentTranslation("commands.vanish.off")); updateVanishStatus(player, false); @@ -114,8 +114,9 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE state = data.toggleState(type); } - String stateStr = " " + StatCollector - .translateToLocal("addServer.resourcePack." + (state ? "enabled" : "disabled")).toLowerCase(); + String stateStr = " " + + StatCollector.translateToLocal("addServer.resourcePack." + (state ? "enabled" : "disabled")) + .toLowerCase(java.util.Locale.ROOT); player.addChatMessage( new ChatComponentText( StatCollector.translateToLocal("commands.vanish." + getCommandName()) + stateStr)); diff --git a/src/main/java/serverutils/command/TransferCommand.java b/src/main/java/serverutils/command/TransferCommand.java index 09596fe01..124615846 100644 --- a/src/main/java/serverutils/command/TransferCommand.java +++ b/src/main/java/serverutils/command/TransferCommand.java @@ -49,7 +49,9 @@ public void processCommand(ICommandSender sender, String[] args) { throw new WrongUsageException(getCommandUsage(sender)); } hostname = hostname.substring(0, colonIdx); - } catch (NumberFormatException ignored) {} + } catch (NumberFormatException ignored) { + // Use the default port when no numeric port suffix is present. + } } } diff --git a/src/main/java/serverutils/command/chunks/CmdClaimAs.java b/src/main/java/serverutils/command/chunks/CmdClaimAs.java index 02a50dd98..fa2ef00e3 100644 --- a/src/main/java/serverutils/command/chunks/CmdClaimAs.java +++ b/src/main/java/serverutils/command/chunks/CmdClaimAs.java @@ -72,7 +72,7 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE UUID.nameUUIDFromBytes("FakePlayerClaimAs".getBytes(StandardCharsets.UTF_8)), "FakePlayerClaimAs"); - p.team = team; + p.setTeam(team); ChunkDimPos pos = new ChunkDimPos(player); for (int x = -radius; x <= radius; x++) { diff --git a/src/main/java/serverutils/command/chunks/CmdLoad.java b/src/main/java/serverutils/command/chunks/CmdLoad.java index 0201264a1..4690a6e4c 100644 --- a/src/main/java/serverutils/command/chunks/CmdLoad.java +++ b/src/main/java/serverutils/command/chunks/CmdLoad.java @@ -33,7 +33,7 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE ChunkDimPos pos = new ChunkDimPos(player); if (p.hasTeam() && ClaimedChunks.instance.canPlayerModify(p, pos, ServerUtilitiesPermissions.CLAIMS_OTHER_LOAD) - && ClaimedChunks.instance.loadChunk(p, p.team, pos)) { + && ClaimedChunks.instance.loadChunk(p, p.getTeam(), pos)) { CHUNK_MODIFIED.send(player, "serverutilities.lang.chunks.chunk_loaded"); ServerUtilitiesNotifications.updateChunkMessage(player, pos); } else { diff --git a/src/main/java/serverutils/command/chunks/CmdUnclaimAll.java b/src/main/java/serverutils/command/chunks/CmdUnclaimAll.java index 1936335e8..12ca709f1 100644 --- a/src/main/java/serverutils/command/chunks/CmdUnclaimAll.java +++ b/src/main/java/serverutils/command/chunks/CmdUnclaimAll.java @@ -47,7 +47,7 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE if (p.hasTeam()) { OptionalInt dimension = CommandUtils.parseDimension(sender, args, 0); - ClaimedChunks.instance.unclaimAllChunks(p, p.team, dimension); + ClaimedChunks.instance.unclaimAllChunks(p, p.getTeam(), dimension); CHUNK_MODIFIED.send(player, "serverutilities.lang.chunks.unclaimed_all"); } else { throw ServerUtilities.error(sender, "serverutilities.lang.team.error.no_team"); diff --git a/src/main/java/serverutils/command/chunks/CmdUnloadAll.java b/src/main/java/serverutils/command/chunks/CmdUnloadAll.java index b38915f68..6e693f5ee 100644 --- a/src/main/java/serverutils/command/chunks/CmdUnloadAll.java +++ b/src/main/java/serverutils/command/chunks/CmdUnloadAll.java @@ -48,7 +48,7 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE if (p.hasTeam()) { OptionalInt dimension = CommandUtils.parseDimension(sender, args, 0); - for (ClaimedChunk chunk : ClaimedChunks.instance.getTeamChunks(p.team, dimension)) { + for (ClaimedChunk chunk : ClaimedChunks.instance.getTeamChunks(p.getTeam(), dimension)) { chunk.setLoaded(false); } diff --git a/src/main/java/serverutils/command/client/CommandPing.java b/src/main/java/serverutils/command/client/CommandPing.java index 523639f40..ce8e02eac 100644 --- a/src/main/java/serverutils/command/client/CommandPing.java +++ b/src/main/java/serverutils/command/client/CommandPing.java @@ -88,7 +88,7 @@ public void onNetworkTick() { new C00Handshake(5, address.getIP(), address.getPort(), EnumConnectionState.STATUS)); networkManager.scheduleOutboundPacket(new C00PacketServerQuery()); } catch (UnknownHostException e) { - e.printStackTrace(); + ServerUtilities.LOGGER.warn("Unknown host while pinging " + address.getIP(), e); sender.addChatMessage(ServerUtilities.lang(sender, "commands.ping.unknown")); } }); diff --git a/src/main/java/serverutils/command/pregen/CmdStart.java b/src/main/java/serverutils/command/pregen/CmdStart.java index 2586ecf5c..f42ad1584 100644 --- a/src/main/java/serverutils/command/pregen/CmdStart.java +++ b/src/main/java/serverutils/command/pregen/CmdStart.java @@ -58,7 +58,7 @@ public void processCommand(ICommandSender sender, String[] args) { new ChatComponentText("Initializing pregenerator. Check progress with '/pregen progress'.")); ChunkLoaderManager.instance.initializePregenerator(commandInfo, MinecraftServer.getServer()); } catch (IOException e) { - e.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to start the pregenerator", e); sender.addChatMessage( new ChatComponentText( "Cannot start a pregenerator! File exception when starting pregenerator!")); diff --git a/src/main/java/serverutils/command/ranks/CmdCreate.java b/src/main/java/serverutils/command/ranks/CmdCreate.java index 2f8be9cb5..275268a4e 100644 --- a/src/main/java/serverutils/command/ranks/CmdCreate.java +++ b/src/main/java/serverutils/command/ranks/CmdCreate.java @@ -32,7 +32,7 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE if (args.length > 1) { for (int i = 1; i < args.length; i++) { - rank.addParent(Ranks.INSTANCE.getRank(sender, args[1].toLowerCase())); + rank.addParent(Ranks.INSTANCE.getRank(sender, args[1].toLowerCase(java.util.Locale.ROOT))); } } diff --git a/src/main/java/serverutils/command/team/CmdCreate.java b/src/main/java/serverutils/command/team/CmdCreate.java index 6d8059c77..3aef39aa7 100644 --- a/src/main/java/serverutils/command/team/CmdCreate.java +++ b/src/main/java/serverutils/command/team/CmdCreate.java @@ -59,15 +59,15 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE throw ServerUtilities.error(sender, "serverutilities.lang.team.id_invalid"); } - if (p.team.universe.getTeam(args[0]).isValid()) { + if (p.getUniverse().getTeam(args[0]).isValid()) { throw ServerUtilities.error(sender, "serverutilities.lang.team.id_already_exists"); } - p.team.universe.clearCache(); + p.getUniverse().clearCache(); ForgeTeam team = new ForgeTeam( - p.team.universe, - p.team.universe.generateTeamUID((short) 0), + p.getUniverse(), + p.getUniverse().generateTeamUID((short) 0), args[0], TeamType.PLAYER); @@ -77,8 +77,8 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE team.setColor(EnumTeamColor.NAME_MAP.getRandom(sender.getEntityWorld().rand)); } - p.team = team; - team.owner = p; + p.setTeam(team); + team.initializeOwner(p); team.universe.addTeam(team); new ForgeTeamCreatedEvent(team).post(); ForgeTeamPlayerJoinedEvent event = new ForgeTeamPlayerJoinedEvent(p); diff --git a/src/main/java/serverutils/command/team/CmdGet.java b/src/main/java/serverutils/command/team/CmdGet.java index 13daf9391..8af72e75a 100644 --- a/src/main/java/serverutils/command/team/CmdGet.java +++ b/src/main/java/serverutils/command/team/CmdGet.java @@ -34,7 +34,7 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE IChatComponent component = new ChatComponentText(""); component.appendSibling(player.getDisplayName()); component.appendText(": "); - component.appendSibling(player.team.getCommandTitle()); + component.appendSibling(player.getTeam().getCommandTitle()); sender.addChatMessage(component); } } diff --git a/src/main/java/serverutils/command/team/CmdInfo.java b/src/main/java/serverutils/command/team/CmdInfo.java index 2a8f3626f..ca913ea2a 100644 --- a/src/main/java/serverutils/command/team/CmdInfo.java +++ b/src/main/java/serverutils/command/team/CmdInfo.java @@ -49,7 +49,9 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE sender, "commands.team.info.uid", StringUtils.color( - new ChatComponentText(team.getUID() + " / " + String.format("%04x", team.getUID())), + new ChatComponentText( + team.getUID() + " / " + + String.format(java.util.Locale.ROOT, "%04x", team.getUID())), EnumChatFormatting.BLUE))); sender.addChatMessage( ServerUtilities.lang( diff --git a/src/main/java/serverutils/command/team/CmdJoin.java b/src/main/java/serverutils/command/team/CmdJoin.java index b41fcf12f..9a0604d16 100644 --- a/src/main/java/serverutils/command/team/CmdJoin.java +++ b/src/main/java/serverutils/command/team/CmdJoin.java @@ -45,7 +45,7 @@ public List addTabCompletionOptions(ICommandSender sender, String[] args list.sort(null); } } catch (Exception ex) { - ex.printStackTrace(); + ServerUtilities.LOGGER.error("Failed to build team tab completions", ex); } return getListOfStringsFromIterableMatchingLastWord(args, list); @@ -68,9 +68,9 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE ForgeTeam team = CommandUtils.getTeam(sender, args[0]); if (team.addMember(p, true)) { - if (p.team.isOwner(p)) { - new ForgeTeamChangedEvent(team, p.team).post(); - p.team.removeMember(p); + if (p.getTeam().isOwner(p)) { + new ForgeTeamChangedEvent(team, p.getTeam()).post(); + p.getTeam().removeMember(p); } else if (p.hasTeam()) { throw ServerUtilities.error(sender, "serverutilities.lang.team.error.must_leave"); } diff --git a/src/main/java/serverutils/command/team/CmdKick.java b/src/main/java/serverutils/command/team/CmdKick.java index 45b675a60..0c670d563 100644 --- a/src/main/java/serverutils/command/team/CmdKick.java +++ b/src/main/java/serverutils/command/team/CmdKick.java @@ -25,7 +25,7 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE if (!p.hasTeam()) { throw ServerUtilities.error(sender, "serverutilities.lang.team.error.no_team"); - } else if (!p.team.isModerator(p)) { + } else if (!p.getTeam().isModerator(p)) { throw new CommandException("commands.generic.permission"); } @@ -33,10 +33,10 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE ForgePlayer p1 = CommandUtils.getForgePlayer(sender, args[0]); - if (!p.team.isMember(p1)) { + if (!p.getTeam().isMember(p1)) { throw ServerUtilities.error(sender, "serverutilities.lang.team.error.not_member", p1.getDisplayName()); } else if (!p1.equalsPlayer(p)) { - p.team.removeMember(p1); + p.getTeam().removeMember(p1); } else { throw ServerUtilities.error(sender, "serverutilities.lang.team.error.must_transfer_ownership"); } diff --git a/src/main/java/serverutils/command/team/CmdLeave.java b/src/main/java/serverutils/command/team/CmdLeave.java index 7565e5618..eb58ed85a 100644 --- a/src/main/java/serverutils/command/team/CmdLeave.java +++ b/src/main/java/serverutils/command/team/CmdLeave.java @@ -20,7 +20,7 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE if (!p.hasTeam()) { throw ServerUtilities.error(sender, "serverutilities.lang.team.error.no_team"); - } else if (!p.team.removeMember(p)) { + } else if (!p.getTeam().removeMember(p)) { throw ServerUtilities.error(sender, "serverutilities.lang.team.error.must_transfer_ownership"); } } diff --git a/src/main/java/serverutils/command/team/CmdSettings.java b/src/main/java/serverutils/command/team/CmdSettings.java index eab5f55ef..be7f78ccf 100644 --- a/src/main/java/serverutils/command/team/CmdSettings.java +++ b/src/main/java/serverutils/command/team/CmdSettings.java @@ -26,16 +26,16 @@ public ConfigGroup getGroup(ICommandSender sender) throws CommandException { if (!p.hasTeam()) { ServerUtilitiesAPI.sendCloseGuiPacket(player); throw ServerUtilities.error(sender, "serverutilities.lang.team.error.no_team"); - } else if (!p.team.isModerator(p)) { + } else if (!p.getTeam().isModerator(p)) { ServerUtilitiesAPI.sendCloseGuiPacket(player); throw new CommandException("commands.generic.permission"); } - return p.team.getSettings(); + return p.getTeam().getSettings(); } @Override public IConfigCallback getCallback(ICommandSender sender) throws CommandException { - return CommandUtils.getForgePlayer(sender).team; + return CommandUtils.getForgePlayer(sender).getTeam(); } } diff --git a/src/main/java/serverutils/command/team/CmdStatus.java b/src/main/java/serverutils/command/team/CmdStatus.java index fe4d2fbcd..708d24713 100644 --- a/src/main/java/serverutils/command/team/CmdStatus.java +++ b/src/main/java/serverutils/command/team/CmdStatus.java @@ -43,7 +43,7 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE if (!p.hasTeam()) { throw ServerUtilities.error(sender, "serverutilities.lang.team.error.no_team"); - } else if (!p.team.isModerator(p)) { + } else if (!p.getTeam().isModerator(p)) { throw new CommandException("commands.generic.permission"); } @@ -51,20 +51,20 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE ForgePlayer p1 = CommandUtils.getForgePlayer(sender, args[0]); if (args.length == 1) { - sender.addChatMessage(EnumTeamStatus.NAME_MAP.getDisplayName(sender, p.team.getHighestStatus(p1))); + sender.addChatMessage(EnumTeamStatus.NAME_MAP.getDisplayName(sender, p.getTeam().getHighestStatus(p1))); return; } - if (p.team.isOwner(p1)) { + if (p.getTeam().isOwner(p1)) { throw ServerUtilities.error(sender, "serverutilities.lang.team.permission.owner"); - } else if (!p.team.isModerator(p)) { + } else if (!p.getTeam().isModerator(p)) { throw new CommandException("commands.generic.permission"); } - EnumTeamStatus status = EnumTeamStatus.NAME_MAP.get(args[1].toLowerCase()); + EnumTeamStatus status = EnumTeamStatus.NAME_MAP.get(args[1].toLowerCase(java.util.Locale.ROOT)); if (status.canBeSet()) { - p.team.setStatus(p1, status); + p.getTeam().setStatus(p1, status); sender.addChatMessage( ServerUtilities.lang( sender, diff --git a/src/main/java/serverutils/command/team/CmdTransferOwnership.java b/src/main/java/serverutils/command/team/CmdTransferOwnership.java index c3b464b7d..58ffa0fe4 100644 --- a/src/main/java/serverutils/command/team/CmdTransferOwnership.java +++ b/src/main/java/serverutils/command/team/CmdTransferOwnership.java @@ -26,7 +26,7 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE if (!p.hasTeam()) { throw ServerUtilities.error(sender, "serverutilities.lang.team.error.no_team"); - } else if (!p.team.isOwner(p)) { + } else if (!p.getTeam().isOwner(p)) { throw ServerUtilities.error(sender, "serverutilities.lang.team.error.not_owner"); } @@ -34,10 +34,10 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE ForgePlayer p1 = CommandUtils.getForgePlayer(sender, args[0]); - if (!p.team.equalsTeam(p1.team)) { + if (!p.getTeam().equalsTeam(p1.getTeam())) { throw ServerUtilities.error(sender, "serverutilities.lang.team.error.not_member", p1.getDisplayName()); } - p.team.setStatus(p1, EnumTeamStatus.OWNER); + p.getTeam().setStatus(p1, EnumTeamStatus.OWNER); } } diff --git a/src/main/java/serverutils/command/tp/CmdDelHome.java b/src/main/java/serverutils/command/tp/CmdDelHome.java index dcbb7e0c5..7abd135a5 100644 --- a/src/main/java/serverutils/command/tp/CmdDelHome.java +++ b/src/main/java/serverutils/command/tp/CmdDelHome.java @@ -36,7 +36,7 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE args = new String[] { "home" }; } - args[0] = args[0].toLowerCase(); + args[0] = args[0].toLowerCase(java.util.Locale.ROOT); if (data.homes.set(args[0], null)) { sender.addChatMessage(ServerUtilities.lang(sender, "serverutilities.lang.homes.del", args[0])); diff --git a/src/main/java/serverutils/command/tp/CmdDelWarp.java b/src/main/java/serverutils/command/tp/CmdDelWarp.java index af342ae4f..dad28211d 100644 --- a/src/main/java/serverutils/command/tp/CmdDelWarp.java +++ b/src/main/java/serverutils/command/tp/CmdDelWarp.java @@ -29,7 +29,7 @@ public List addTabCompletionOptions(ICommandSender sender, String[] args public void processCommand(ICommandSender sender, String[] args) throws CommandException { checkArgs(sender, args, 1); - args[0] = args[0].toLowerCase(); + args[0] = args[0].toLowerCase(java.util.Locale.ROOT); if (ServerUtilitiesUniverseData.WARPS.set(args[0], null)) { sender.addChatMessage(ServerUtilities.lang(sender, "serverutilities.lang.warps.del", args[0])); diff --git a/src/main/java/serverutils/command/tp/CmdSetHome.java b/src/main/java/serverutils/command/tp/CmdSetHome.java index 1f2c3506a..0d444a683 100644 --- a/src/main/java/serverutils/command/tp/CmdSetHome.java +++ b/src/main/java/serverutils/command/tp/CmdSetHome.java @@ -41,7 +41,7 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE args = new String[] { "home" }; } - args[0] = args[0].toLowerCase(); + args[0] = args[0].toLowerCase(java.util.Locale.ROOT); int maxHomes = RankConfigAPI.get(player, ServerUtilitiesPermissions.HOMES_MAX).getInt(); diff --git a/src/main/java/serverutils/command/tp/CmdSetWarp.java b/src/main/java/serverutils/command/tp/CmdSetWarp.java index 6892f9b6e..9b051fad5 100644 --- a/src/main/java/serverutils/command/tp/CmdSetWarp.java +++ b/src/main/java/serverutils/command/tp/CmdSetWarp.java @@ -33,7 +33,7 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE checkArgs(sender, args, 1); BlockDimPos pos; - args[0] = args[0].toLowerCase(); + args[0] = args[0].toLowerCase(java.util.Locale.ROOT); if (args.length == 2) { EntityPlayerMP targetPlayer = CommandUtils.getForgePlayer(sender, args[1]).getCommandPlayer(sender); diff --git a/src/main/java/serverutils/command/tp/CmdWarp.java b/src/main/java/serverutils/command/tp/CmdWarp.java index 0c7e7437b..e837c22e4 100644 --- a/src/main/java/serverutils/command/tp/CmdWarp.java +++ b/src/main/java/serverutils/command/tp/CmdWarp.java @@ -45,7 +45,7 @@ public List addTabCompletionOptions(ICommandSender sender, String[] args public void processCommand(ICommandSender sender, String[] args) throws CommandException { checkArgs(sender, args, 1); - args[0] = args[0].toLowerCase(); + args[0] = args[0].toLowerCase(java.util.Locale.ROOT); if (args[0].equals("list")) { Collection list = ServerUtilitiesUniverseData.WARPS.list(); diff --git a/src/main/java/serverutils/data/BackwardsCompat.java b/src/main/java/serverutils/data/BackwardsCompat.java index 138e0d702..9f12d425a 100644 --- a/src/main/java/serverutils/data/BackwardsCompat.java +++ b/src/main/java/serverutils/data/BackwardsCompat.java @@ -1,7 +1,7 @@ package serverutils.data; import java.io.File; -import java.io.FileInputStream; +import java.nio.file.Files; import java.util.Collection; import java.util.HashMap; import java.util.Map; @@ -62,7 +62,7 @@ public static void loadPlayers() { UUID uuid = StringUtils.fromString(tag1.getString("UUID")); if (uuid != null) { ForgePlayer player = new ForgePlayer(Universe.get(), uuid, tag1.getString("Name")); - Universe.get().players.put(uuid, player); + Universe.get().registerPlayer(uuid, player); player.lastTimeSeen = tag1.getCompoundTag("Stats").getLong("LastSeen"); // Load player homes from Latmod @@ -97,19 +97,19 @@ public static void loadChunks() { if (p != null) { // If player exists in the ClaimedChunks.json file, create a team for them - if (p.team.type == TeamType.NONE) { + if (p.getTeam().type == TeamType.NONE) { ForgeTeam team = new ForgeTeam( universe, universe.generateTeamUID((short) 0), p.getName(), TeamType.PLAYER); - team.owner = p; + team.initializeOwner(p); team.setColor(EnumTeamColor.NAME_MAP.getRandom(universe.world.rand)); universe.addTeam(team); - p.team = team; + p.setTeam(team); p.markDirty(); } - ServerUtilitiesTeamData data = ServerUtilitiesTeamData.get(p.team); + ServerUtilitiesTeamData data = ServerUtilitiesTeamData.get(p.getTeam()); JsonArray chunksList = e1.getValue().getAsJsonArray(); for (int k = 0; k < chunksList.size(); k++) { @@ -124,10 +124,10 @@ public static void loadChunks() { ClaimedChunks.instance.addChunk(c); } } - p.team.markDirty(); + p.getTeam().markDirty(); } } catch (Exception ex) { - ex.printStackTrace(); + ServerUtilities.LOGGER.error("Failed to import legacy claimed chunk data", ex); } } } @@ -143,11 +143,12 @@ public static void loadWarps() { if (warps != null) for (Map.Entry e : warps.entrySet()) { if (e.getValue().isJsonArray()) { int[] val = fromIntArray(e.getValue()); - ServerUtilitiesUniverseData.WARPS.set(e.getKey().toLowerCase(), BlockDimPos.fromIntArray(val)); + ServerUtilitiesUniverseData.WARPS + .set(e.getKey().toLowerCase(java.util.Locale.ROOT), BlockDimPos.fromIntArray(val)); } else { JsonObject o = e.getValue().getAsJsonObject(); ServerUtilitiesUniverseData.WARPS.set( - e.getKey().toLowerCase(), + e.getKey().toLowerCase(java.util.Locale.ROOT), new BlockDimPos( o.get("x").getAsInt(), o.get("y").getAsInt(), @@ -227,17 +228,14 @@ public static NBTTagCompound readMap(File f) { try { return CompressedStreamTools.read(f); } catch (Exception e) { - e.printStackTrace(); + ServerUtilities.LOGGER.warn("Failed to read legacy compressed data; trying the old format", e); ServerUtilities.LOGGER.info("Possibly corrupted / old file. Trying the old method"); try { - FileInputStream is = new FileInputStream(f); - byte[] b = new byte[is.available()]; - is.read(b); - is.close(); + byte[] b = Files.readAllBytes(f.toPath()); return CompressedStreamTools.func_152457_a(b, NBTSizeTracker.field_152451_a); } catch (Exception e1) { - e1.printStackTrace(); + ServerUtilities.LOGGER.error("Failed to read legacy data using either supported format", e1); } } return null; diff --git a/src/main/java/serverutils/data/BlockDimPosStorage.java b/src/main/java/serverutils/data/BlockDimPosStorage.java index e8c77658b..7c101e38a 100644 --- a/src/main/java/serverutils/data/BlockDimPosStorage.java +++ b/src/main/java/serverutils/data/BlockDimPosStorage.java @@ -59,7 +59,7 @@ public NBTTagCompound serializeNBT() { NBTTagCompound nbt = new NBTTagCompound(); for (Map.Entry entry : map.entrySet()) { - nbt.setIntArray(entry.getKey().toLowerCase(), entry.getValue().toIntArray()); + nbt.setIntArray(entry.getKey().toLowerCase(java.util.Locale.ROOT), entry.getValue().toIntArray()); } return nbt; @@ -74,7 +74,7 @@ public void deserializeNBT(NBTTagCompound nbt) { BlockDimPos pos = BlockDimPos.fromIntArray(nbt.getIntArray(name)); if (pos != null) { - map.put(name.toLowerCase(), pos); + map.put(name.toLowerCase(java.util.Locale.ROOT), pos); } } diff --git a/src/main/java/serverutils/data/ClaimedChunk.java b/src/main/java/serverutils/data/ClaimedChunk.java index cdfbd1440..9a80878ff 100644 --- a/src/main/java/serverutils/data/ClaimedChunk.java +++ b/src/main/java/serverutils/data/ClaimedChunk.java @@ -28,7 +28,7 @@ public boolean isInvalid() { public void setInvalid() { if (!invalid) { invalid = true; - getTeam().claimedChunks.remove(this); + getTeam().removeClaimedChunk(this); getTeam().markDirty(); } } diff --git a/src/main/java/serverutils/data/ClaimedChunks.java b/src/main/java/serverutils/data/ClaimedChunks.java index 1841d3e2a..1ee43ac28 100644 --- a/src/main/java/serverutils/data/ClaimedChunks.java +++ b/src/main/java/serverutils/data/ClaimedChunks.java @@ -169,7 +169,7 @@ public void removeChunk(ChunkDimPos pos) { public void addChunk(ClaimedChunk chunk) { pendingChunks.add(chunk); - chunk.getTeam().claimedChunks.add(chunk); + chunk.getTeam().addClaimedChunk(chunk); chunk.getTeam().markDirty(); markDirty(); } @@ -190,13 +190,13 @@ public Set getTeamChunks(@Nullable ForgeTeam team, OptionalInt dim Set set; if (dimension.isPresent()) { set = new HashSet<>(); - for (ClaimedChunk chunk : team.claimedChunks) { + for (ClaimedChunk chunk : team.getClaimedChunksView()) { if (chunk.getPos().dim == dimension.getAsInt()) { set.add(chunk); } } } else { - set = new HashSet<>(team.claimedChunks); + set = new HashSet<>(team.getClaimedChunksView()); } if (includePending) { @@ -302,7 +302,7 @@ public boolean canPlayerModify(ForgePlayer player, ChunkDimPos pos, String perm) return false; } - return player.hasTeam() && chunk.getTeam().equalsTeam(player.team) || perm.isEmpty() + return player.hasTeam() && chunk.getTeam().equalsTeam(player.getTeam()) || perm.isEmpty() || player.hasPermission(perm); } @@ -317,7 +317,7 @@ public ClaimResult claimChunk(ForgePlayer player, ChunkDimPos pos, boolean check return ClaimResult.DIMENSION_BLOCKED; } - ServerUtilitiesTeamData data = ServerUtilitiesTeamData.get(player.team); + ServerUtilitiesTeamData data = ServerUtilitiesTeamData.get(player.getTeam()); if (checkLimits && !player.hasPermission(ServerUtilitiesPermissions.CLAIMS_BYPASS_LIMITS)) { int max = data.getMaxClaimChunks(); diff --git a/src/main/java/serverutils/data/Leaderboard.java b/src/main/java/serverutils/data/Leaderboard.java index 60e5dbd2d..3aff3abda 100644 --- a/src/main/java/serverutils/data/Leaderboard.java +++ b/src/main/java/serverutils/data/Leaderboard.java @@ -55,7 +55,7 @@ public static class FromStat extends Leaderboard { public static final IntFunction DEFAULT = value -> new ChatComponentText( value <= 0 ? "0" : Integer.toString(value)); public static final DoubleFunction PERCENTAGE = value -> new ChatComponentText( - String.format("%.2f%%", value * 100.0)); + String.format(java.util.Locale.ROOT, "%.2f%%", value * 100.0)); public static final IntFunction TIME = value -> new ChatComponentText( "[" + (int) (value / 72000D + 0.5D) + "h] " + Ticks.get(value).toTimeString()); public static final LongFunction LONG_TIME = value -> new ChatComponentText( diff --git a/src/main/java/serverutils/data/ServerUtilitiesLoadedChunkManager.java b/src/main/java/serverutils/data/ServerUtilitiesLoadedChunkManager.java index 66e9171f3..074d5a85a 100644 --- a/src/main/java/serverutils/data/ServerUtilitiesLoadedChunkManager.java +++ b/src/main/java/serverutils/data/ServerUtilitiesLoadedChunkManager.java @@ -120,7 +120,7 @@ public void forceChunk(MinecraftServer server, ClaimedChunk chunk) { + ex); if (ServerUtilitiesConfig.debugging.print_more_errors) { - ex.printStackTrace(); + ServerUtilities.LOGGER.error("Failed to load a forced chunk", ex); } } diff --git a/src/main/java/serverutils/data/ServerUtilitiesPlayerData.java b/src/main/java/serverutils/data/ServerUtilitiesPlayerData.java index 624d0dba6..39b364f86 100644 --- a/src/main/java/serverutils/data/ServerUtilitiesPlayerData.java +++ b/src/main/java/serverutils/data/ServerUtilitiesPlayerData.java @@ -119,7 +119,7 @@ public void addConfig(ConfigGroup main) { && player.hasPermission(ServerUtilitiesPermissions.CHAT_NICKNAME_SET)); IChatComponent info = new ChatComponentTranslation( "player_config.serverutilities.show_team_prefix.info", - player.team.getTitle()); + player.getTeam().getTitle()); config.addBool("show_team_prefix", () -> showTeamPrefix, v -> showTeamPrefix = v, false).setInfo(info) .setExcluded(ServerUtilitiesConfig.teams.force_team_prefix); @@ -227,7 +227,8 @@ public IChatComponent getNameForChat() { if (ServerUtilitiesConfig.teams.force_team_prefix || showTeamPrefix) { IChatComponent end = new ChatComponentText("] "); - IChatComponent prefix = new ChatComponentText("[").appendSibling(player.team.getTitle()).appendSibling(end); + IChatComponent prefix = new ChatComponentText("[").appendSibling(player.getTeam().getTitle()) + .appendSibling(end); cachedNameForChat = new ChatComponentText("").appendSibling(prefix).appendSibling(cachedNameForChat); } diff --git a/src/main/java/serverutils/data/ServerUtilitiesUniverseData.java b/src/main/java/serverutils/data/ServerUtilitiesUniverseData.java index 915756a06..9dfdec475 100644 --- a/src/main/java/serverutils/data/ServerUtilitiesUniverseData.java +++ b/src/main/java/serverutils/data/ServerUtilitiesUniverseData.java @@ -157,7 +157,7 @@ public static void onUniverseSaved(UniverseSavedEvent event) { out.println(s); } } catch (IOException ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to append the world log", ex); } return false; @@ -178,7 +178,7 @@ public static void onUniverseSaved(UniverseSavedEvent event) { out.println(s); } } catch (IOException ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to append the chat log", ex); } return false; diff --git a/src/main/java/serverutils/events/ServerUtilitiesPreInitRegistryEvent.java b/src/main/java/serverutils/events/ServerUtilitiesPreInitRegistryEvent.java index 957186c50..0787e6ccd 100644 --- a/src/main/java/serverutils/events/ServerUtilitiesPreInitRegistryEvent.java +++ b/src/main/java/serverutils/events/ServerUtilitiesPreInitRegistryEvent.java @@ -8,7 +8,7 @@ import serverutils.lib.data.TeamAction; /** - * Deprecated. Use {@link serverutils.ServerUtilitiesRegistry} instead. + * Deprecated. Use {@link serverutils.api.ServerUtilitiesRegistry} instead. */ @Deprecated public class ServerUtilitiesPreInitRegistryEvent extends ServerUtilitiesEvent { diff --git a/src/main/java/serverutils/events/player/ForgePlayerEvent.java b/src/main/java/serverutils/events/player/ForgePlayerEvent.java index 12a174b8b..fa3145a34 100644 --- a/src/main/java/serverutils/events/player/ForgePlayerEvent.java +++ b/src/main/java/serverutils/events/player/ForgePlayerEvent.java @@ -8,7 +8,7 @@ public abstract class ForgePlayerEvent extends ForgeTeamEvent { private final ForgePlayer player; public ForgePlayerEvent(ForgePlayer p) { - super(p.team); + super(p.getTeam()); player = p; } diff --git a/src/main/java/serverutils/handlers/ServerUtilitiesPlayerEventHandler.java b/src/main/java/serverutils/handlers/ServerUtilitiesPlayerEventHandler.java index 601d2d614..36d145153 100644 --- a/src/main/java/serverutils/handlers/ServerUtilitiesPlayerEventHandler.java +++ b/src/main/java/serverutils/handlers/ServerUtilitiesPlayerEventHandler.java @@ -76,7 +76,7 @@ public static void onPlayerLoggedIn(ForgePlayerLoggedInEvent event) { ClaimedChunks.instance.markDirty(); } - ForgeTeam team = event.getPlayer().team; + ForgeTeam team = event.getPlayer().getTeam(); ServerUtilitiesTeamData data = ServerUtilitiesTeamData.get(team); if (team.isValid()) { @@ -198,7 +198,7 @@ private static String getDim(EntityPlayer player) { } private static String getPos(int x, int y, int z) { - return String.format("[%d, %d, %d]", x, y, z); + return String.format(java.util.Locale.ROOT, "[%d, %d, %d]", x, y, z); } private static String getHeldItemName(EntityPlayer player) { @@ -213,6 +213,7 @@ public static void onBlockBreakLog(BlockEvent.BreakEvent event) { if (ServerUtilitiesConfig.world.logging.block_broken && ServerUtilitiesConfig.world.logging.log(playerMP)) { ServerUtilitiesUniverseData.worldLog( String.format( + java.util.Locale.ROOT, "%s broke %s at %s in %s", playerMP.getCommandSenderName(), getStateName(event.world, event.x, event.y, event.z), @@ -228,6 +229,7 @@ public static void onBlockPlaceLog(BlockEvent.PlaceEvent event) { if (ServerUtilitiesConfig.world.logging.block_placed && ServerUtilitiesConfig.world.logging.log(playerMP)) { ServerUtilitiesUniverseData.worldLog( String.format( + java.util.Locale.ROOT, "%s placed %s at %s in %s", playerMP.getCommandSenderName(), getStateName(event.world, event.x, event.y, event.z), @@ -245,6 +247,7 @@ public static void onRightClickItemLog(PlayerInteractEvent event) { && ServerUtilitiesConfig.world.logging.log(playerMP)) { ServerUtilitiesUniverseData.worldLog( String.format( + java.util.Locale.ROOT, "%s clicked %s in air at %s in %s", playerMP.getCommandSenderName(), getHeldItemName(playerMP), @@ -263,6 +266,7 @@ public static void onEntityAttackedLog(AttackEntityEvent event) { if (print) { ServerUtilitiesUniverseData.worldLog( String.format( + java.util.Locale.ROOT, "%s attacked %s with %s at %s in %s", playerMP.getCommandSenderName(), target.getCommandSenderName(), diff --git a/src/main/java/serverutils/handlers/ServerUtilitiesServerEventHandler.java b/src/main/java/serverutils/handlers/ServerUtilitiesServerEventHandler.java index ac4b74d53..e43cbefea 100644 --- a/src/main/java/serverutils/handlers/ServerUtilitiesServerEventHandler.java +++ b/src/main/java/serverutils/handlers/ServerUtilitiesServerEventHandler.java @@ -324,7 +324,8 @@ public static void onWorldTick(TickEvent.WorldTickEvent event) { @SubscribeEvent public static void onServerChatEventLog(ServerChatEvent event) { if (ServerUtilitiesConfig.world.logging.chat_enable) { - ServerUtilitiesUniverseData.chatLog(String.format("From %s: %s", event.username, event.message)); + ServerUtilitiesUniverseData + .chatLog(String.format(java.util.Locale.ROOT, "From %s: %s", event.username, event.message)); } } } diff --git a/src/main/java/serverutils/handlers/ServerUtilitiesWorldEventHandler.java b/src/main/java/serverutils/handlers/ServerUtilitiesWorldEventHandler.java index e86310273..593e43f9a 100644 --- a/src/main/java/serverutils/handlers/ServerUtilitiesWorldEventHandler.java +++ b/src/main/java/serverutils/handlers/ServerUtilitiesWorldEventHandler.java @@ -79,7 +79,7 @@ private static boolean isEntityAllowed(Entity entity) { String[] mobTypes = ServerUtilitiesConfig.world.mobTypesToBlock; if (mobTypes.length == 0) return false; for (String string : mobTypes) { - EnumCreature creature = EnumCreature.NAME_MAP.getNullable(string.toLowerCase()); + EnumCreature creature = EnumCreature.NAME_MAP.getNullable(string.toLowerCase(java.util.Locale.ROOT)); if (creature != null && creature.creatureType.getCreatureClass().isAssignableFrom(entity.getClass())) { return false; } diff --git a/src/main/java/serverutils/invsee/inventories/BaublesInventory.java b/src/main/java/serverutils/invsee/inventories/BaublesInventory.java index f0fa2068d..0f3712765 100644 --- a/src/main/java/serverutils/invsee/inventories/BaublesInventory.java +++ b/src/main/java/serverutils/invsee/inventories/BaublesInventory.java @@ -121,11 +121,11 @@ public void saveInventory(ForgePlayer player, IInventory inventory) { private @Nullable File getBaublesFile(ForgePlayer player) { File baublesFile = new File( - player.team.universe.getWorldDirectory(), + player.getUniverse().getWorldDirectory(), "playerdata/" + player.getName() + ".baub"); if (!baublesFile.exists()) { baublesFile = new File( - player.team.universe.getWorldDirectory(), + player.getUniverse().getWorldDirectory(), "playerdata/" + player.getName() + ".baubback"); } if (!baublesFile.exists()) return null; diff --git a/src/main/java/serverutils/invsee/inventories/InvSeeRegistry.java b/src/main/java/serverutils/invsee/inventories/InvSeeRegistry.java index 65b8278bc..1bd823622 100644 --- a/src/main/java/serverutils/invsee/inventories/InvSeeRegistry.java +++ b/src/main/java/serverutils/invsee/inventories/InvSeeRegistry.java @@ -43,7 +43,10 @@ private enum DefaultInventories { if ((modId == null || Loader.isModLoaded(modId)) && (isLoaded == null || isLoaded.get())) { try { registerInventory(inventory.getDeclaredConstructor().newInstance()); - } catch (Exception ignored) {} + } catch (ReflectiveOperationException ex) { + serverutils.ServerUtilities.LOGGER + .warn("Failed to register inventory integration " + inventory.getName(), ex); + } } } } diff --git a/src/main/java/serverutils/lib/EnumDyeColor.java b/src/main/java/serverutils/lib/EnumDyeColor.java index 378b9637f..ee5f177df 100644 --- a/src/main/java/serverutils/lib/EnumDyeColor.java +++ b/src/main/java/serverutils/lib/EnumDyeColor.java @@ -46,7 +46,7 @@ public enum EnumDyeColor { ID = ordinal(); name = ItemDye.field_150921_b[ID]; unlocalizedName = ItemDye.field_150923_a[ID]; - lang = "serverutilities.color." + s.toLowerCase(); + lang = "serverutilities.color." + s.toLowerCase(java.util.Locale.ROOT); color = ItemDye.field_150922_c[ID]; colorBright = c; chatFormatting = f; diff --git a/src/main/java/serverutils/lib/command/CommandUtils.java b/src/main/java/serverutils/lib/command/CommandUtils.java index 80b7e8673..d47de7428 100644 --- a/src/main/java/serverutils/lib/command/CommandUtils.java +++ b/src/main/java/serverutils/lib/command/CommandUtils.java @@ -155,7 +155,7 @@ public static OptionalInt parseDimension(ICommandSender sender, String[] args, i return OptionalInt.empty(); } - return switch (args[index].toLowerCase()) { + return switch (args[index].toLowerCase(java.util.Locale.ROOT)) { case "overworld", "0" -> OptionalInt.of(0); case "nether", "-1" -> OptionalInt.of(-1); case "end", "1" -> OptionalInt.of(1); diff --git a/src/main/java/serverutils/lib/config/ConfigColor.java b/src/main/java/serverutils/lib/config/ConfigColor.java index e56885d76..be1c3ed78 100644 --- a/src/main/java/serverutils/lib/config/ConfigColor.java +++ b/src/main/java/serverutils/lib/config/ConfigColor.java @@ -93,7 +93,9 @@ public boolean setValueFromString(@Nullable ICommandSender sender, String string return true; } - } catch (Exception ex) {} + } catch (NumberFormatException ignored) { + // Invalid user input is reported through the false return value. + } return false; } diff --git a/src/main/java/serverutils/lib/config/EnumTristate.java b/src/main/java/serverutils/lib/config/EnumTristate.java index 5fdd7544b..d9fd0cb3e 100644 --- a/src/main/java/serverutils/lib/config/EnumTristate.java +++ b/src/main/java/serverutils/lib/config/EnumTristate.java @@ -10,7 +10,7 @@ public enum EnumTristate implements IStringSerializable { @Override public String getName() { - return name().toLowerCase(); + return name().toLowerCase(java.util.Locale.ROOT); } public boolean isTrue() { diff --git a/src/main/java/serverutils/lib/data/Action.java b/src/main/java/serverutils/lib/data/Action.java index e09aa9be5..c1f58b502 100644 --- a/src/main/java/serverutils/lib/data/Action.java +++ b/src/main/java/serverutils/lib/data/Action.java @@ -54,7 +54,7 @@ private Inst(DataIn data) { public Inst(Action action, Action.Type t) { id = action.getId(); title = action.getTitle(); - requiresConfirm = action.getRequireConfirm(); + requiresConfirm = action.requiresConfirmation(); icon = action.getIcon(); enabled = t.isEnabled(); order = action.getOrder(); @@ -82,12 +82,12 @@ public int compareTo(Inst o) { private Icon icon; private int order; - public Action(ResourceLocation _id, IChatComponent t, Icon i, int o) { - id = _id; - title = t; + public Action(ResourceLocation id, IChatComponent title, Icon icon, int order) { + this.id = id; + this.title = title; requiresConfirm = false; - icon = i; - order = o; + this.icon = icon; + this.order = order; } public final ResourceLocation getId() { @@ -98,8 +98,8 @@ public final ResourceLocation getId() { public abstract void onAction(ForgePlayer player, NBTTagCompound data); - public Action setTitle(IChatComponent t) { - title = t; + public Action setTitle(IChatComponent title) { + this.title = title; return this; } @@ -112,12 +112,17 @@ public Action setRequiresConfirm() { return this; } + @Deprecated public boolean getRequireConfirm() { + return requiresConfirmation(); + } + + public boolean requiresConfirmation() { return requiresConfirm; } - public Action setIcon(Icon i) { - icon = i; + public Action setIcon(Icon icon) { + this.icon = icon; return this; } @@ -125,8 +130,8 @@ public Icon getIcon() { return icon; } - public Action setOrder(int o) { - order = MathHelper.clamp_int(o, Short.MIN_VALUE, Short.MAX_VALUE); + public Action setOrder(int order) { + this.order = MathHelper.clamp_int(order, Short.MIN_VALUE, Short.MAX_VALUE); return this; } @@ -138,8 +143,13 @@ public final int hashCode() { return id.hashCode(); } - public final boolean equals(Object o) { - return o == this; + /** Actions use instance identity for equality; IDs are compared explicitly through {@link #hasSameId(Action)}. */ + public final boolean equals(Object other) { + return other == this; + } + + public final boolean hasSameId(Action other) { + return other != null && id.equals(other.id); } public final String toString() { diff --git a/src/main/java/serverutils/lib/data/ForgePlayer.java b/src/main/java/serverutils/lib/data/ForgePlayer.java index 772819e42..e621b7d3e 100644 --- a/src/main/java/serverutils/lib/data/ForgePlayer.java +++ b/src/main/java/serverutils/lib/data/ForgePlayer.java @@ -4,6 +4,7 @@ import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.InputStream; +import java.util.Objects; import java.util.UUID; import javax.annotation.Nullable; @@ -53,17 +54,23 @@ public class ForgePlayer implements INBTSerializable, Comparable private static FakePlayer playerForStats; + private final Universe universe; public GameProfile profile; private final NBTDataStorage dataStorage; + /** @deprecated Use {@link #getTeam()} and {@link #setTeam(ForgeTeam)}. */ + @Deprecated public ForgeTeam team; private boolean hideTeamNotification; public NBTTagCompound cachedPlayerNBT; private ConfigGroup cachedConfig; public long lastTimeSeen; + /** @deprecated Use {@link #isDirty()}, {@link #markDirty()}, and {@link #markSaved()}. */ + @Deprecated public boolean needsSaving; public EntityPlayerMP tempPlayer; public ForgePlayer(Universe u, GameProfile p) { + universe = u; profile = p; dataStorage = new NBTDataStorage(); team = u.getTeam(""); @@ -100,7 +107,59 @@ public void clearCache() { public void markDirty() { needsSaving = true; - team.universe.checkSaving = true; + universe.markChildDirty(); + } + + public boolean isDirty() { + return needsSaving; + } + + public void markSaved() { + needsSaving = false; + } + + public ForgeTeam getTeam() { + return team; + } + + public Universe getUniverse() { + return universe; + } + + public void setTeam(ForgeTeam team) { + ForgeTeam nextTeam = requireOwnedTeam(team); + if (this.team == nextTeam) { + return; + } + + ForgeTeam previousTeam = this.team; + this.team = nextTeam; + clearCache(); + if (previousTeam != null) { + previousTeam.clearCache(); + } + nextTeam.clearCache(); + universe.clearCache(); + markDirty(); + + if (previousTeam != null && previousTeam.isValid()) { + previousTeam.markDirty(); + } + if (nextTeam.isValid()) { + nextTeam.markDirty(); + } + } + + void setTeamFromLoad(ForgeTeam team) { + this.team = requireOwnedTeam(team); + } + + private ForgeTeam requireOwnedTeam(ForgeTeam team) { + ForgeTeam ownedTeam = Objects.requireNonNull(team, "team"); + if (ownedTeam.universe != universe) { + throw new IllegalArgumentException("Player and team belong to different universes"); + } + return ownedTeam; } public boolean hasTeam() { @@ -123,7 +182,9 @@ public final String getDisplayNameString() { if (isOnline()) { try { return getPlayer().getDisplayName(); - } catch (Exception ignored) {} + } catch (RuntimeException ex) { + ServerUtilities.LOGGER.debug("Failed to read the online display name for " + getName(), ex); + } } return getName(); @@ -134,7 +195,9 @@ public final IChatComponent getDisplayName() { try { return new ChatComponentText(getDisplayNameString()); - } catch (Exception ignored) {} + } catch (RuntimeException ex) { + ServerUtilities.LOGGER.debug("Failed to build the online display name for " + getName(), ex); + } } return new ChatComponentText(getName()); @@ -185,7 +248,7 @@ public boolean canInteract(@Nullable ForgePlayer owner, EnumPrivacyLevel level) } else if (level == EnumPrivacyLevel.PRIVATE) { return false; } else if (level == EnumPrivacyLevel.TEAM) { - return owner.team.isAlly(this); + return owner.getTeam().isAlly(this); } return false; @@ -246,7 +309,7 @@ void onLoggedIn(EntityPlayerMP player, Universe universe, boolean firstLogin) { sendTeamJoinEvent = true; } else { - String id = getName().toLowerCase(); + String id = getName().toLowerCase(java.util.Locale.ROOT); if (universe.getTeam(id).isValid()) { id = StringUtils.fromUUID(getId()); @@ -254,7 +317,7 @@ void onLoggedIn(EntityPlayerMP player, Universe universe, boolean firstLogin) { if (!universe.getTeam(id).isValid()) { team = new ForgeTeam(universe, universe.generateTeamUID((short) 0), id, TeamType.PLAYER); - team.owner = this; + team.initializeOwner(this); universe.addTeam(team); team.setColor(EnumTeamColor.NAME_MAP.getRandom(universe.world.rand)); team.markDirty(); @@ -359,7 +422,7 @@ public NBTTagCompound getPlayerNBT() { new File(team.universe.getWorldDirectory(), "playerdata/" + getId() + ".dat"))) { cachedPlayerNBT = CompressedStreamTools.readCompressed(stream); } catch (Exception ex) { - ex.printStackTrace(); + ServerUtilities.LOGGER.error("Failed to read player data for " + getId(), ex); } } @@ -383,7 +446,7 @@ public void setPlayerNBT(NBTTagCompound nbt) { new File(team.universe.getWorldDirectory(), "playerdata/" + getId() + ".dat"))) { CompressedStreamTools.writeCompressed(nbt, stream); } catch (Exception ex) { - ex.printStackTrace(); + ServerUtilities.LOGGER.error("Failed to write player data for " + getId(), ex); } } @@ -421,7 +484,7 @@ public ConfigValue getRankConfig(String node) { public File getDataFile() { File dir = new File(team.universe.dataFolder, "players/"); - return new File(dir, getName().toLowerCase() + ".dat"); + return new File(dir, getName().toLowerCase(java.util.Locale.ROOT) + ".dat"); } @Override diff --git a/src/main/java/serverutils/lib/data/ForgeTeam.java b/src/main/java/serverutils/lib/data/ForgeTeam.java index b3691d4d6..097df2c8f 100644 --- a/src/main/java/serverutils/lib/data/ForgeTeam.java +++ b/src/main/java/serverutils/lib/data/ForgeTeam.java @@ -4,7 +4,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -19,19 +18,15 @@ import net.minecraft.event.ClickEvent; import net.minecraft.event.HoverEvent; import net.minecraft.nbt.NBTTagCompound; -import net.minecraft.nbt.NBTTagList; -import net.minecraft.nbt.NBTTagString; import net.minecraft.util.ChatComponentText; import net.minecraft.util.ChatComponentTranslation; import net.minecraft.util.EnumChatFormatting; import net.minecraft.util.IChatComponent; -import net.minecraftforge.common.util.Constants; import serverutils.ServerUtilities; import serverutils.data.ClaimedChunk; import serverutils.events.team.ForgeTeamConfigEvent; import serverutils.events.team.ForgeTeamConfigSavedEvent; -import serverutils.events.team.ForgeTeamDataEvent; import serverutils.events.team.ForgeTeamOwnerChangedEvent; import serverutils.events.team.ForgeTeamPlayerJoinedEvent; import serverutils.events.team.ForgeTeamPlayerLeftEvent; @@ -54,42 +49,46 @@ public class ForgeTeam extends FinalIDObject implements INBTSerializable requestingInvite; + private final ForgeTeamPersistence persistence; + private final ForgeTeamMembership membership; + /** @deprecated Use status and membership methods or {@link #getPlayerStatusesView()}. */ + @Deprecated public final Map players; private ConfigGroup cachedConfig; private IChatComponent cachedTitle; private Icon cachedIcon; + /** @deprecated Use {@link #isDirty()}, {@link #markDirty()}, and {@link #markSaved()}. */ + @Deprecated public boolean needsSaving; - private long lastActivity; + /** @deprecated Use claimed-chunk mutation methods and {@link #getClaimedChunksView()}. */ + @Deprecated public final Set claimedChunks = new HashSet<>(); public ForgeTeam(Universe u, short id, String n, TeamType t) { + this(u, id, n, t, true); + } + + ForgeTeam(Universe u, short id, String n, TeamType t, boolean initializePersistence) { super(n, t.isNone ? 0 : (StringUtils.FLAG_ID_DEFAULTS | StringUtils.FLAG_ID_ALLOW_EMPTY)); uid = id; universe = u; type = t; - title = ""; - desc = ""; - color = EnumTeamColor.BLUE; - icon = ""; - freeToJoin = false; - fakePlayerStatus = EnumTeamStatus.ALLY; - requestingInvite = new HashSet<>(); - players = new HashMap<>(); - dataStorage = new NBTDataStorage(); - new ForgeTeamDataEvent(this, dataStorage).post(); + membership = new ForgeTeamMembership(); + players = membership.mutableStatuses(); + persistence = new ForgeTeamPersistence(); + if (initializePersistence) { + persistence.initialize(this); + } clearCache(); cachedIcon = null; needsSaving = false; - lastActivity = 0L; + } + + void initializePersistence() { + persistence.initialize(this); } public final short getUID() { @@ -97,124 +96,124 @@ public final short getUID() { } public final int hashCode() { - return uid; + return 31 * System.identityHashCode(universe) + uid; } public final boolean equals(Object o) { - return o == this || uid == Objects.hashCode(o); + return o == this || o instanceof ForgeTeam other && universe == other.universe && uid == other.uid; } public final String getUIDCode() { - return String.format("%04X", uid); + return String.format(java.util.Locale.ROOT, "%04X", uid); } @Override public NBTTagCompound serializeNBT() { - NBTTagCompound nbt = new NBTTagCompound(); - if (owner != null) { - nbt.setString("Owner", owner.getName()); - } - - nbt.setString("Title", title); - nbt.setString("Desc", desc); - nbt.setString("Color", EnumTeamColor.NAME_MAP.getName(color)); - nbt.setString("Icon", icon); - nbt.setBoolean("FreeToJoin", freeToJoin); - nbt.setString("FakePlayerStatus", EnumTeamStatus.NAME_MAP_PERMS.getName(fakePlayerStatus)); - nbt.setLong("LastActivity", lastActivity); - - NBTTagCompound nbt1 = new NBTTagCompound(); + return persistence.serialize(this, membership); + } - if (!players.isEmpty()) { - for (Map.Entry entry : players.entrySet()) { - nbt1.setString(entry.getKey().getName(), entry.getValue().getName()); - } - } + @Override + public void deserializeNBT(NBTTagCompound nbt) { + persistence.deserialize(this, membership, nbt); + } - nbt.setTag("Players", nbt1); + public void clearCache() { + cachedTitle = null; + cachedIcon = null; + cachedConfig = null; + persistence.clearCache(); + } - NBTTagList list = new NBTTagList(); + public void markDirty() { + needsSaving = true; + universe.markChildDirty(); + } - for (ForgePlayer player : requestingInvite) { - list.appendTag(new NBTTagString(player.getName())); - } + public boolean isDirty() { + return needsSaving; + } - nbt.setTag("RequestingInvite", list); - nbt.setTag("Data", dataStorage.serializeNBT()); - return nbt; + public void markSaved() { + needsSaving = false; } - @Override - public void deserializeNBT(NBTTagCompound nbt) { - owner = universe.getPlayer(nbt.getString("Owner")); + public void initializeOwner(ForgePlayer player) { + Objects.requireNonNull(player, "player"); + if (!type.isPlayer) { + throw new IllegalStateException("Only player teams can have an owner"); + } + requireSameUniverse(player); + if (owner != null && owner != player) { + throw new IllegalStateException("Team owner has already been initialized"); + } - if (!isValid()) { + if (player.getTeam() != this) { + player.setTeam(this); + } + if (owner == player) { return; } - title = nbt.getString("Title"); - desc = nbt.getString("Desc"); - color = EnumTeamColor.NAME_MAP.get(nbt.getString("Color")); - icon = nbt.getString("Icon"); - freeToJoin = nbt.getBoolean("FreeToJoin"); - fakePlayerStatus = EnumTeamStatus.NAME_MAP_PERMS.get(nbt.getString("FakePlayerStatus")); - lastActivity = nbt.getLong("LastActivity"); - - players.clear(); - - if (nbt.hasKey("Players")) { - NBTTagCompound nbt1 = nbt.getCompoundTag("Players"); - - for (String s : nbt1.func_150296_c()) { - ForgePlayer player = universe.getPlayer(s); + owner = player; + universe.clearCache(); + player.markDirty(); + markDirty(); + } - if (player != null) { - EnumTeamStatus status = EnumTeamStatus.NAME_MAP.get(nbt1.getString(s)); + ForgePlayer getStoredOwner() { + return owner; + } - if (status.canBeSet()) { - setStatus(player, status); - } - } - } + void setStoredOwner(@Nullable ForgePlayer player) { + if (player != null) { + requireSameUniverse(player); } + owner = player; + } - NBTTagList list = nbt.getTagList("RequestingInvite", Constants.NBT.TAG_STRING); - - for (int i = 0; i < list.tagCount(); i++) { - ForgePlayer player = universe.getPlayer(list.getStringTagAt(i)); - - if (player != null && !isMember(player)) { - setRequestingInvite(player, true); - } + private void requireSameUniverse(ForgePlayer player) { + if (player.getUniverse() != universe) { + throw new IllegalArgumentException("Player and team belong to different universes"); } + } - list = nbt.getTagList("Invited", Constants.NBT.TAG_STRING); + public Map getPlayerStatusesView() { + return membership.statusesView(); + } - for (int i = 0; i < list.tagCount(); i++) { - ForgePlayer player = universe.getPlayer(list.getStringTagAt(i)); + public Set getClaimedChunksView() { + return Collections.unmodifiableSet(claimedChunks); + } - if (player != null && !isMember(player)) { - setStatus(player, EnumTeamStatus.INVITED); - } + public boolean addClaimedChunk(ClaimedChunk chunk) { + requireOwnedChunk(chunk); + if (claimedChunks.add(chunk)) { + clearCache(); + markDirty(); + return true; } - - dataStorage.deserializeNBT(nbt.getCompoundTag("Data")); + return false; } - public void clearCache() { - cachedTitle = null; - cachedIcon = null; - cachedConfig = null; - dataStorage.clearCache(); + public boolean removeClaimedChunk(ClaimedChunk chunk) { + requireOwnedChunk(chunk); + if (claimedChunks.remove(chunk)) { + clearCache(); + markDirty(); + return true; + } + return false; } - public void markDirty() { - needsSaving = true; - universe.checkSaving = true; + private void requireOwnedChunk(ClaimedChunk chunk) { + Objects.requireNonNull(chunk, "chunk"); + if (chunk.getTeam() != this) { + throw new IllegalArgumentException("Claimed chunk belongs to a different team"); + } } public NBTDataStorage getData() { - return dataStorage; + return persistence.data(); } @Nullable @@ -227,11 +226,11 @@ public IChatComponent getTitle() { return cachedTitle; } - if (title.isEmpty()) { + if (persistence.title().isEmpty()) { cachedTitle = getOwner() != null ? getOwner().getDisplayName().appendText("'s Team") : new ChatComponentTranslation("serverutilities.lang.team.no_team"); } else { - cachedTitle = new ChatComponentText(title); + cachedTitle = new ChatComponentText(persistence.title()); } cachedTitle = StringUtils.color(cachedTitle, getColor().getEnumChatFormatting()); @@ -254,44 +253,47 @@ public IChatComponent getCommandTitle() { } public void setTitle(String s) { - if (!title.equals(s)) { - title = s; + if (!persistence.title().equals(s)) { + persistence.title(s); + cachedTitle = null; markDirty(); } } public String getDesc() { - return desc; + return persistence.description(); } public void setDesc(String s) { - if (!desc.equals(s)) { - desc = s; + if (!persistence.description().equals(s)) { + persistence.description(s); markDirty(); } } public EnumTeamColor getColor() { - return color; + return persistence.color(); } public void setColor(EnumTeamColor col) { - if (color != col) { - color = col; + if (persistence.color() != col) { + persistence.color(col); + cachedTitle = null; + cachedIcon = null; markDirty(); } } public Icon getIcon() { if (cachedIcon == null) { - if (icon.isEmpty()) { + if (persistence.icon().isEmpty()) { if (getOwner() != null) { cachedIcon = new PlayerHeadIcon(getOwner().getProfile().getId()); } else { cachedIcon = getColor().getColor(); } } else { - cachedIcon = Icon.getIcon(icon); + cachedIcon = Icon.getIcon(persistence.icon()); } } @@ -299,32 +301,33 @@ public Icon getIcon() { } public void setIcon(String s) { - if (!icon.equals(s)) { - icon = s; + if (!persistence.icon().equals(s)) { + persistence.icon(s); + cachedIcon = null; markDirty(); } } public boolean isFreeToJoin() { - return freeToJoin; + return persistence.freeToJoin(); } public void setFreeToJoin(boolean b) { - if (freeToJoin != b) { - freeToJoin = b; + if (persistence.freeToJoin() != b) { + persistence.freeToJoin(b); markDirty(); } } public EnumTeamStatus getFakePlayerStatus(ForgePlayer player) { - return fakePlayerStatus; + return persistence.fakePlayerStatus(); } public EnumTeamStatus getHighestStatus(@Nullable ForgePlayer player) { if (player == null) { return EnumTeamStatus.NONE; } else if (player.isFake()) { - return fakePlayerStatus; + return persistence.fakePlayerStatus(); } else if (isOwner(player)) { return EnumTeamStatus.OWNER; } else if (isModerator(player)) { @@ -346,12 +349,12 @@ private EnumTeamStatus getSetStatus(@Nullable ForgePlayer player) { if (player == null || !isValid()) { return EnumTeamStatus.NONE; } else if (player.isFake()) { - return fakePlayerStatus; + return persistence.fakePlayerStatus(); } else if (type == TeamType.SERVER && getId().equals("singleplayer")) { return EnumTeamStatus.MOD; } - EnumTeamStatus status = players.get(player); + EnumTeamStatus status = membership.getStatus(player); return status == null ? EnumTeamStatus.NONE : status; } @@ -388,7 +391,7 @@ public boolean setStatus(@Nullable ForgePlayer player, EnumTeamStatus status) { universe.clearCache(); ForgePlayer oldOwner = getOwner(); owner = player; - players.remove(player); + membership.removeStatus(player); new ForgeTeamOwnerChangedEvent(this, oldOwner).post(); if (oldOwner != null) { @@ -402,13 +405,13 @@ public boolean setStatus(@Nullable ForgePlayer player, EnumTeamStatus status) { return false; } else if (!status.isNone() && status.canBeSet()) { - if (players.put(player, status) != status) { + if (membership.putStatus(player, status) != status) { universe.clearCache(); player.markDirty(); markDirty(); return true; } - } else if (players.remove(player) != status) { + } else if (membership.removeStatus(player) != status) { universe.clearCache(); player.markDirty(); markDirty(); @@ -440,9 +443,9 @@ public boolean addMember(ForgePlayer player, boolean simulate) { if (isValid() && ((isOwner(player) || isInvited(player)) && !isMember(player))) { if (!simulate) { universe.clearCache(); - player.team = this; - players.remove(player); - requestingInvite.remove(player); + player.setTeam(this); + membership.removeStatus(player); + membership.removeInviteRequest(player); ForgeTeamPlayerJoinedEvent event = new ForgeTeamPlayerJoinedEvent(player); event.post(); @@ -466,7 +469,7 @@ public boolean removeMember(ForgePlayer player) { return false; } else if (getMembers().size() == 1) { universe.clearCache(); - new ForgeTeamPlayerLeftEvent(player).post(); + postPlayerLeftEvent(player); if (type.isPlayer) { delete(); @@ -474,22 +477,27 @@ public boolean removeMember(ForgePlayer player) { setStatus(player, EnumTeamStatus.NONE); } - player.team = universe.getTeam(""); + player.setTeam(universe.getTeam("")); player.markDirty(); markDirty(); + return true; } else if (isOwner(player)) { return false; } universe.clearCache(); - new ForgeTeamPlayerLeftEvent(player).post(); - player.team = universe.getTeam(""); + postPlayerLeftEvent(player); + player.setTeam(universe.getTeam("")); setStatus(player, EnumTeamStatus.NONE); player.markDirty(); markDirty(); return true; } + void postPlayerLeftEvent(ForgePlayer player) { + new ForgeTeamPlayerLeftEvent(player).post(); + } + public void delete() { universe.removeTeam(this); } @@ -502,10 +510,10 @@ public boolean isMember(@Nullable ForgePlayer player) { if (player == null) { return false; } else if (player.isFake()) { - return fakePlayerStatus.isEqualOrGreaterThan(EnumTeamStatus.MEMBER); + return persistence.fakePlayerStatus().isEqualOrGreaterThan(EnumTeamStatus.MEMBER); } - return isValid() && equalsTeam(player.team); + return isValid() && equalsTeam(player.getTeam()); } public boolean isAlly(@Nullable ForgePlayer player) { @@ -521,12 +529,12 @@ public boolean isInvited(@Nullable ForgePlayer player) { public boolean setRequestingInvite(@Nullable ForgePlayer player, boolean value) { if (player != null && isValid()) { if (value) { - if (requestingInvite.add(player)) { + if (membership.addInviteRequest(player)) { player.markDirty(); markDirty(); return true; } - } else if (requestingInvite.remove(player)) { + } else if (membership.removeInviteRequest(player)) { player.markDirty(); markDirty(); return true; @@ -541,7 +549,7 @@ public boolean setRequestingInvite(@Nullable ForgePlayer player, boolean value) public boolean isRequestingInvite(@Nullable ForgePlayer player) { return player != null && isValid() && !isMember(player) - && requestingInvite.contains(player) + && membership.isRequestingInvite(player) && !isEnemy(player); } @@ -571,17 +579,17 @@ public ConfigGroup getSettings() { ConfigGroup main = cachedConfig.getGroup(ServerUtilities.MOD_ID); main.setDisplayName(new ChatComponentText(ServerUtilities.MOD_NAME)); - main.addBool("free_to_join", () -> freeToJoin, v -> freeToJoin = v, false); + main.addBool("free_to_join", persistence::freeToJoin, persistence::freeToJoin, false); ConfigGroup display = main.getGroup("display"); - display.addEnum("color", () -> color, v -> color = v, EnumTeamColor.NAME_MAP); + display.addEnum("color", persistence::color, persistence::color, EnumTeamColor.NAME_MAP); display.addEnum( "fake_player_status", - () -> fakePlayerStatus, - v -> fakePlayerStatus = v, + persistence::fakePlayerStatus, + persistence::fakePlayerStatus, EnumTeamStatus.NAME_MAP_PERMS); - display.addString("title", () -> title, v -> title = v, ""); - display.addString("desc", () -> desc, v -> desc = v, ""); + display.addString("title", persistence::title, persistence::title, ""); + display.addString("desc", persistence::description, persistence::description, ""); } return cachedConfig; @@ -596,7 +604,7 @@ public boolean isValid() { } public boolean equalsTeam(@Nullable ForgeTeam team) { - return team == this || uid == Objects.hashCode(team); + return equals(team); } public boolean anyPlayerHasPermission(String permission, EnumTeamStatus status) { @@ -662,19 +670,20 @@ public List getOnlineMembers() { } public long getLastActivity() { - if (lastActivity == 0) { + if (persistence.lastActivity() == 0) { long latestActivity = 0; for (ForgePlayer player : getMembers()) { latestActivity = Math.max(player.getLastTimeSeen(), latestActivity); } - lastActivity = System.currentTimeMillis() - Ticks.get(universe.ticks.ticks() - latestActivity).millis(); + persistence.lastActivity( + System.currentTimeMillis() - Ticks.get(universe.ticks.ticks() - latestActivity).millis()); markDirty(); } - return lastActivity; + return persistence.lastActivity(); } public void refreshActivity() { - lastActivity = System.currentTimeMillis(); + persistence.lastActivity(System.currentTimeMillis()); markDirty(); } diff --git a/src/main/java/serverutils/lib/data/ForgeTeamMembership.java b/src/main/java/serverutils/lib/data/ForgeTeamMembership.java new file mode 100644 index 000000000..cb12707d6 --- /dev/null +++ b/src/main/java/serverutils/lib/data/ForgeTeamMembership.java @@ -0,0 +1,102 @@ +package serverutils.lib.data; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import net.minecraft.nbt.NBTTagString; +import net.minecraftforge.common.util.Constants; + +import serverutils.lib.EnumTeamStatus; + +final class ForgeTeamMembership { + + private final Map statuses = new HashMap<>(); + private final Set requestingInvites = new HashSet<>(); + private final Map statusesView = Collections.unmodifiableMap(statuses); + + Map mutableStatuses() { + return statuses; + } + + Map statusesView() { + return statusesView; + } + + EnumTeamStatus getStatus(ForgePlayer player) { + return statuses.get(player); + } + + EnumTeamStatus putStatus(ForgePlayer player, EnumTeamStatus status) { + return statuses.put(player, status); + } + + EnumTeamStatus removeStatus(ForgePlayer player) { + return statuses.remove(player); + } + + boolean addInviteRequest(ForgePlayer player) { + return requestingInvites.add(player); + } + + boolean removeInviteRequest(ForgePlayer player) { + return requestingInvites.remove(player); + } + + boolean isRequestingInvite(ForgePlayer player) { + return requestingInvites.contains(player); + } + + void writeTo(NBTTagCompound nbt) { + NBTTagCompound playerTags = new NBTTagCompound(); + for (Map.Entry entry : statuses.entrySet()) { + playerTags.setString(entry.getKey().getName(), entry.getValue().getName()); + } + nbt.setTag("Players", playerTags); + + NBTTagList inviteRequests = new NBTTagList(); + for (ForgePlayer player : requestingInvites) { + inviteRequests.appendTag(new NBTTagString(player.getName())); + } + nbt.setTag("RequestingInvite", inviteRequests); + } + + void readFrom(ForgeTeam team, NBTTagCompound nbt) { + statuses.clear(); + + if (nbt.hasKey("Players")) { + NBTTagCompound playerTags = nbt.getCompoundTag("Players"); + for (String playerName : playerTags.func_150296_c()) { + ForgePlayer player = team.universe.getPlayer(playerName); + if (player == null) { + continue; + } + + EnumTeamStatus status = EnumTeamStatus.NAME_MAP.get(playerTags.getString(playerName)); + if (status.canBeSet()) { + team.setStatus(player, status); + } + } + } + + NBTTagList inviteRequests = nbt.getTagList("RequestingInvite", Constants.NBT.TAG_STRING); + for (int i = 0; i < inviteRequests.tagCount(); i++) { + ForgePlayer player = team.universe.getPlayer(inviteRequests.getStringTagAt(i)); + if (player != null && !team.isMember(player)) { + team.setRequestingInvite(player, true); + } + } + + NBTTagList invitedPlayers = nbt.getTagList("Invited", Constants.NBT.TAG_STRING); + for (int i = 0; i < invitedPlayers.tagCount(); i++) { + ForgePlayer player = team.universe.getPlayer(invitedPlayers.getStringTagAt(i)); + if (player != null && !team.isMember(player)) { + team.setStatus(player, EnumTeamStatus.INVITED); + } + } + } +} diff --git a/src/main/java/serverutils/lib/data/ForgeTeamPersistence.java b/src/main/java/serverutils/lib/data/ForgeTeamPersistence.java new file mode 100644 index 000000000..20e35cfb7 --- /dev/null +++ b/src/main/java/serverutils/lib/data/ForgeTeamPersistence.java @@ -0,0 +1,139 @@ +package serverutils.lib.data; + +import net.minecraft.nbt.NBTTagCompound; + +import serverutils.events.team.ForgeTeamDataEvent; +import serverutils.lib.EnumTeamColor; +import serverutils.lib.EnumTeamStatus; + +final class ForgeTeamPersistence { + + private final NBTDataStorage dataStorage; + private String title; + private String description; + private EnumTeamColor color; + private String icon; + private boolean freeToJoin; + private EnumTeamStatus fakePlayerStatus; + private long lastActivity; + private boolean initialized; + + ForgeTeamPersistence() { + dataStorage = new NBTDataStorage(); + title = ""; + description = ""; + color = EnumTeamColor.BLUE; + icon = ""; + freeToJoin = false; + fakePlayerStatus = EnumTeamStatus.ALLY; + lastActivity = 0L; + initialized = false; + } + + void initialize(ForgeTeam team) { + if (initialized) { + throw new IllegalStateException("Team persistence has already been initialized"); + } + initialized = true; + new ForgeTeamDataEvent(team, dataStorage).post(); + } + + NBTTagCompound serialize(ForgeTeam team, ForgeTeamMembership membership) { + NBTTagCompound nbt = new NBTTagCompound(); + if (team.getStoredOwner() != null) { + nbt.setString("Owner", team.getStoredOwner().getName()); + } + + nbt.setString("Title", title); + nbt.setString("Desc", description); + nbt.setString("Color", EnumTeamColor.NAME_MAP.getName(color)); + nbt.setString("Icon", icon); + nbt.setBoolean("FreeToJoin", freeToJoin); + nbt.setString("FakePlayerStatus", EnumTeamStatus.NAME_MAP_PERMS.getName(fakePlayerStatus)); + nbt.setLong("LastActivity", lastActivity); + membership.writeTo(nbt); + nbt.setTag("Data", dataStorage.serializeNBT()); + return nbt; + } + + void deserialize(ForgeTeam team, ForgeTeamMembership membership, NBTTagCompound nbt) { + team.setStoredOwner(team.universe.getPlayer(nbt.getString("Owner"))); + if (!team.isValid()) { + return; + } + + title = nbt.getString("Title"); + description = nbt.getString("Desc"); + color = EnumTeamColor.NAME_MAP.get(nbt.getString("Color")); + icon = nbt.getString("Icon"); + freeToJoin = nbt.getBoolean("FreeToJoin"); + fakePlayerStatus = EnumTeamStatus.NAME_MAP_PERMS.get(nbt.getString("FakePlayerStatus")); + lastActivity = nbt.getLong("LastActivity"); + membership.readFrom(team, nbt); + dataStorage.deserializeNBT(nbt.getCompoundTag("Data")); + } + + NBTDataStorage data() { + return dataStorage; + } + + void clearCache() { + dataStorage.clearCache(); + } + + String title() { + return title; + } + + void title(String value) { + title = value; + } + + String description() { + return description; + } + + void description(String value) { + description = value; + } + + EnumTeamColor color() { + return color; + } + + void color(EnumTeamColor value) { + color = value; + } + + String icon() { + return icon; + } + + void icon(String value) { + icon = value; + } + + boolean freeToJoin() { + return freeToJoin; + } + + void freeToJoin(boolean value) { + freeToJoin = value; + } + + EnumTeamStatus fakePlayerStatus() { + return fakePlayerStatus; + } + + void fakePlayerStatus(EnumTeamStatus value) { + fakePlayerStatus = value; + } + + long lastActivity() { + return lastActivity; + } + + void lastActivity(long value) { + lastActivity = value; + } +} diff --git a/src/main/java/serverutils/lib/data/ServerUtilitiesAPI.java b/src/main/java/serverutils/lib/data/ServerUtilitiesAPI.java index 5378c27ae..707b6be6b 100644 --- a/src/main/java/serverutils/lib/data/ServerUtilitiesAPI.java +++ b/src/main/java/serverutils/lib/data/ServerUtilitiesAPI.java @@ -15,7 +15,7 @@ import serverutils.ServerUtilities; import serverutils.ServerUtilitiesCommon; import serverutils.ServerUtilitiesConfig; -import serverutils.ServerUtilitiesRegistry; +import serverutils.api.ServerUtilitiesRegistry; import serverutils.events.IReloadHandler; import serverutils.events.ServerReloadEvent; import serverutils.lib.EnumReloadType; @@ -41,7 +41,8 @@ public static void reloadServer(Universe universe, ICommandSender sender, EnumRe HashSet failed = new HashSet<>(); ServerReloadEvent event = new ServerReloadEvent(universe, sender, type, id, failed); - for (Map.Entry entry : ServerUtilitiesRegistry.RELOAD_IDS.entrySet()) { + for (Map.Entry entry : ServerUtilitiesRegistry.reloadHandlersView() + .entrySet()) { try { if (event.reload(entry.getKey()) && !entry.getValue().onReload(event)) { event.failedToReload(entry.getKey()); @@ -50,7 +51,7 @@ public static void reloadServer(Universe universe, ICommandSender sender, EnumRe event.failedToReload(entry.getKey()); if (ServerUtilitiesConfig.debugging.print_more_errors) { - ex.printStackTrace(); + ServerUtilities.LOGGER.error("Server reload handler failed for " + entry.getKey(), ex); } } } @@ -112,7 +113,7 @@ public static ConfigValue createConfigValueFromId(String id) { return ConfigNull.INSTANCE; } - ConfigValueProvider provider = ServerUtilitiesRegistry.CONFIG_VALUE_PROVIDERS.get(id); + ConfigValueProvider provider = ServerUtilitiesRegistry.findConfigValueProvider(id); Objects.requireNonNull(provider, "Unknown Config ID: " + id); ConfigValue value = provider.get(); return value == null || value.isNull() ? ConfigNull.INSTANCE : value; @@ -139,7 +140,7 @@ public static boolean arePlayersInSameTeam(UUID player1, UUID player2) { } ForgePlayer p2 = Universe.get().getPlayer(player2); - return p2 != null && p2.hasTeam() && p1.team.equalsTeam(p2.team); + return p2 != null && p2.hasTeam() && p1.getTeam().equalsTeam(p2.getTeam()); } public static boolean isPlayerInTeam(UUID player, String team) { @@ -153,7 +154,7 @@ public static boolean isPlayerInTeam(UUID player, String team) { return false; } - return p.hasTeam() ? p.team.getId().equals(team) : team.isEmpty(); + return p.hasTeam() ? p.getTeam().getId().equals(team) : team.isEmpty(); } public static boolean isPlayerInTeam(UUID player, int team) { @@ -167,7 +168,7 @@ public static boolean isPlayerInTeam(UUID player, int team) { return false; } - return p.hasTeam() ? p.team.getUID() == team : team == 0; + return p.hasTeam() ? p.getTeam().getUID() == team : team == 0; } public static String getTeam(UUID player) { @@ -176,7 +177,7 @@ public static String getTeam(UUID player) { } ForgePlayer p = Universe.get().getPlayer(player); - return p == null ? "" : p.team.getId(); + return p == null ? "" : p.getTeam().getId(); } public static short getTeamID(UUID player) { @@ -185,7 +186,7 @@ public static short getTeamID(UUID player) { } ForgePlayer p = Universe.get().getPlayer(player); - return p == null ? 0 : p.team.getUID(); + return p == null ? 0 : p.getTeam().getUID(); } public static void reload(MinecraftServer server) { diff --git a/src/main/java/serverutils/lib/data/ServerUtilitiesTeamGuiActions.java b/src/main/java/serverutils/lib/data/ServerUtilitiesTeamGuiActions.java index 323bbfaf4..9f0c634cd 100644 --- a/src/main/java/serverutils/lib/data/ServerUtilitiesTeamGuiActions.java +++ b/src/main/java/serverutils/lib/data/ServerUtilitiesTeamGuiActions.java @@ -10,6 +10,7 @@ import serverutils.ServerUtilities; import serverutils.lib.EnumTeamStatus; import serverutils.lib.gui.GuiIcons; +import serverutils.lib.util.StringUtils; import serverutils.net.MessageMyTeamPlayerList; public class ServerUtilitiesTeamGuiActions { @@ -26,12 +27,12 @@ public class ServerUtilitiesTeamGuiActions { @Override public Type getType(ForgePlayer player, NBTTagCompound data) { - return player.team.isModerator(player) ? Type.ENABLED : Type.DISABLED; + return player.getTeam().isModerator(player) ? Type.ENABLED : Type.DISABLED; } @Override public void onAction(ForgePlayer player, NBTTagCompound data) { - ServerUtilitiesAPI.editServerConfig(player.getPlayer(), player.team.getSettings(), player.team); + ServerUtilitiesAPI.editServerConfig(player.getPlayer(), player.getTeam().getSettings(), player.getTeam()); } }.setTitle(new ChatComponentTranslation("gui.settings")); @@ -52,7 +53,7 @@ public void onAction(ForgePlayer player, NBTTagCompound data) { @Override public Type getType(ForgePlayer player, NBTTagCompound data) { - return (player.team.isModerator(player) && player.team.universe.getPlayers().size() > 1) ? Type.ENABLED + return (player.getTeam().isModerator(player) && player.getUniverse().getPlayers().size() > 1) ? Type.ENABLED : Type.DISABLED; } @@ -63,7 +64,7 @@ public void onAction(ForgePlayer player, NBTTagCompound data) { return; } - ForgePlayer p = player.team.universe.getPlayer(data.getString("player")); + ForgePlayer p = getPayloadPlayer(player, data); if (p == null || p == player) { return; @@ -71,43 +72,45 @@ public void onAction(ForgePlayer player, NBTTagCompound data) { switch (data.getString("action")) { case "kick": { - if (player.team.isMember(p)) { - player.team.removeMember(p); - player.team.setRequestingInvite(p, true); + if (player.getTeam().isMember(p)) { + player.getTeam().removeMember(p); + player.getTeam().setRequestingInvite(p, true); } break; } case "invite": { - player.team.setStatus(p, EnumTeamStatus.INVITED); + player.getTeam().setStatus(p, EnumTeamStatus.INVITED); - if (player.team.isRequestingInvite(p)) { + if (player.getTeam().isRequestingInvite(p)) { if (p.hasTeam()) { - player.team.setRequestingInvite(p, false); + player.getTeam().setRequestingInvite(p, false); } else { - player.team.addMember(p, false); + player.getTeam().addMember(p, false); } } else if (p.isOnline()) { IChatComponent component = new ChatComponentTranslation( "serverutilities.lang.team.invited_you", - player.team, + player.getTeam(), player.getDisplayName()); component.getChatStyle().setChatClickEvent( - new ClickEvent(ClickEvent.Action.RUN_COMMAND, "/team join " + player.team.getId())); + new ClickEvent( + ClickEvent.Action.RUN_COMMAND, + "/team join " + player.getTeam().getId())); p.getPlayer().addChatComponentMessage(component); } break; } case "cancel_invite": { - if (player.team.getHighestStatus(p) == EnumTeamStatus.INVITED) { - player.team.setStatus(p, EnumTeamStatus.NONE); + if (player.getTeam().getHighestStatus(p) == EnumTeamStatus.INVITED) { + player.getTeam().setStatus(p, EnumTeamStatus.NONE); } break; } case "deny_request": { - player.team.setRequestingInvite(p, false); + player.getTeam().setRequestingInvite(p, false); break; } } @@ -118,7 +121,7 @@ public void onAction(ForgePlayer player, NBTTagCompound data) { @Override public Type getType(ForgePlayer player, NBTTagCompound data) { - return (player.team.isModerator(player) && player.team.universe.getPlayers().size() > 1) ? Type.ENABLED + return (player.getTeam().isModerator(player) && player.getUniverse().getPlayers().size() > 1) ? Type.ENABLED : Type.DISABLED; } @@ -128,10 +131,10 @@ public void onAction(ForgePlayer player, NBTTagCompound data) { new MessageMyTeamPlayerList(getId(), player, ALLIES_PREDICATE).sendTo(player.getPlayer()); } - ForgePlayer p = player.team.universe.getPlayer(data.getString("player")); + ForgePlayer p = getPayloadPlayer(player, data); if (p != null && p != player) { - player.team.setStatus(p, data.getBoolean("add") ? EnumTeamStatus.ALLY : EnumTeamStatus.NONE); + player.getTeam().setStatus(p, data.getBoolean("add") ? EnumTeamStatus.ALLY : EnumTeamStatus.NONE); } } }; @@ -144,7 +147,8 @@ public void onAction(ForgePlayer player, NBTTagCompound data) { @Override public Type getType(ForgePlayer player, NBTTagCompound data) { - return (player.team.isOwner(player) && player.team.getMembers().size() > 1) ? Type.ENABLED : Type.DISABLED; + return (player.getTeam().isOwner(player) && player.getTeam().getMembers().size() > 1) ? Type.ENABLED + : Type.DISABLED; } @Override @@ -154,10 +158,10 @@ public void onAction(ForgePlayer player, NBTTagCompound data) { return; } - ForgePlayer p = player.team.universe.getPlayer(data.getString("player")); + ForgePlayer p = getPayloadPlayer(player, data); if (p != null && p != player) { - player.team.setStatus(p, data.getBoolean("add") ? EnumTeamStatus.MOD : EnumTeamStatus.NONE); + player.getTeam().setStatus(p, data.getBoolean("add") ? EnumTeamStatus.MOD : EnumTeamStatus.NONE); } } }; @@ -166,7 +170,7 @@ public void onAction(ForgePlayer player, NBTTagCompound data) { @Override public Type getType(ForgePlayer player, NBTTagCompound data) { - return (player.team.isModerator(player) && player.team.universe.getPlayers().size() > 1) ? Type.ENABLED + return (player.getTeam().isModerator(player) && player.getUniverse().getPlayers().size() > 1) ? Type.ENABLED : Type.DISABLED; } @@ -176,10 +180,10 @@ public void onAction(ForgePlayer player, NBTTagCompound data) { new MessageMyTeamPlayerList(getId(), player, ENEMIES_PREDICATE).sendTo(player.getPlayer()); } - ForgePlayer p = player.team.universe.getPlayer(data.getString("player")); + ForgePlayer p = getPayloadPlayer(player, data); if (p != null && p != player) { - player.team.setStatus(p, data.getBoolean("add") ? EnumTeamStatus.ENEMY : EnumTeamStatus.NONE); + player.getTeam().setStatus(p, data.getBoolean("add") ? EnumTeamStatus.ENEMY : EnumTeamStatus.NONE); } } }; @@ -188,13 +192,13 @@ public void onAction(ForgePlayer player, NBTTagCompound data) { @Override public Type getType(ForgePlayer player, NBTTagCompound data) { - return (!player.team.isOwner(player) || player.team.getMembers().size() <= 1) ? Type.ENABLED + return (!player.getTeam().isOwner(player) || player.getTeam().getMembers().size() <= 1) ? Type.ENABLED : Type.INVISIBLE; } @Override public void onAction(ForgePlayer player, NBTTagCompound data) { - player.team.removeMember(player); + player.getTeam().removeMember(player); ServerUtilitiesAPI.sendCloseGuiPacket(player.getPlayer()); } }.setRequiresConfirm(); @@ -207,7 +211,7 @@ public void onAction(ForgePlayer player, NBTTagCompound data) { @Override public Type getType(ForgePlayer player, NBTTagCompound data) { - return (!player.team.isOwner(player) || player.team.getMembers().size() <= 1) ? Type.INVISIBLE + return (!player.getTeam().isOwner(player) || player.getTeam().getMembers().size() <= 1) ? Type.INVISIBLE : Type.ENABLED; } @@ -217,11 +221,17 @@ public void onAction(ForgePlayer player, NBTTagCompound data) { new MessageMyTeamPlayerList(getId(), player, MEMBERS_PREDICATE).sendTo(player.getPlayer()); } - ForgePlayer p = player.team.universe.getPlayer(data.getString("player")); + ForgePlayer p = getPayloadPlayer(player, data); if (p != null && p != player) { - player.team.setStatus(p, EnumTeamStatus.OWNER); + player.getTeam().setStatus(p, EnumTeamStatus.OWNER); } } }; + + static ForgePlayer getPayloadPlayer(ForgePlayer actor, NBTTagCompound data) { + String identifier = data.getString("player"); + ForgePlayer player = actor.getUniverse().getPlayer(StringUtils.fromString(identifier)); + return player == null ? actor.getUniverse().getPlayer(identifier) : player; + } } diff --git a/src/main/java/serverutils/lib/data/Universe.java b/src/main/java/serverutils/lib/data/Universe.java index b637cae69..e6a9fab01 100644 --- a/src/main/java/serverutils/lib/data/Universe.java +++ b/src/main/java/serverutils/lib/data/Universe.java @@ -4,9 +4,9 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +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; @@ -16,13 +16,10 @@ import net.minecraft.command.ICommandSender; import net.minecraft.entity.player.EntityPlayerMP; -import net.minecraft.nbt.NBTTagCompound; import net.minecraft.server.MinecraftServer; import net.minecraft.world.WorldServer; import net.minecraftforge.event.world.WorldEvent; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; import com.mojang.authlib.GameProfile; import cpw.mods.fml.common.event.FMLServerAboutToStartEvent; @@ -32,27 +29,13 @@ import cpw.mods.fml.common.eventhandler.SubscribeEvent; import cpw.mods.fml.common.gameevent.PlayerEvent; import cpw.mods.fml.common.gameevent.TickEvent; -import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; -import serverutils.ServerUtilities; import serverutils.ServerUtilitiesConfig; -import serverutils.data.BackwardsCompat; -import serverutils.events.ServerReloadEvent; -import serverutils.events.player.ForgePlayerLoadedEvent; -import serverutils.events.player.ForgePlayerSavedEvent; import serverutils.events.team.ForgeTeamDeletedEvent; -import serverutils.events.team.ForgeTeamLoadedEvent; -import serverutils.events.team.ForgeTeamSavedEvent; import serverutils.events.universe.UniverseClearCacheEvent; import serverutils.events.universe.UniverseClosedEvent; -import serverutils.events.universe.UniverseLoadedEvent; -import serverutils.events.universe.UniverseSavedEvent; -import serverutils.lib.EnumReloadType; -import serverutils.lib.EnumTeamColor; -import serverutils.lib.io.DataReader; import serverutils.lib.math.MathUtils; import serverutils.lib.math.Ticks; import serverutils.lib.util.FileUtils; -import serverutils.lib.util.NBTUtils; import serverutils.lib.util.ServerUtils; import serverutils.lib.util.StringUtils; import serverutils.ranks.Ranks; @@ -76,10 +59,22 @@ public static Universe get() { return INSTANCE; } + public static Universe requireLoaded() { + if (INSTANCE == null) { + throw new IllegalStateException("ServerUtilities Universe is not loaded"); + } + + return INSTANCE; + } + public static @Nullable Universe getNullable() { return INSTANCE; } + public static @Nullable Universe getIfLoaded() { + return INSTANCE; + } + // Event handlers start // public static void onServerAboutToStart(FMLServerAboutToStartEvent event) { @@ -163,23 +158,7 @@ public void onTickEvent(TickEvent.WorldTickEvent event) { if (event.phase == TickEvent.Phase.START) { universe.ticks = Ticks.get(event.world.getTotalWorldTime()); } else if (!event.world.isRemote && event.world.provider.dimensionId == 0) { - universe.taskList.addAll(universe.taskQueue); - universe.taskQueue.clear(); - - Iterator taskIterator = universe.taskList.iterator(); - - while (taskIterator.hasNext()) { - Task task = taskIterator.next(); - if (task.isComplete(universe)) { - task.execute(universe); - - if (task.isRepeatable()) { - task.setNextTime(System.currentTimeMillis() + task.getInterval()); - continue; - } - taskIterator.remove(); - } - } + universe.taskScheduler.tick(universe); if (universe.server.isSinglePlayer()) { boolean cheats = universe.server.getConfigurationManager().commandsAllowedForAll; @@ -196,18 +175,17 @@ public void onTickEvent(TickEvent.WorldTickEvent event) { public final MinecraftServer server; public WorldServer world; + private final UniverseRepository repository; + /** @deprecated Use player registration and lookup methods instead. */ + @Deprecated public final Map players; + /** @deprecated Use {@link #setVanished(ForgePlayer, boolean)} and {@link #getVanishedPlayersView()}. */ + @Deprecated public final Set vanishedPlayers; - private final Map teams; - private final Map teamMap; - private final ForgeTeam noneTeam; - private UUID uuid; - private boolean needsSaving; - boolean checkSaving; public ForgeTeam fakePlayerTeam; public FakeForgePlayer fakePlayer; - private final List taskList; - private final List taskQueue; + private final UniversePersistence persistence; + private final UniverseTaskScheduler taskScheduler; public Ticks ticks; private boolean prevCheats = false; public File dataFolder; @@ -218,32 +196,26 @@ public void onTickEvent(TickEvent.WorldTickEvent event) { public Universe(MinecraftServer s) { server = s; ticks = Ticks.NO_TICKS; - players = new HashMap<>(); - vanishedPlayers = new ObjectOpenHashSet<>(); - teams = new HashMap<>(); - teamMap = new HashMap<>(); - noneTeam = new ForgeTeam(this, (short) 0, "", TeamType.NONE); - uuid = null; - needsSaving = false; - checkSaving = true; - taskList = new ArrayList<>(); - taskQueue = new ArrayList<>(); + repository = new UniverseRepository(); + persistence = new UniversePersistence(this); + taskScheduler = new UniverseTaskScheduler(); + players = repository.mutablePlayers(); + vanishedPlayers = repository.mutableVanishedPlayers(); gameRulesFlipped = false; flippedRulesSaveState = new HashMap<>(); + repository.initialize(this); } public void markDirty() { - needsSaving = true; - checkSaving = true; + persistence.markDirty(); } - public UUID getUUID() { - if (uuid == null) { - uuid = UUID.randomUUID(); - markDirty(); - } + void markChildDirty() { + persistence.markChildDirty(); + } - return uuid; + public UUID getUUID() { + return persistence.getUuid(); } public void scheduleTask(Task task) { @@ -251,253 +223,15 @@ public void scheduleTask(Task task) { } public void scheduleTask(Task task, boolean condition) { - if (!condition) return; - if (task.getNextTime() <= -1) return; - task.queueNotifications(this); - taskQueue.add(task); + taskScheduler.schedule(this, task, condition); } private void load() { - dataFolder = new File(getWorldDirectory(), "serverutilities/"); - latModFolder = new File(getWorldDirectory(), "LatMod"); - NBTTagCompound universeData = NBTUtils.readNBT(new File(dataFolder, "universe.dat")); - - if (universeData == null) { - universeData = new NBTTagCompound(); - } - - File worldDataJsonFile = new File(getWorldDirectory(), "world_data.json"); - JsonElement worldData = DataReader.get(worldDataJsonFile).safeJson(); - - if (worldData.isJsonObject()) { - JsonObject jsonWorldData = worldData.getAsJsonObject(); - - if (jsonWorldData.has("world_id")) { - universeData.setString("UUID", jsonWorldData.get("world_id").getAsString()); - } - - worldDataJsonFile.delete(); - } - - uuid = StringUtils.fromString(universeData.getString("UUID")); - - if (uuid != null && uuid.getLeastSignificantBits() == 0L && uuid.getMostSignificantBits() == 0L) { - uuid = null; - } - - NBTTagCompound data = universeData.getCompoundTag("Data"); - - new UniverseLoadedEvent.Pre(this, data).post(); - - Map playerNBT = new HashMap<>(); - Map teamNBT = new HashMap<>(); - - try { - File[] files = new File(dataFolder, "players").listFiles(); - - if (files != null) { - for (File file : files) { - if (file.isFile() && file.getName().endsWith(".dat") - && file.getName().indexOf('.') == file.getName().lastIndexOf('.')) { - NBTTagCompound nbt = NBTUtils.readNBT(file); - - if (nbt != null) { - String uuidString = nbt.getString("UUID"); - - if (uuidString.isEmpty()) { - uuidString = FileUtils.getBaseName(file); - FileUtils.deleteSafe(file); - } - - UUID uuid = StringUtils.fromString(uuidString); - - if (uuid != null) { - playerNBT.put(uuid, nbt); - ForgePlayer player = new ForgePlayer(this, uuid, nbt.getString("Name")); - players.put(uuid, player); - } - } - } - } - } - } catch (Exception ex) { - ex.printStackTrace(); - } - - try { - File[] files = new File(dataFolder, "teams").listFiles(); - - if (files != null) { - for (File file : files) { - if (file.isFile() && file.getName().endsWith(".dat") - && file.getName().indexOf('.') == file.getName().lastIndexOf('.')) { - NBTTagCompound nbt = NBTUtils.readNBT(file); - - if (nbt != null) { - String s = nbt.getString("ID"); - - if (s.isEmpty()) { - s = FileUtils.getBaseName(file); - } - - teamNBT.put(s, nbt); - short uid = nbt.getShort("UID"); - ForgeTeam team = new ForgeTeam( - this, - generateTeamUID(uid), - s, - TeamType.NAME_MAP.get(nbt.getString("Type"))); - addTeam(team); - - if (uid == 0) { - team.markDirty(); - } - } - } - } - } - } catch (Exception ex) { - ex.printStackTrace(); - } - - fakePlayerTeam = new ForgeTeam(this, (short) 1, "fakeplayer", TeamType.SERVER_NO_SAVE) { - - @Override - public void markDirty() { - Universe.this.markDirty(); - } - }; - - fakePlayer = new FakeForgePlayer(this); - fakePlayer.team = fakePlayerTeam; - fakePlayerTeam.setColor(EnumTeamColor.GRAY); - - new UniverseLoadedEvent.CreateServerTeams(this).post(); - - for (ForgePlayer player : players.values()) { - NBTTagCompound nbt = playerNBT.get(player.getId()); - - if (nbt != null && !nbt.hasNoTags()) { - player.team = getTeam(nbt.getString("TeamID")); - player.deserializeNBT(nbt); - } - - new ForgePlayerLoadedEvent(player).post(); - } - - for (ForgeTeam team : getTeams()) { - if (!team.type.save) { - continue; - } - - NBTTagCompound nbt = teamNBT.get(team.getId()); - - if (nbt != null && !nbt.hasNoTags()) { - team.deserializeNBT(nbt); - } - - new ForgeTeamLoadedEvent(team).post(); - } - - if (universeData.hasKey("FakePlayer")) { - fakePlayer.deserializeNBT(universeData.getCompoundTag("FakePlayer")); - } - - if (universeData.hasKey("FakeTeam")) { - fakePlayerTeam.deserializeNBT(universeData.getCompoundTag("FakeTeam")); - } - - fakePlayerTeam.owner = fakePlayer; - - if (universeData.hasKey("GameRulesState")) { - NBTTagCompound gameRulesState = universeData.getCompoundTag("GameRulesState"); - gameRulesFlipped = gameRulesState.getBoolean("Flipped"); - NBTTagCompound savedRules = gameRulesState.getCompoundTag("SavedRules"); - for (String key : NBTUtils.getKeySet(savedRules)) { - flippedRulesSaveState.put(key, savedRules.getString(key)); - } - } - - new UniverseLoadedEvent.Post(this, data).post(); - - if (shouldLoadLatmod()) { - BackwardsCompat.load(); - } - - new UniverseLoadedEvent.Finished(this).post(); - - ServerUtilitiesAPI.reloadServer(this, server, EnumReloadType.CREATED, ServerReloadEvent.ALL); + persistence.load(); } private void save() { - if (!checkSaving) { - return; - } - - if (needsSaving) { - if (ServerUtilitiesConfig.debugging.print_more_info) { - ServerUtilities.LOGGER.info("Saving universe data"); - } - - NBTTagCompound universeData = new NBTTagCompound(); - NBTTagCompound data = new NBTTagCompound(); - new UniverseSavedEvent(this, data).post(); - universeData.setTag("Data", data); - universeData.setString("UUID", StringUtils.fromUUID(getUUID())); - universeData.setTag("FakePlayer", fakePlayer.serializeNBT()); - universeData.setTag("FakeTeam", fakePlayerTeam.serializeNBT()); - NBTTagCompound gameRulesState = new NBTTagCompound(); - gameRulesState.setBoolean("Flipped", gameRulesFlipped); - NBTTagCompound savedRules = new NBTTagCompound(); - for (Map.Entry entry : flippedRulesSaveState.entrySet()) { - savedRules.setString(entry.getKey(), entry.getValue()); - } - gameRulesState.setTag("SavedRules", savedRules); - universeData.setTag("GameRulesState", gameRulesState); - NBTUtils.writeNBTSafe(new File(dataFolder, "universe.dat"), universeData); - needsSaving = false; - } - - for (ForgePlayer player : players.values()) { - if (player.needsSaving) { - if (ServerUtilitiesConfig.debugging.print_more_info) { - ServerUtilities.LOGGER.info("Saved player data for " + player.getName()); - } - - NBTTagCompound nbt = player.serializeNBT(); - nbt.setString("Name", player.getName()); - nbt.setString("UUID", StringUtils.fromUUID(player.getId())); - nbt.setString("TeamID", player.team.getId()); - NBTUtils.writeNBTSafe(player.getDataFile(), nbt); - new ForgePlayerSavedEvent(player).post(); - player.needsSaving = false; - } - } - - for (ForgeTeam team : getTeams()) { - if (team.needsSaving) { - if (ServerUtilitiesConfig.debugging.print_more_info) { - ServerUtilities.LOGGER.info("Saved team data for {}", team.getId()); - } - - File file = team.getDataFile(""); - - if (team.type.save && team.isValid()) { - NBTTagCompound nbt = team.serializeNBT(); - nbt.setString("ID", team.getId()); - nbt.setShort("UID", team.getUID()); - nbt.setString("Type", team.type.getName()); - NBTUtils.writeNBTSafe(file, nbt); - new ForgeTeamSavedEvent(team).post(); - } else if (file.exists()) { - file.delete(); - } - - team.needsSaving = false; - } - } - - checkSaving = false; + persistence.save(); } public File getWorldDirectory() { @@ -513,17 +247,17 @@ private void onPlayerLoggedIn(EntityPlayerMP player) { if (p == null) { p = new ForgePlayer(this, player.getUniqueID(), player.getCommandSenderName()); - players.put(p.getId(), p); + repository.putPlayer(p.getId(), p); p.onLoggedIn(player, this, true); } else { if (!p.getId().equals(player.getUniqueID()) || !p.getName().equals(player.getCommandSenderName())) { File old = p.getDataFile(); - players.remove(p.getId()); + repository.removePlayer(p.getId()); p.profile = new GameProfile(player.getUniqueID(), player.getCommandSenderName()); - players.put(p.getId(), p); + repository.putPlayer(p.getId(), p); old.renameTo(p.getDataFile()); p.markDirty(); - p.team.markDirty(); + p.getTeam().markDirty(); markDirty(); } @@ -537,13 +271,33 @@ private void onPlayerLoggedIn(EntityPlayerMP player) { } public Collection getPlayers() { - return players.values(); + return repository.players(); + } + + public Collection getPlayersView() { + return Collections.unmodifiableCollection(repository.players()); + } + + public void registerPlayer(ForgePlayer player) { + registerPlayer(player.getId(), player); + } + + public void registerPlayer(UUID id, ForgePlayer player) { + repository.putPlayer(id, player); } public Collection getVanishedPlayers() { return vanishedPlayers; } + public Set getVanishedPlayersView() { + return Collections.unmodifiableSet(vanishedPlayers); + } + + public boolean setVanished(ForgePlayer player, boolean vanished) { + return vanished ? vanishedPlayers.add(player) : vanishedPlayers.remove(player); + } + @Nullable public ForgePlayer getPlayer(@Nullable UUID id) { if (id == null) { @@ -552,38 +306,73 @@ public ForgePlayer getPlayer(@Nullable UUID id) { return fakePlayer; } - return players.get(id); + return repository.getPlayer(id); } @Nullable public ForgePlayer getPlayer(CharSequence nameOrId) { - String s = nameOrId.toString().toLowerCase(); - - if (s.isEmpty()) { - return null; + if (fakePlayer != null && ServerUtils.FAKE_PLAYER_PROFILE.getName().equalsIgnoreCase(nameOrId.toString())) { + return fakePlayer; } - UUID id = StringUtils.fromString(s); + List matches = searchPlayers(nameOrId); + return matches.isEmpty() ? null : matches.get(0); + } + @Nullable + public ForgePlayer findPlayerExact(CharSequence nameOrId) { + String query = nameOrId.toString(); + UUID id = StringUtils.fromString(query); if (id != null) { return getPlayer(id); - } else if (s.equals(ServerUtils.FAKE_PLAYER_PROFILE.getName().toLowerCase())) { - return fakePlayer; } - for (ForgePlayer p : players.values()) { - if (p.getName().toLowerCase().equals(s)) { - return p; - } + List exactMatches = findPlayersByName(query, true); + return exactMatches.size() == 1 ? exactMatches.get(0) : null; + } + + public List searchPlayers(CharSequence nameOrId) { + String query = nameOrId.toString(); + if (query.isEmpty()) { + return Collections.emptyList(); } - for (ForgePlayer p : players.values()) { - if (p.getName().toLowerCase().contains(s)) { - return p; + UUID id = StringUtils.fromString(query); + if (id != null) { + ForgePlayer player = getPlayer(id); + return player == null ? Collections.emptyList() : Collections.singletonList(player); + } + + List exactMatches = findPlayersByName(query, true); + if (!exactMatches.isEmpty()) { + return Collections.unmodifiableList(exactMatches); + } + + return Collections.unmodifiableList(findPlayersByName(query, false)); + } + + private List findPlayersByName(String query, boolean exact) { + String normalizedQuery = query.toLowerCase(java.util.Locale.ROOT); + List matches = new ArrayList<>(); + Set matchedIds = new HashSet<>(); + if (exact && fakePlayer != null + && ServerUtils.FAKE_PLAYER_PROFILE.getName().equalsIgnoreCase(normalizedQuery)) { + matches.add(fakePlayer); + matchedIds.add(fakePlayer.getId()); + } + + for (ForgePlayer player : repository.players()) { + String normalizedName = player.getName().toLowerCase(java.util.Locale.ROOT); + if ((exact ? normalizedName.equals(normalizedQuery) : normalizedName.contains(normalizedQuery)) + && matchedIds.add(player.getId())) { + matches.add(player); } } - return null; + matches.sort( + Comparator.comparing((ForgePlayer player) -> player.getName().toLowerCase(java.util.Locale.ROOT)) + .thenComparing(ForgePlayer::getId)); + return matches; } public ForgePlayer getPlayer(@Nullable ICommandSender sender) { @@ -624,7 +413,7 @@ public ForgePlayer getPlayer(GameProfile profile) { if (player == null && ServerUtilitiesConfig.general.merge_offline_mode_players.get(!server.isDedicatedServer())) { String profileName = profile.getName(); - for (ForgePlayer p : players.values()) { + for (ForgePlayer p : repository.players()) { if (p.getName().equalsIgnoreCase(profileName)) { player = p; break; @@ -632,7 +421,7 @@ public ForgePlayer getPlayer(GameProfile profile) { } if (player != null) { - players.put(profile.getId(), player); + repository.putPlayer(profile.getId(), player); player.markDirty(); } } @@ -641,12 +430,12 @@ public ForgePlayer getPlayer(GameProfile profile) { } public Collection getTeams() { - return teams.values(); + return repository.teamsView(); } public ForgeTeam getTeam(String id) { if (id.isEmpty()) { - return noneTeam; + return repository.noneTeam(); } else if (id.length() == 4) { try { ForgeTeam team = getTeam(Integer.valueOf(id, 16).shortValue()); @@ -654,14 +443,16 @@ public ForgeTeam getTeam(String id) { if (team.isValid()) { return team; } - } catch (Exception ex) {} + } catch (NumberFormatException ignored) { + // Not a hexadecimal team UID; continue with regular ID lookup. + } } if (id.equals("fakeplayer")) { return fakePlayerTeam; } - ForgeTeam team = teams.get(id); + ForgeTeam team = repository.getTeam(id); if (team != null) { return team; @@ -670,21 +461,21 @@ public ForgeTeam getTeam(String id) { ForgePlayer player = getPlayer(id); if (player != null) { - return player.team; + return player.getTeam(); } - return noneTeam; + return repository.noneTeam(); } public ForgeTeam getTeam(short uid) { if (uid == 0) { - return noneTeam; + return repository.noneTeam(); } else if (uid == 1) { return fakePlayerTeam; } - ForgeTeam team = teamMap.get(uid); - return team == null ? noneTeam : team; + ForgeTeam team = repository.getTeam(uid); + return team == null ? repository.noneTeam() : team; } public Collection getOnlinePlayers() { @@ -714,22 +505,23 @@ public void clearCache() { } public void addTeam(ForgeTeam team) { - teamMap.put(team.getUID(), team); - teams.put(team.getId(), team); + if (team.universe != this) { + throw new IllegalArgumentException("Team belongs to a different universe"); + } + repository.addTeam(team); } public void removeTeam(ForgeTeam team) { File folder = new File(dataFolder, "teams/"); new ForgeTeamDeletedEvent(team, folder).post(); - teamMap.remove(team.getUID()); - teams.remove(team.getId()); + repository.removeTeam(team); FileUtils.deleteSafe(new File(folder, team.getId() + ".dat")); markDirty(); clearCache(); } public short generateTeamUID(short id) { - while (id == 0 || id == 1 || id == 2 || teamMap.containsKey(id)) { + while (id == 0 || id == 1 || id == 2 || repository.containsTeamUid(id)) { id = (short) MathUtils.RAND.nextInt(); } @@ -737,6 +529,6 @@ public short generateTeamUID(short id) { } public boolean shouldLoadLatmod() { - return latModFolder.exists() && !get().dataFolder.exists(); + return persistence.shouldLoadLatmod(); } } diff --git a/src/main/java/serverutils/lib/data/UniversePersistence.java b/src/main/java/serverutils/lib/data/UniversePersistence.java new file mode 100644 index 000000000..3b0e5550a --- /dev/null +++ b/src/main/java/serverutils/lib/data/UniversePersistence.java @@ -0,0 +1,412 @@ +package serverutils.lib.data; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import net.minecraft.nbt.NBTTagCompound; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import serverutils.ServerUtilities; +import serverutils.ServerUtilitiesConfig; +import serverutils.data.BackwardsCompat; +import serverutils.events.ServerReloadEvent; +import serverutils.events.player.ForgePlayerLoadedEvent; +import serverutils.events.player.ForgePlayerSavedEvent; +import serverutils.events.team.ForgeTeamLoadedEvent; +import serverutils.events.team.ForgeTeamSavedEvent; +import serverutils.events.universe.UniverseLoadedEvent; +import serverutils.events.universe.UniverseSavedEvent; +import serverutils.lib.EnumReloadType; +import serverutils.lib.EnumTeamColor; +import serverutils.lib.io.DataReader; +import serverutils.lib.util.FileUtils; +import serverutils.lib.util.NBTUtils; +import serverutils.lib.util.StringUtils; + +final class UniversePersistence { + + private final Universe universe; + private UUID uuid; + private boolean dirty; + private boolean checkSaving; + private long dirtyVersion; + + UniversePersistence(Universe universe) { + this.universe = universe; + uuid = null; + dirty = false; + checkSaving = true; + dirtyVersion = 0L; + } + + void markDirty() { + dirty = true; + checkSaving = true; + dirtyVersion++; + } + + void markChildDirty() { + checkSaving = true; + } + + UUID getUuid() { + if (uuid == null) { + uuid = UUID.randomUUID(); + markDirty(); + } + + return uuid; + } + + void load() { + universe.dataFolder = new File(universe.getWorldDirectory(), "serverutilities/"); + universe.latModFolder = new File(universe.getWorldDirectory(), "LatMod"); + NBTTagCompound universeData = NBTUtils.readNBT(new File(universe.dataFolder, "universe.dat")); + + if (universeData == null) { + universeData = new NBTTagCompound(); + } + + migrateWorldDataJson(universeData); + uuid = StringUtils.fromString(universeData.getString("UUID")); + + if (uuid != null && uuid.getLeastSignificantBits() == 0L && uuid.getMostSignificantBits() == 0L) { + uuid = null; + } + + NBTTagCompound data = universeData.getCompoundTag("Data"); + new UniverseLoadedEvent.Pre(universe, data).post(); + + Map playerNbt = new HashMap<>(); + Map teamNbt = new HashMap<>(); + loadPlayers(playerNbt); + loadTeams(teamNbt); + createFakePlayerTeam(); + + new UniverseLoadedEvent.CreateServerTeams(universe).post(); + hydratePlayers(playerNbt); + hydrateTeams(teamNbt); + hydrateUniverseData(universeData); + + new UniverseLoadedEvent.Post(universe, data).post(); + + if (shouldLoadLatmod()) { + BackwardsCompat.load(); + } + + new UniverseLoadedEvent.Finished(universe).post(); + ServerUtilitiesAPI.reloadServer(universe, universe.server, EnumReloadType.CREATED, ServerReloadEvent.ALL); + } + + private void migrateWorldDataJson(NBTTagCompound universeData) { + File worldDataJsonFile = new File(universe.getWorldDirectory(), "world_data.json"); + JsonElement worldData = DataReader.get(worldDataJsonFile).safeJson(); + + if (!worldData.isJsonObject()) { + return; + } + + JsonObject jsonWorldData = worldData.getAsJsonObject(); + if (jsonWorldData.has("world_id")) { + universeData.setString("UUID", jsonWorldData.get("world_id").getAsString()); + } + + if (worldDataJsonFile.exists() && !worldDataJsonFile.delete()) { + ServerUtilities.LOGGER.warn("Failed to delete migrated world data at {}", worldDataJsonFile); + } + } + + private void loadPlayers(Map playerNbt) { + File[] files = new File(universe.dataFolder, "players").listFiles(); + if (files == null) { + return; + } + + for (File file : files) { + if (!isDataFile(file)) { + continue; + } + + try { + loadPlayer(file, playerNbt); + } catch (RuntimeException ex) { + ServerUtilities.LOGGER.error("Failed to load player data from {}", file.getAbsolutePath(), ex); + } + } + } + + private void loadPlayer(File file, Map playerNbt) { + NBTTagCompound nbt = NBTUtils.readNBT(file); + if (nbt == null) { + return; + } + + String uuidString = nbt.getString("UUID"); + if (uuidString.isEmpty()) { + uuidString = FileUtils.getBaseName(file); + FileUtils.deleteSafe(file); + } + + UUID playerId = StringUtils.fromString(uuidString); + if (playerId != null) { + playerNbt.put(playerId, nbt); + universe.registerPlayer(new ForgePlayer(universe, playerId, nbt.getString("Name"))); + } else { + ServerUtilities.LOGGER.warn("Ignoring player data with invalid UUID in {}", file.getAbsolutePath()); + } + } + + private void loadTeams(Map teamNbt) { + File[] files = new File(universe.dataFolder, "teams").listFiles(); + if (files == null) { + return; + } + + for (File file : files) { + if (!isDataFile(file)) { + continue; + } + + try { + loadTeam(file, teamNbt); + } catch (RuntimeException ex) { + ServerUtilities.LOGGER.error("Failed to load team data from {}", file.getAbsolutePath(), ex); + } + } + } + + private void loadTeam(File file, Map teamNbt) { + NBTTagCompound nbt = NBTUtils.readNBT(file); + if (nbt == null) { + return; + } + + String id = nbt.getString("ID"); + if (id.isEmpty()) { + id = FileUtils.getBaseName(file); + } + + teamNbt.put(id, nbt); + short storedUid = nbt.getShort("UID"); + ForgeTeam team = new ForgeTeam( + universe, + universe.generateTeamUID(storedUid), + id, + TeamType.NAME_MAP.get(nbt.getString("Type"))); + universe.addTeam(team); + + if (storedUid == 0) { + team.markDirty(); + } + } + + private static boolean isDataFile(File file) { + return file.isFile() && file.getName().endsWith(".dat") + && file.getName().indexOf('.') == file.getName().lastIndexOf('.'); + } + + private void createFakePlayerTeam() { + universe.fakePlayerTeam = new ForgeTeam(universe, (short) 1, "fakeplayer", TeamType.SERVER_NO_SAVE) { + + @Override + public void markDirty() { + universe.markDirty(); + } + }; + + universe.fakePlayer = new FakeForgePlayer(universe); + universe.fakePlayer.setTeamFromLoad(universe.fakePlayerTeam); + universe.fakePlayerTeam.setColor(EnumTeamColor.GRAY); + } + + private void hydratePlayers(Map playerNbt) { + for (ForgePlayer player : universe.getPlayers()) { + NBTTagCompound nbt = playerNbt.get(player.getId()); + if (nbt != null && !nbt.hasNoTags()) { + player.setTeamFromLoad(universe.getTeam(nbt.getString("TeamID"))); + player.deserializeNBT(nbt); + } + + new ForgePlayerLoadedEvent(player).post(); + } + } + + private void hydrateTeams(Map teamNbt) { + for (ForgeTeam team : universe.getTeams()) { + if (!team.type.save) { + continue; + } + + NBTTagCompound nbt = teamNbt.get(team.getId()); + if (nbt != null && !nbt.hasNoTags()) { + team.deserializeNBT(nbt); + } + + new ForgeTeamLoadedEvent(team).post(); + } + } + + private void hydrateUniverseData(NBTTagCompound universeData) { + if (universeData.hasKey("FakePlayer")) { + universe.fakePlayer.deserializeNBT(universeData.getCompoundTag("FakePlayer")); + } + + if (universeData.hasKey("FakeTeam")) { + universe.fakePlayerTeam.deserializeNBT(universeData.getCompoundTag("FakeTeam")); + } + + universe.fakePlayerTeam.setStoredOwner(universe.fakePlayer); + + if (!universeData.hasKey("GameRulesState")) { + return; + } + + NBTTagCompound gameRulesState = universeData.getCompoundTag("GameRulesState"); + universe.gameRulesFlipped = gameRulesState.getBoolean("Flipped"); + NBTTagCompound savedRules = gameRulesState.getCompoundTag("SavedRules"); + for (String key : NBTUtils.getKeySet(savedRules)) { + universe.flippedRulesSaveState.put(key, savedRules.getString(key)); + } + } + + void save() { + if (!checkSaving) { + return; + } + + boolean allSaved = saveUniverse(); + allSaved &= savePlayers(); + allSaved &= saveTeams(); + checkSaving = !allSaved || hasDirtyData(); + } + + private boolean saveUniverse() { + if (!dirty) { + return true; + } + + if (ServerUtilitiesConfig.debugging.print_more_info) { + ServerUtilities.LOGGER.info("Saving universe data"); + } + + UUID universeUuid = getUuid(); + long savingVersion = dirtyVersion; + NBTTagCompound universeData = new NBTTagCompound(); + NBTTagCompound data = new NBTTagCompound(); + new UniverseSavedEvent(universe, data).post(); + universeData.setTag("Data", data); + universeData.setString("UUID", StringUtils.fromUUID(universeUuid)); + universeData.setTag("FakePlayer", universe.fakePlayer.serializeNBT()); + universeData.setTag("FakeTeam", universe.fakePlayerTeam.serializeNBT()); + + NBTTagCompound gameRulesState = new NBTTagCompound(); + gameRulesState.setBoolean("Flipped", universe.gameRulesFlipped); + NBTTagCompound savedRules = new NBTTagCompound(); + for (Map.Entry entry : universe.flippedRulesSaveState.entrySet()) { + savedRules.setString(entry.getKey(), entry.getValue()); + } + + gameRulesState.setTag("SavedRules", savedRules); + universeData.setTag("GameRulesState", gameRulesState); + if (!NBTUtils.writeNBTChecked(new File(universe.dataFolder, "universe.dat"), universeData)) { + return false; + } + + if (dirtyVersion == savingVersion) { + dirty = false; + } + return true; + } + + private boolean savePlayers() { + boolean allSaved = true; + for (ForgePlayer player : universe.getPlayers()) { + if (!player.isDirty()) { + continue; + } + + if (ServerUtilitiesConfig.debugging.print_more_info) { + ServerUtilities.LOGGER.info("Saved player data for {}", player.getName()); + } + + NBTTagCompound nbt = player.serializeNBT(); + nbt.setString("Name", player.getName()); + nbt.setString("UUID", StringUtils.fromUUID(player.getId())); + nbt.setString("TeamID", player.getTeam().getId()); + if (NBTUtils.writeNBTChecked(player.getDataFile(), nbt)) { + player.markSaved(); + new ForgePlayerSavedEvent(player).post(); + } else { + allSaved = false; + } + } + return allSaved; + } + + private boolean hasDirtyData() { + if (dirty) { + return true; + } + for (ForgePlayer player : universe.getPlayers()) { + if (player.isDirty()) { + return true; + } + } + for (ForgeTeam team : universe.getTeams()) { + if (team.isDirty()) { + return true; + } + } + return false; + } + + private boolean saveTeams() { + boolean allSaved = true; + for (ForgeTeam team : universe.getTeams()) { + if (!team.isDirty()) { + continue; + } + + if (ServerUtilitiesConfig.debugging.print_more_info) { + ServerUtilities.LOGGER.info("Saved team data for {}", team.getId()); + } + + File file = team.getDataFile(""); + if (team.type.save && team.isValid()) { + NBTTagCompound nbt = team.serializeNBT(); + nbt.setString("ID", team.getId()); + nbt.setShort("UID", team.getUID()); + nbt.setString("Type", team.type.getName()); + if (NBTUtils.writeNBTChecked(file, nbt)) { + team.markSaved(); + new ForgeTeamSavedEvent(team).post(); + } else { + allSaved = false; + } + } else if (file.exists()) { + try { + if (FileUtils.delete(file)) { + team.markSaved(); + } else { + ServerUtilities.LOGGER.warn("Failed to delete invalid team data at {}", file); + allSaved = false; + } + } catch (RuntimeException ex) { + ServerUtilities.LOGGER.error("Failed to delete invalid team data at " + file, ex); + allSaved = false; + } + } else { + team.markSaved(); + } + } + return allSaved; + } + + boolean shouldLoadLatmod() { + return universe.latModFolder.exists() && !universe.dataFolder.exists(); + } +} diff --git a/src/main/java/serverutils/lib/data/UniverseRepository.java b/src/main/java/serverutils/lib/data/UniverseRepository.java new file mode 100644 index 000000000..bd6ded04a --- /dev/null +++ b/src/main/java/serverutils/lib/data/UniverseRepository.java @@ -0,0 +1,100 @@ +package serverutils.lib.data; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; + +final class UniverseRepository { + + private final Map players = new HashMap<>(); + private final Set vanishedPlayers = new ObjectOpenHashSet<>(); + private final Map teams = new HashMap<>(); + private final Map teamsByUid = new HashMap<>(); + private final Collection teamsView = Collections.unmodifiableCollection(teams.values()); + private ForgeTeam noneTeam; + + UniverseRepository() {} + + void initialize(Universe universe) { + if (noneTeam != null) { + throw new IllegalStateException("Universe repository has already been initialized"); + } + ForgeTeam team = new ForgeTeam(universe, (short) 0, "", TeamType.NONE, false); + noneTeam = team; + team.initializePersistence(); + } + + Map mutablePlayers() { + return players; + } + + Set mutableVanishedPlayers() { + return vanishedPlayers; + } + + Collection players() { + return players.values(); + } + + ForgePlayer getPlayer(UUID id) { + return players.get(id); + } + + void putPlayer(UUID id, ForgePlayer player) { + players.put(id, player); + } + + void removePlayer(UUID id) { + players.remove(id); + } + + Collection teams() { + return teams.values(); + } + + Collection teamsView() { + return teamsView; + } + + ForgeTeam getTeam(String id) { + return teams.get(id); + } + + ForgeTeam getTeam(short uid) { + return teamsByUid.get(uid); + } + + ForgeTeam noneTeam() { + if (noneTeam == null) { + throw new IllegalStateException("Universe repository is not initialized"); + } + return noneTeam; + } + + void addTeam(ForgeTeam team) { + ForgeTeam teamWithId = teams.get(team.getId()); + ForgeTeam teamWithUid = teamsByUid.get(team.getUID()); + if (teamWithId != null && teamWithId != team) { + throw new IllegalArgumentException("Duplicate team ID: " + team.getId()); + } + if (teamWithUid != null && teamWithUid != team) { + throw new IllegalArgumentException("Duplicate team UID: " + team.getUIDCode()); + } + teamsByUid.put(team.getUID(), team); + teams.put(team.getId(), team); + } + + void removeTeam(ForgeTeam team) { + teamsByUid.remove(team.getUID(), team); + teams.remove(team.getId(), team); + } + + boolean containsTeamUid(short uid) { + return teamsByUid.containsKey(uid); + } +} diff --git a/src/main/java/serverutils/lib/data/UniverseTaskScheduler.java b/src/main/java/serverutils/lib/data/UniverseTaskScheduler.java new file mode 100644 index 000000000..f24c70292 --- /dev/null +++ b/src/main/java/serverutils/lib/data/UniverseTaskScheduler.java @@ -0,0 +1,39 @@ +package serverutils.lib.data; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import serverutils.task.Task; + +final class UniverseTaskScheduler { + + private final List activeTasks = new ArrayList<>(); + private final List queuedTasks = new ArrayList<>(); + + void schedule(Universe universe, Task task, boolean condition) { + if (!condition || task.getNextTime() <= -1) return; + task.queueNotifications(universe); + queuedTasks.add(task); + } + + void tick(Universe universe) { + activeTasks.addAll(queuedTasks); + queuedTasks.clear(); + + Iterator taskIterator = activeTasks.iterator(); + while (taskIterator.hasNext()) { + Task task = taskIterator.next(); + if (!task.isComplete(universe)) { + continue; + } + + task.execute(universe); + if (task.isRepeatable()) { + task.setNextTime(System.currentTimeMillis() + task.getInterval()); + } else { + taskIterator.remove(); + } + } + } +} diff --git a/src/main/java/serverutils/lib/gui/CheckBoxList.java b/src/main/java/serverutils/lib/gui/CheckBoxList.java index 3ea2a871a..7101719e0 100644 --- a/src/main/java/serverutils/lib/gui/CheckBoxList.java +++ b/src/main/java/serverutils/lib/gui/CheckBoxList.java @@ -136,7 +136,8 @@ public void addMouseOverText(List list) { public List getActiveEntries() { if (getGui() instanceof GuiButtonListBase btnList && btnList.hasSearchBox() && !btnList.getTextInSearchBox().isEmpty()) { - return entries.stream().filter(entry -> entry.name.toLowerCase().contains(btnList.getTextInSearchBox())) + return entries.stream().filter( + entry -> entry.name.toLowerCase(java.util.Locale.ROOT).contains(btnList.getTextInSearchBox())) .collect(Collectors.toList()); } return entries; diff --git a/src/main/java/serverutils/lib/gui/GuiBase.java b/src/main/java/serverutils/lib/gui/GuiBase.java index a493b9878..c6862072e 100644 --- a/src/main/java/serverutils/lib/gui/GuiBase.java +++ b/src/main/java/serverutils/lib/gui/GuiBase.java @@ -381,7 +381,7 @@ public boolean handleClick(String scheme, String path) { try { FilesUtil.openUri(uri); } catch (Exception ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to open URI " + uri, ex); } } Minecraft.getMinecraft().displayGuiScreen(currentScreen); @@ -392,7 +392,7 @@ public boolean handleClick(String scheme, String path) { return true; } catch (Exception ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to open link " + scheme + ':' + path, ex); } return false; @@ -402,7 +402,7 @@ public boolean handleClick(String scheme, String path) { FilesUtil.openUri(new URI("file:" + path)); return true; } catch (Exception ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to open file " + path, ex); } return false; diff --git a/src/main/java/serverutils/lib/gui/GuiHelper.java b/src/main/java/serverutils/lib/gui/GuiHelper.java index c398da433..355ceff1e 100644 --- a/src/main/java/serverutils/lib/gui/GuiHelper.java +++ b/src/main/java/serverutils/lib/gui/GuiHelper.java @@ -202,7 +202,7 @@ public static boolean drawItem(ItemStack stack, double x, double y, double scale renderItem.renderItemOverlayIntoGUI(fontRenderer, textureManager, stack, 0, 0); } } catch (Exception ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to render an item stack", ex); result = false; } diff --git a/src/main/java/serverutils/lib/gui/Panel.java b/src/main/java/serverutils/lib/gui/Panel.java index 68059c637..d30fe3e72 100644 --- a/src/main/java/serverutils/lib/gui/Panel.java +++ b/src/main/java/serverutils/lib/gui/Panel.java @@ -73,7 +73,7 @@ public void refreshWidgets() { } catch (MismatchingParentPanelException ex) { ServerUtilities.LOGGER.error(ex.getMessage()); } catch (Exception ex) { - ex.printStackTrace(); + ServerUtilities.LOGGER.error("Failed to add a widget to the panel", ex); } // alignWidgets(); diff --git a/src/main/java/serverutils/lib/gui/misc/GuiButtonListBase.java b/src/main/java/serverutils/lib/gui/misc/GuiButtonListBase.java index 3a06651a5..d6d603d36 100644 --- a/src/main/java/serverutils/lib/gui/misc/GuiButtonListBase.java +++ b/src/main/java/serverutils/lib/gui/misc/GuiButtonListBase.java @@ -46,7 +46,7 @@ public void setHasSearchBox(boolean v) { } public String getFilterText(Widget widget) { - return widget.getTitle().toLowerCase(); + return widget.getTitle().toLowerCase(java.util.Locale.ROOT); } @Override @@ -65,7 +65,7 @@ protected Panel createButtonPanel() { @Override public void add(Widget widget) { if (!hasSearchBox || searchBox.getText().isEmpty() - || getFilterText(widget).contains(searchBox.getText().toLowerCase()) + || getFilterText(widget).contains(searchBox.getText().toLowerCase(java.util.Locale.ROOT)) || widget instanceof CheckBoxList) { super.add(widget); } @@ -162,7 +162,7 @@ public boolean hasSearchBox() { } public String getTextInSearchBox() { - return searchBox.getText().toLowerCase(); + return searchBox.getText().toLowerCase(java.util.Locale.ROOT); } public void focus() { diff --git a/src/main/java/serverutils/lib/gui/misc/GuiSelectItemStack.java b/src/main/java/serverutils/lib/gui/misc/GuiSelectItemStack.java index b38ee68f9..2b7ef32a4 100644 --- a/src/main/java/serverutils/lib/gui/misc/GuiSelectItemStack.java +++ b/src/main/java/serverutils/lib/gui/misc/GuiSelectItemStack.java @@ -69,7 +69,7 @@ public boolean shouldAdd(String search, String mod) { return GameData.getItemRegistry().getNameForObject(stack.getItem()).contains(mod); } - return stack.getDisplayName().toLowerCase().contains(search); + return stack.getDisplayName().toLowerCase(java.util.Locale.ROOT).contains(search); } @Override @@ -341,7 +341,7 @@ private class ThreadItemList extends Thread { public ThreadItemList() { super("Item Search Thread"); setDaemon(true); - search = searchBox.getText().toLowerCase(); + search = searchBox.getText().toLowerCase(java.util.Locale.ROOT); } @Override @@ -534,9 +534,7 @@ public void onClosed() { private void stopSearch() { if (threadItemList != null) { - try { - threadItemList.interrupt(); - } catch (Exception ex) {} + threadItemList.interrupt(); } threadItemList = null; diff --git a/src/main/java/serverutils/lib/icon/Icon.java b/src/main/java/serverutils/lib/icon/Icon.java index 2ae8d8298..99541fd3e 100644 --- a/src/main/java/serverutils/lib/icon/Icon.java +++ b/src/main/java/serverutils/lib/icon/Icon.java @@ -221,7 +221,9 @@ private static Icon getIcon0(String id) { case "file": try { return new URLImageIcon(new URI(id)); - } catch (Exception ex) {} + } catch (Exception ex) { + serverutils.ServerUtilities.LOGGER.debug("Ignoring invalid icon URI " + id, ex); + } case "player": return new PlayerHeadIcon(StringUtils.fromString(ida[1])); case "hollow_rectangle": diff --git a/src/main/java/serverutils/lib/icon/IconRenderer.java b/src/main/java/serverutils/lib/icon/IconRenderer.java index 48673d59c..4f3e83491 100644 --- a/src/main/java/serverutils/lib/icon/IconRenderer.java +++ b/src/main/java/serverutils/lib/icon/IconRenderer.java @@ -124,72 +124,93 @@ public static void render() { mc.entityRenderer.setupOverlayRendering(); RenderHelper.enableGUIStandardItemLighting(); - float scale = size / (16F * res.getScaleFactor()); - GlStateManager.translate(0, 0, -(scale * 100F)); - - GlStateManager.scale(scale, scale, scale); - RenderItem renderItem = RenderItem.getInstance(); float oldZLevel = renderItem.zLevel; - renderItem.zLevel = -50; - - GlStateManager.enableRescaleNormal(); - GlStateManager.enableColorMaterial(); - GlStateManager.enableDepth(); - GlStateManager.enableBlend(); - GlStateManager - .tryBlendFuncSeparate(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA, GL11.GL_SRC_ALPHA, GL11.GL_ONE); - GlStateManager.blendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); - GlStateManager.disableAlpha(); - - int[] pixels = new int[size * size]; - AffineTransform at = new AffineTransform(); - at.concatenate(AffineTransform.getScaleInstance(1, -1)); - at.concatenate(AffineTransform.getTranslateInstance(0, -size)); - BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); - - for (IconCallbackPair pair : queued) { - GlStateManager.pushMatrix(); - GlStateManager.clearColor(0F, 0F, 0F, 0F); - GlStateManager.clear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT); - pair.icon.drawStatic(0, 0, 16, 16); - GlStateManager.popMatrix(); - - try { - ByteBuffer buf = BufferUtils.createByteBuffer(size * size * 4); - GL11.glReadBuffer(GL11.GL_BACK); - GlStateManager.glGetError(); // FIXME: For some reason it throws error here, but it still works. Calling - // this to not spam console - GL11.glReadPixels( - 0, - Minecraft.getMinecraft().displayHeight - size, - size, - size, - GL12.GL_BGRA, - GL11.GL_UNSIGNED_BYTE, - buf); - buf.asIntBuffer().get(pixels); - img.setRGB(0, 0, size, size, pixels, 0, size); - BufferedImage flipped = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); - Graphics2D g = flipped.createGraphics(); - g.transform(at); - g.drawImage(img, 0, 0, null); - g.dispose(); - pixels = flipped.getRGB(0, 0, size, size, pixels, 0, size); - - BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); - image.setRGB(0, 0, size, size, pixels, 0, size); - imageCache.put(pair.icon, image); - pair.callback.imageLoaded(true, image); - } catch (Exception ex) { - ex.printStackTrace(); + GlStateManager.pushMatrix(); + try { + float scale = size / (16F * res.getScaleFactor()); + GlStateManager.translate(0, 0, -(scale * 100F)); + GlStateManager.scale(scale, scale, scale); + renderItem.zLevel = -50; + + GlStateManager.enableRescaleNormal(); + GlStateManager.enableColorMaterial(); + GlStateManager.enableDepth(); + GlStateManager.enableBlend(); + GlStateManager.tryBlendFuncSeparate( + GL11.GL_SRC_ALPHA, + GL11.GL_ONE_MINUS_SRC_ALPHA, + GL11.GL_SRC_ALPHA, + GL11.GL_ONE); + GlStateManager.blendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + GlStateManager.disableAlpha(); + + int[] pixels = new int[size * size]; + AffineTransform at = new AffineTransform(); + at.concatenate(AffineTransform.getScaleInstance(1, -1)); + at.concatenate(AffineTransform.getTranslateInstance(0, -size)); + BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + + for (IconCallbackPair pair : queued) { + Image renderedImage = null; + try { + GlStateManager.pushMatrix(); + try { + GlStateManager.clearColor(0F, 0F, 0F, 0F); + GlStateManager.clear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT); + pair.icon.drawStatic(0, 0, 16, 16); + } finally { + GlStateManager.popMatrix(); + } + + ByteBuffer buf = BufferUtils.createByteBuffer(size * size * 4); + GL11.glReadBuffer(GL11.GL_BACK); + GlStateManager.glGetError(); // FIXME: For some reason it throws error here, but it still works. + // Calling + // this to not spam console + GL11.glReadPixels( + 0, + Minecraft.getMinecraft().displayHeight - size, + size, + size, + GL12.GL_BGRA, + GL11.GL_UNSIGNED_BYTE, + buf); + buf.asIntBuffer().get(pixels); + img.setRGB(0, 0, size, size, pixels, 0, size); + BufferedImage flipped = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = flipped.createGraphics(); + try { + g.transform(at); + g.drawImage(img, 0, 0, null); + } finally { + g.dispose(); + } + pixels = flipped.getRGB(0, 0, size, size, pixels, 0, size); + + BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + image.setRGB(0, 0, size, size, pixels, 0, size); + imageCache.put(pair.icon, image); + renderedImage = image; + } catch (Exception ex) { + serverutils.ServerUtilities.LOGGER.error("Failed to load an icon image", ex); + } + + try { + pair.callback.imageLoaded(true, renderedImage); + } catch (RuntimeException ex) { + serverutils.ServerUtilities.LOGGER.error("Icon callback failed", ex); + } } + } finally { + GlStateManager.disableLighting(); + GlStateManager.disableColorMaterial(); + GlStateManager.disableDepth(); + GlStateManager.disableBlend(); + GlStateManager.disableRescaleNormal(); + GlStateManager.enableAlpha(); + renderItem.zLevel = oldZLevel; + GlStateManager.popMatrix(); } - - GlStateManager.disableLighting(); - GlStateManager.disableColorMaterial(); - GlStateManager.disableDepth(); - GlStateManager.disableBlend(); - renderItem.zLevel = oldZLevel; } } diff --git a/src/main/java/serverutils/lib/icon/PlayerHeadIcon.java b/src/main/java/serverutils/lib/icon/PlayerHeadIcon.java index feee8e6ce..03092fd1e 100644 --- a/src/main/java/serverutils/lib/icon/PlayerHeadIcon.java +++ b/src/main/java/serverutils/lib/icon/PlayerHeadIcon.java @@ -95,7 +95,9 @@ public void run() { } } } - } catch (Exception ignored) {} + } catch (Exception ex) { + serverutils.ServerUtilities.LOGGER.debug("Failed to resolve player skin metadata", ex); + } if (imageUrl.isEmpty()) { return; @@ -106,7 +108,9 @@ public void run() { DataReader .get(new URL(imageUrl), DataReader.PNG, Minecraft.getMinecraft().getProxy()) .image()); - } catch (Exception ignored) {} + } catch (Exception ex) { + serverutils.ServerUtilities.LOGGER.debug("Failed to download a player skin", ex); + } } }; diff --git a/src/main/java/serverutils/lib/icon/URLImageIcon.java b/src/main/java/serverutils/lib/icon/URLImageIcon.java index 9b5be30a6..cc18080b6 100644 --- a/src/main/java/serverutils/lib/icon/URLImageIcon.java +++ b/src/main/java/serverutils/lib/icon/URLImageIcon.java @@ -58,7 +58,7 @@ public void bindTexture() { try { file = new File(uri.getPath()); } catch (Exception ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.warn("Failed to resolve icon URI " + uri, ex); } } diff --git a/src/main/java/serverutils/lib/io/DataReader.java b/src/main/java/serverutils/lib/io/DataReader.java index cd79284a2..5d63e4864 100644 --- a/src/main/java/serverutils/lib/io/DataReader.java +++ b/src/main/java/serverutils/lib/io/DataReader.java @@ -6,6 +6,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; +import java.net.MalformedURLException; import java.net.Proxy; import java.net.URI; import java.net.URL; @@ -67,8 +68,8 @@ public static DataReader get(URI uri, Proxy proxy) { case "https": try { return get(uri.toURL(), "", proxy); - } catch (Exception ex) { - ex.printStackTrace(); + } catch (MalformedURLException ex) { + throw new IllegalArgumentException("Invalid HTTP data URI: " + uri, ex); } case "file": return get(new File(uri.getPath())); @@ -85,7 +86,7 @@ private static DataReader getMCResource(URI uri) { try { return get(Minecraft.getMinecraft().getResourceManager().getResource(new ResourceLocation(uri.getPath()))); } catch (Throwable ex) { - throw new IllegalArgumentException("Failed to load minecraft resource: " + uri.getPath() + "!"); + throw new IllegalArgumentException("Failed to load minecraft resource: " + uri.getPath() + "!", ex); } } diff --git a/src/main/java/serverutils/lib/io/HttpDataReader.java b/src/main/java/serverutils/lib/io/HttpDataReader.java index 85791471a..23e8bb030 100644 --- a/src/main/java/serverutils/lib/io/HttpDataReader.java +++ b/src/main/java/serverutils/lib/io/HttpDataReader.java @@ -13,10 +13,6 @@ import javax.annotation.Nullable; import javax.imageio.ImageIO; -import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; import com.google.gson.JsonElement; @@ -25,6 +21,9 @@ public class HttpDataReader extends DataReader { + private static final int CONNECT_TIMEOUT_MILLIS = 15_000; + private static final int READ_TIMEOUT_MILLIS = 30_000; + public interface HttpDataOutput { void writeData(OutputStream output) throws Exception; @@ -43,7 +42,9 @@ public StringOutput(Iterable text) { @Override public void writeData(OutputStream output) throws Exception { - new OutputStreamWriter(output).write(string); + OutputStreamWriter writer = new OutputStreamWriter(output, StandardCharsets.UTF_8); + writer.write(string); + writer.flush(); } } } @@ -82,55 +83,43 @@ public String toString() { private HttpURLConnection getConnection() throws Exception { HttpURLConnection connection = (HttpURLConnection) url.openConnection(proxy); + boolean ready = false; + + try { + connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS); + connection.setReadTimeout(READ_TIMEOUT_MILLIS); + connection.setRequestMethod(requestMethod.name()); + connection.setRequestProperty( + "User-Agent", + "Mozilla/5.0 (Windows; U; Windows NT 6.0; en-GB; rv:1.9.0.3) Gecko/2008092417 Firefox/3.0.3"); + + if (!contentType.isEmpty()) { + connection.setRequestProperty("Content-Type", contentType); + } - if (connection instanceof HttpsURLConnection) { - try { - TrustManager[] trustAllCerts = new TrustManager[] { new X509TrustManager() { - - @Override - public java.security.cert.X509Certificate[] getAcceptedIssuers() { - return null; - } - - @Override - public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) {} - - @Override - public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) {} - } }; - - SSLContext sc = SSLContext.getInstance("SSL"); - sc.init(null, trustAllCerts, new java.security.SecureRandom()); - ((HttpsURLConnection) connection).setSSLSocketFactory(sc.getSocketFactory()); - } catch (Exception e) {} - } - - connection.setRequestMethod(requestMethod.name()); - connection.setRequestProperty( - "User-Agent", - "Mozilla/5.0 (Windows; U; Windows NT 6.0; en-GB; rv:1.9.0.3) Gecko/2008092417 Firefox/3.0.3"); - - if (!contentType.isEmpty()) { - connection.setRequestProperty("Content-Type", contentType); - } + connection.setDoInput(true); - connection.setDoInput(true); + if (data != null) { + connection.setDoOutput(true); + try (OutputStream output = connection.getOutputStream()) { + data.writeData(output); + output.flush(); + } + } - if (data != null) { - connection.setDoOutput(true); - OutputStream os = connection.getOutputStream(); - data.writeData(os); - os.flush(); - os.close(); - } + int responseCode = connection.getResponseCode(); - int responseCode = connection.getResponseCode(); + if (responseCode / 100 != 2) { + throw new ConnectionNotOKException(responseCode); + } - if (responseCode / 100 != 2) { - throw new ConnectionNotOKException(responseCode); + ready = true; + return connection; + } finally { + if (!ready) { + connection.disconnect(); + } } - - return connection; } @Override diff --git a/src/main/java/serverutils/lib/math/Ticks.java b/src/main/java/serverutils/lib/math/Ticks.java index aabb69a6e..50b1511c2 100644 --- a/src/main/java/serverutils/lib/math/Ticks.java +++ b/src/main/java/serverutils/lib/math/Ticks.java @@ -68,7 +68,7 @@ public static Ticks get(String value) throws NumberFormatException { default -> ticks.add(Long.parseLong(s)); }; } catch (Exception ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.warn("Ignoring invalid tick duration '" + s + "'", ex); } } } diff --git a/src/main/java/serverutils/lib/util/BackupGlobUtils.java b/src/main/java/serverutils/lib/util/BackupGlobUtils.java new file mode 100644 index 000000000..16b94b95c --- /dev/null +++ b/src/main/java/serverutils/lib/util/BackupGlobUtils.java @@ -0,0 +1,49 @@ +package serverutils.lib.util; + +import java.nio.file.Path; +import java.nio.file.Paths; + +/** Applies a world folder name to a configured backup glob without treating the name itself as glob syntax. */ +public final class BackupGlobUtils { + + private BackupGlobUtils() {} + + public static String substituteLiteralPath(String pattern, String worldName) { + return pattern.replace("$WORLDNAME", worldName); + } + + public static String substituteGlob(String pattern, String worldName) { + return pattern.replace("$WORLDNAME", escapeGlobLiteral(worldName)); + } + + public static Path searchRoot(String pattern, String worldName) { + int firstWildcardIndex = pattern.indexOf('*'); + if (firstWildcardIndex < 0) { + return Paths.get(substituteLiteralPath(pattern, worldName)); + } + + String literalPrefix = substituteLiteralPath(pattern.substring(0, firstWildcardIndex), worldName); + Path root = Paths.get(literalPrefix); + if (firstWildcardIndex != 0 && pattern.charAt(firstWildcardIndex - 1) != '/') { + root = root.getParent(); + } + return root == null ? Paths.get("") : root; + } + + private static String escapeGlobLiteral(String value) { + StringBuilder escaped = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char character = value.charAt(i); + if (character == '\\' || character == '*' + || character == '?' + || character == '[' + || character == ']' + || character == '{' + || character == '}') { + escaped.append('\\'); + } + escaped.append(character); + } + return escaped.toString(); + } +} diff --git a/src/main/java/serverutils/lib/util/FileUtils.java b/src/main/java/serverutils/lib/util/FileUtils.java index b0b5a30bf..c352ed11d 100644 --- a/src/main/java/serverutils/lib/util/FileUtils.java +++ b/src/main/java/serverutils/lib/util/FileUtils.java @@ -47,7 +47,7 @@ public static File newFile(File file) { } file.createNewFile(); } catch (Exception e) { - e.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to create file " + file.getAbsolutePath(), e); } } @@ -55,24 +55,20 @@ public static File newFile(File file) { } public static void save(File file, Iterable list) throws Exception { - OutputStreamWriter fw = new OutputStreamWriter(new FileOutputStream(newFile(file)), StandardCharsets.UTF_8); - BufferedWriter br = new BufferedWriter(fw); - - for (String s : list) { - br.write(s); - br.write('\n'); + try (BufferedWriter writer = new BufferedWriter( + new OutputStreamWriter(new FileOutputStream(newFile(file)), StandardCharsets.UTF_8))) { + for (String s : list) { + writer.write(s); + writer.write('\n'); + } } - - br.close(); - fw.close(); } public static void save(File file, String string) throws Exception { - OutputStreamWriter fw = new OutputStreamWriter(new FileOutputStream(newFile(file)), StandardCharsets.UTF_8); - BufferedWriter br = new BufferedWriter(fw); - br.write(string); - br.close(); - fw.close(); + try (BufferedWriter writer = new BufferedWriter( + new OutputStreamWriter(new FileOutputStream(newFile(file)), StandardCharsets.UTF_8))) { + writer.write(string); + } } public static void saveSafe(final File file, final Iterable list) { @@ -80,7 +76,7 @@ public static void saveSafe(final File file, final Iterable list) { try { save(file, list); } catch (Exception ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to save string list to " + file.getAbsolutePath(), ex); } return false; @@ -92,7 +88,7 @@ public static void saveSafe(final File file, final String string) { try { save(file, string); } catch (Exception ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to save text to " + file.getAbsolutePath(), ex); } return false; @@ -155,11 +151,11 @@ private static long getSize0(File file) { public static String getSizeString(double b) { if (b >= GB.getSize()) { - return String.format("%.1fGB", b / (double) GB.getSize()); + return String.format(java.util.Locale.ROOT, "%.1fGB", b / (double) GB.getSize()); } else if (b >= MB.getSize()) { - return String.format("%.1fMB", b / (double) MB.getSize()); + return String.format(java.util.Locale.ROOT, "%.1fMB", b / (double) MB.getSize()); } else if (b >= KB.getSize()) { - return String.format("%.1fKB", b / (double) KB.getSize()); + return String.format(java.util.Locale.ROOT, "%.1fKB", b / (double) KB.getSize()); } return b + "B"; @@ -213,10 +209,10 @@ public static void deleteSafe(File file) { ThreadedFileIOBase.threadedIOInstance.queueIO(() -> { try { if (file.exists() && !delete(file)) { - System.err.println("Failed to safely delete " + file.getAbsolutePath()); + serverutils.ServerUtilities.LOGGER.warn("Failed to safely delete {}", file.getAbsolutePath()); } } catch (Exception ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to safely delete " + file.getAbsolutePath(), ex); } return false; diff --git a/src/main/java/serverutils/lib/util/IdentifierUtils.java b/src/main/java/serverutils/lib/util/IdentifierUtils.java new file mode 100644 index 000000000..ee763b4e9 --- /dev/null +++ b/src/main/java/serverutils/lib/util/IdentifierUtils.java @@ -0,0 +1,61 @@ +package serverutils.lib.util; + +import java.util.Locale; +import java.util.regex.Pattern; + +import serverutils.lib.io.Bits; + +public final class IdentifierUtils { + + private static final Pattern NOT_SNAKE_CASE_PATTERN = Pattern.compile("[^a-z0-9_]"); + private static final Pattern REPEATING_UNDERSCORE_PATTERN = Pattern.compile("_{2,}"); + + private IdentifierUtils() {} + + public static String toSnakeCase(String value) { + return value.isEmpty() ? value + : REPEATING_UNDERSCORE_PATTERN.matcher( + NOT_SNAKE_CASE_PATTERN.matcher(StringUtils.unformatted(value).toLowerCase(Locale.ROOT)) + .replaceAll("_")) + .replaceAll("_"); + } + + public static String normalize(Object value, int flags) { + String id = StringUtils.getRawID(value); + if (flags == 0) { + return id; + } + + boolean fix = Bits.getFlag(flags, StringUtils.FLAG_ID_FIX); + if (!fix && id.isEmpty() && !Bits.getFlag(flags, StringUtils.FLAG_ID_ALLOW_EMPTY)) { + throw new NullPointerException("ID can't be empty!"); + } + + if (Bits.getFlag(flags, StringUtils.FLAG_ID_ONLY_LOWERCASE) + || Bits.getFlag(flags, StringUtils.FLAG_ID_ONLY_UNDERLINE)) { + String lowercase = id.toLowerCase(Locale.ROOT); + if (fix) { + id = lowercase; + } else if (!id.equals(lowercase)) { + throw new IllegalArgumentException("ID can't contain uppercase characters!"); + } + } + + if (Bits.getFlag(flags, StringUtils.FLAG_ID_ONLY_UNDERLINE)) { + boolean allowPeriod = Bits.getFlag(flags, 16); + char[] chars = id.toCharArray(); + for (int i = 0; i < chars.length; i++) { + if (!(chars[i] == '.' && allowPeriod || StringUtils.isTextChar(chars[i], true))) { + if (fix) { + chars[i] = '_'; + } else { + throw new IllegalArgumentException("ID contains invalid character: '" + chars[i] + "'!"); + } + } + } + id = new String(chars); + } + + return id; + } +} diff --git a/src/main/java/serverutils/lib/util/JsonUtils.java b/src/main/java/serverutils/lib/util/JsonUtils.java index a84d6a3fe..3ba7c0fec 100644 --- a/src/main/java/serverutils/lib/util/JsonUtils.java +++ b/src/main/java/serverutils/lib/util/JsonUtils.java @@ -93,7 +93,7 @@ public static void toJson(Writer writer, @Nullable JsonElement element, boolean try { writer.write("null"); } catch (Exception ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to write JSON null value", ex); } return; @@ -127,7 +127,7 @@ public static void toJson(File file, @Nullable JsonElement element, boolean pret StandardCharsets.UTF_8); BufferedWriter writer = new BufferedWriter(output)) { toJson(writer, element, prettyPrinting); } catch (Exception ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to write JSON file " + file.getAbsolutePath(), ex); } } @@ -663,14 +663,14 @@ public static JsonElement fromJson(Reader json) { } public static JsonElement fromJson(File json) { - try { - if (json == null || !json.exists()) return JsonNull.INSTANCE; - BufferedReader reader = new BufferedReader(new FileReader(json)); - JsonElement e = fromJson(reader); - reader.close(); - return e; - } catch (Exception ex) {} - return JsonNull.INSTANCE; + if (json == null || !json.exists()) return JsonNull.INSTANCE; + + try (BufferedReader reader = new BufferedReader(new FileReader(json))) { + return fromJson(reader); + } catch (Exception ex) { + serverutils.ServerUtilities.LOGGER.warn("Failed to read JSON file " + json.getAbsolutePath(), ex); + return JsonNull.INSTANCE; + } } public static void copy(JsonObject from, JsonObject to) { diff --git a/src/main/java/serverutils/lib/util/MOTDFormatter.java b/src/main/java/serverutils/lib/util/MOTDFormatter.java index fd36158b7..79903c712 100644 --- a/src/main/java/serverutils/lib/util/MOTDFormatter.java +++ b/src/main/java/serverutils/lib/util/MOTDFormatter.java @@ -1,6 +1,6 @@ package serverutils.lib.util; -import java.text.DecimalFormat; +import java.util.concurrent.atomic.AtomicLong; import net.minecraft.server.MinecraftServer; import net.minecraft.util.ChatComponentText; @@ -11,15 +11,10 @@ public class MOTDFormatter { - private static final DecimalFormat TPS_FORMAT = new DecimalFormat("0.0"); - private static final DecimalFormat MEMORY_FORMAT = new DecimalFormat("0"); - - private static long serverStartTime = 0L; + private static final AtomicLong SERVER_START_TIME = new AtomicLong(); public static IChatComponent buildMOTD(MinecraftServer server) { - if (serverStartTime == 0L) { - serverStartTime = System.currentTimeMillis(); - } + SERVER_START_TIME.compareAndSet(0L, System.currentTimeMillis()); if (!ServerUtilitiesConfig.motd.enabled) { return new ChatComponentText(server.getMOTD()); @@ -49,13 +44,11 @@ private static String processVariables(String text, MinecraftServer server) { long freeMemory = runtime.freeMemory() / 1024 / 1024; long usedMemory = totalMemory - freeMemory; - result = result.replace( - "{memory}", - MEMORY_FORMAT.format(usedMemory) + "/" + MEMORY_FORMAT.format(maxMemory) + "MB"); + result = result.replace("{memory}", Long.toString(usedMemory) + "/" + maxMemory + "MB"); } if (result.contains("{uptime}")) { - long uptimeMillis = System.currentTimeMillis() - serverStartTime; + long uptimeMillis = System.currentTimeMillis() - SERVER_START_TIME.get(); result = result.replace("{uptime}", formatUptime(uptimeMillis)); } @@ -77,7 +70,7 @@ private static String calculateTPS(MinecraftServer server) { // Cap at 20.0 TPS maximum double tps = Math.min(20.0, 1000.0 / avgTickTimeMs); - return TPS_FORMAT.format(tps); + return NumberFormatUtils.formatRoundedOneDecimal(tps); } catch (Exception e) { // Fallback if calculation fails return "N/A"; diff --git a/src/main/java/serverutils/lib/util/NBTUtils.java b/src/main/java/serverutils/lib/util/NBTUtils.java index 78a778ab0..6ed5bcbf3 100644 --- a/src/main/java/serverutils/lib/util/NBTUtils.java +++ b/src/main/java/serverutils/lib/util/NBTUtils.java @@ -3,7 +3,14 @@ import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; +import java.io.FilterOutputStream; +import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.Set; import javax.annotation.Nullable; @@ -36,10 +43,56 @@ public static void renameTag(NBTTagCompound nbt, String oldName, String newName) } public static void writeNBT(File file, NBTTagCompound tag) { - try (FileOutputStream stream = new FileOutputStream(FileUtils.newFile(file))) { - CompressedStreamTools.writeCompressed(tag, stream); + writeNBTChecked(file, tag); + } + + /** + * Writes an NBT file through a sibling temporary file so a failed write never truncates the last valid copy. + * + * @return {@code true} only after the completed file has replaced the target + */ + public static boolean writeNBTChecked(File file, NBTTagCompound tag) { + Path target = file.toPath().toAbsolutePath().normalize(); + Path parent = target.getParent(); + Path temporary = null; + + try { + if (parent == null) { + throw new IOException("NBT file has no parent directory: " + target); + } + + Files.createDirectories(parent); + temporary = Files.createTempFile(parent, target.getFileName().toString(), ".tmp"); + try (FileOutputStream stream = new FileOutputStream(temporary.toFile())) { + OutputStream nonClosing = new FilterOutputStream(stream) { + + @Override + public void close() throws IOException { + flush(); + } + }; + CompressedStreamTools.writeCompressed(tag, nonClosing); + stream.getChannel().force(true); + } + + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + temporary = null; + return true; } catch (Exception ex) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to write NBT file " + file.getAbsolutePath(), ex); + return false; + } finally { + if (temporary != null) { + try { + Files.deleteIfExists(temporary); + } catch (IOException ex) { + serverutils.ServerUtilities.LOGGER.warn("Failed to delete temporary NBT file {}", temporary, ex); + } + } } } @@ -62,6 +115,8 @@ public static NBTTagCompound readNBT(File file) { try { return CompressedStreamTools.read(file); } catch (Exception ex1) { + ex1.addSuppressed(ex); + serverutils.ServerUtilities.LOGGER.error("Failed to read NBT file " + file.getAbsolutePath(), ex1); return null; } } diff --git a/src/main/java/serverutils/lib/util/NumberFormatUtils.java b/src/main/java/serverutils/lib/util/NumberFormatUtils.java new file mode 100644 index 000000000..031304a95 --- /dev/null +++ b/src/main/java/serverutils/lib/util/NumberFormatUtils.java @@ -0,0 +1,46 @@ +package serverutils.lib.util; + +import java.math.RoundingMode; +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.Locale; + +public final class NumberFormatUtils { + + private static final ThreadLocal ONE_DECIMAL = ThreadLocal + .withInitial(() -> createFormatter("#0.0")); + private static final ThreadLocal TWO_DECIMALS = ThreadLocal + .withInitial(() -> createFormatter("#0.00")); + private static final ThreadLocal ROUNDED_ONE_DECIMAL = ThreadLocal + .withInitial(() -> createFormatter("#0.0", RoundingMode.HALF_EVEN)); + + private NumberFormatUtils() {} + + private static DecimalFormat createFormatter(String pattern) { + return createFormatter(pattern, RoundingMode.DOWN); + } + + private static DecimalFormat createFormatter(String pattern, RoundingMode roundingMode) { + DecimalFormat format = new DecimalFormat(pattern, DecimalFormatSymbols.getInstance(Locale.ROOT)); + format.setRoundingMode(roundingMode); + return format; + } + + public static String formatOneDecimal(double value) { + String formatted = ONE_DECIMAL.get().format(value); + return formatted.endsWith(".0") ? formatted.substring(0, formatted.length() - 2) : formatted; + } + + public static String formatFixedOneDecimal(double value) { + return ONE_DECIMAL.get().format(value); + } + + public static String formatRoundedOneDecimal(double value) { + return ROUNDED_ONE_DECIMAL.get().format(value); + } + + public static String formatTwoDecimals(double value) { + String formatted = TWO_DECIMALS.get().format(value); + return formatted.endsWith(".00") ? formatted.substring(0, formatted.length() - 3) : formatted; + } +} diff --git a/src/main/java/serverutils/lib/util/StringUtils.java b/src/main/java/serverutils/lib/util/StringUtils.java index fcb291cb8..0ff8ea4c2 100644 --- a/src/main/java/serverutils/lib/util/StringUtils.java +++ b/src/main/java/serverutils/lib/util/StringUtils.java @@ -6,11 +6,13 @@ import java.io.Reader; import java.math.RoundingMode; import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.regex.Pattern; @@ -21,7 +23,6 @@ import net.minecraft.util.IChatComponent; import serverutils.ServerUtilities; -import serverutils.lib.io.Bits; public class StringUtils { @@ -44,14 +45,19 @@ public class StringUtils { public static final Comparator ID_COMPARATOR = (o1, o2) -> getID(o1, FLAG_ID_FIX) .compareToIgnoreCase(getID(o2, FLAG_ID_FIX)); + @Deprecated public static final Map TEMP_MAP = new HashMap<>(); - public static final DecimalFormat DOUBLE_FORMATTER_00 = new DecimalFormat("#0.00"); - public static final DecimalFormat DOUBLE_FORMATTER_0 = new DecimalFormat("#0.0"); + @Deprecated + public static final DecimalFormat DOUBLE_FORMATTER_00 = new DecimalFormat( + "#0.00", + DecimalFormatSymbols.getInstance(Locale.ROOT)); + @Deprecated + public static final DecimalFormat DOUBLE_FORMATTER_0 = new DecimalFormat( + "#0.0", + DecimalFormatSymbols.getInstance(Locale.ROOT)); + @Deprecated public final static int[] INT_SIZE_TABLE = { 9, 99, 999, 9999, 99999, 999999, 9999999, 99999999, 999999999, Integer.MAX_VALUE }; - - private static final Pattern NOT_SNAKE_CASE_PATTERN = Pattern.compile("[^a-z0-9_]"); - private static final Pattern REPEATING_UNDERSCORE_PATTERN = Pattern.compile("_{2,}"); private static final Pattern FORMATTING_CODE_PATTERN = Pattern.compile("(?i)[\\&\u00a7]([0-9A-FK-ORXGQZVU])"); static { @@ -68,10 +74,7 @@ public static String addFormatting(String string) { } public static String toSnakeCase(String string) { - return string.isEmpty() ? string - : REPEATING_UNDERSCORE_PATTERN - .matcher(NOT_SNAKE_CASE_PATTERN.matcher(unformatted(string).toLowerCase()).replaceAll("_")) - .replaceAll("_"); + return IdentifierUtils.toSnakeCase(string); } public static String emptyIfNull(@Nullable Object o) { @@ -91,53 +94,7 @@ public static String getRawID(Object o) { } public static String getID(Object o, int flags) { - String id = getRawID(o); - - if (flags == 0) { - return id; - } - - boolean fix = Bits.getFlag(flags, FLAG_ID_FIX); - - if (!fix && id.isEmpty() && !Bits.getFlag(flags, FLAG_ID_ALLOW_EMPTY)) { - throw new NullPointerException("ID can't be empty!"); - } - - if (Bits.getFlag(flags, FLAG_ID_ONLY_LOWERCASE)) { - if (fix) { - id = id.toLowerCase(); - } else if (!id.equals(id.toLowerCase())) { - throw new IllegalArgumentException("ID can't contain uppercase characters!"); - } - } - - if (Bits.getFlag(flags, FLAG_ID_ONLY_UNDERLINE)) { - if (fix) { - id = id.toLowerCase(); - } else if (!id.equals(id.toLowerCase())) { - throw new IllegalArgumentException("ID can't contain uppercase characters!"); - } - } - - if (Bits.getFlag(flags, FLAG_ID_ONLY_UNDERLINE)) { - boolean allowPeriod = Bits.getFlag(flags, 16); - - char[] chars = id.toCharArray(); - - for (int i = 0; i < chars.length; i++) { - if (!(chars[i] == '.' && allowPeriod || isTextChar(chars[i], true))) { - if (fix) { - chars[i] = '_'; - } else { - throw new IllegalArgumentException("ID contains invalid character: '" + chars[i] + "'!"); - } - } - } - - id = new String(chars); - } - - return id; + return IdentifierUtils.normalize(o, flags); } public static String[] shiftArray(@Nullable String[] s) { @@ -310,13 +267,21 @@ public static String fromStringList(List l) { } public static String formatDouble0(double value) { - String s = DOUBLE_FORMATTER_0.format(value); - return s.endsWith(".00") ? s.substring(0, s.length() - 2) : s; + String formatted = formatWithLegacyFormatter(DOUBLE_FORMATTER_0, value); + return formatted.endsWith(".0") ? formatted.substring(0, formatted.length() - 2) : formatted; } public static String formatDouble00(double value) { - String s = DOUBLE_FORMATTER_00.format(value); - return s.endsWith(".00") ? s.substring(0, s.length() - 3) : s; + String formatted = formatWithLegacyFormatter(DOUBLE_FORMATTER_00, value); + return formatted.endsWith(".00") ? formatted.substring(0, formatted.length() - 3) : formatted; + } + + private static String formatWithLegacyFormatter(DecimalFormat formatter, double value) { + DecimalFormat copy; + synchronized (formatter) { + copy = (DecimalFormat) formatter.clone(); + } + return copy.format(value); } public static String formatDouble(double value, boolean fancy) { @@ -410,52 +375,12 @@ public static String getTimeString(long millis) { } public static String fromUUID(@Nullable UUID id) { - if (id != null) { - long msb = id.getMostSignificantBits(); - long lsb = id.getLeastSignificantBits(); - StringBuilder sb = new StringBuilder(32); - digitsUUID(sb, msb >> 32, 8); - digitsUUID(sb, msb >> 16, 4); - digitsUUID(sb, msb, 4); - digitsUUID(sb, lsb >> 48, 4); - digitsUUID(sb, lsb, 12); - return sb.toString(); - } - - return ""; - } - - private static void digitsUUID(StringBuilder sb, long val, int digits) { - long hi = 1L << (digits * 4); - String s = Long.toHexString(hi | (val & (hi - 1))); - sb.append(s, 1, s.length()); + return UuidUtils.toCompactString(id); } @Nullable public static UUID fromString(@Nullable String s) { - if (s == null || !(s.length() == 32 || s.length() == 36)) { - return null; - } - - try { - if (s.indexOf('-') != -1) { - return UUID.fromString(s); - } - - int l = s.length(); - StringBuilder sb = new StringBuilder(36); - for (int i = 0; i < l; i++) { - sb.append(s.charAt(i)); - if (i == 7 || i == 11 || i == 15 || i == 19) { - sb.append('-'); - } - } - - return UUID.fromString(sb.toString()); - } catch (Exception e) { - e.printStackTrace(); - } - return null; + return UuidUtils.parse(s); } public static Map parse(Map map, String s) { diff --git a/src/main/java/serverutils/lib/util/UuidUtils.java b/src/main/java/serverutils/lib/util/UuidUtils.java new file mode 100644 index 000000000..b18e73bb1 --- /dev/null +++ b/src/main/java/serverutils/lib/util/UuidUtils.java @@ -0,0 +1,56 @@ +package serverutils.lib.util; + +import java.util.UUID; + +import javax.annotation.Nullable; + +public final class UuidUtils { + + private UuidUtils() {} + + public static String toCompactString(@Nullable UUID id) { + if (id == null) { + return ""; + } + + long mostSignificant = id.getMostSignificantBits(); + long leastSignificant = id.getLeastSignificantBits(); + StringBuilder builder = new StringBuilder(32); + appendDigits(builder, mostSignificant >> 32, 8); + appendDigits(builder, mostSignificant >> 16, 4); + appendDigits(builder, mostSignificant, 4); + appendDigits(builder, leastSignificant >> 48, 4); + appendDigits(builder, leastSignificant, 12); + return builder.toString(); + } + + @Nullable + public static UUID parse(@Nullable String value) { + if (value == null || !(value.length() == 32 || value.length() == 36)) { + return null; + } + + try { + if (value.indexOf('-') != -1) { + return UUID.fromString(value); + } + + StringBuilder builder = new StringBuilder(36); + for (int i = 0; i < value.length(); i++) { + builder.append(value.charAt(i)); + if (i == 7 || i == 11 || i == 15 || i == 19) { + builder.append('-'); + } + } + return UUID.fromString(builder.toString()); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private static void appendDigits(StringBuilder builder, long value, int digits) { + long highBit = 1L << (digits * 4); + String encoded = Long.toHexString(highBit | (value & (highBit - 1))); + builder.append(encoded, 1, encoded.length()); + } +} diff --git a/src/main/java/serverutils/lib/util/compression/AbstractZipCompressor.java b/src/main/java/serverutils/lib/util/compression/AbstractZipCompressor.java new file mode 100644 index 000000000..77ff39350 --- /dev/null +++ b/src/main/java/serverutils/lib/util/compression/AbstractZipCompressor.java @@ -0,0 +1,231 @@ +package serverutils.lib.util.compression; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Enumeration; + +import javax.annotation.Nullable; + +abstract class AbstractZipCompressor implements ICompress { + + private static final int MAX_ARCHIVE_ENTRIES = 1_000_000; + private static final long MAX_EXTRACTED_BYTES = 1L << 40; + private static final long MIN_FREE_SPACE_RESERVE = 64L * 1024L * 1024L; + + protected abstract A openArchive(File archive) throws IOException; + + protected abstract Enumeration getEntries(A archive); + + protected abstract String getEntryName(E entry); + + protected abstract boolean isDirectory(E entry); + + protected abstract InputStream openEntry(A archive, E entry) throws IOException; + + protected static void copyArchiveInput(InputStream input, OutputStream output) throws IOException { + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = input.read(buffer)) != -1) { + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedIOException("Archive operation cancelled"); + } + output.write(buffer, 0, read); + } + } + + @Override + public boolean isOldBackup(File archive) throws IOException { + try (A zip = openArchive(archive)) { + Enumeration entries = getEntries(zip); + int entryCount = 0; + while (entries.hasMoreElements()) { + if (++entryCount > MAX_ARCHIVE_ENTRIES) { + throw new IOException("Archive contains too many entries"); + } + if (getEntryName(entries.nextElement()).replace('\\', '/').startsWith("saves/")) { + return false; + } + } + } + + return true; + } + + @Override + public void extractArchive(File archive, boolean includeGlobal, boolean isOldBackup) throws IOException { + extractArchiveTo(Paths.get(""), archive, includeGlobal, isOldBackup); + } + + @Override + public void extractArchiveTo(File extractionRoot, File archive, boolean includeGlobal, boolean isOldBackup) + throws IOException { + extractArchiveTo(extractionRoot.toPath(), archive, includeGlobal, isOldBackup, null); + } + + @Override + public void extractArchiveTo(File extractionRoot, File archive, boolean includeGlobal, boolean isOldBackup, + @Nullable String worldName) throws IOException { + extractArchiveTo(extractionRoot.toPath(), archive, includeGlobal, isOldBackup, worldName); + } + + void extractArchiveTo(Path extractionRoot, File archive, boolean includeGlobal, boolean isOldBackup) + throws IOException { + extractArchiveTo(extractionRoot, archive, includeGlobal, isOldBackup, null); + } + + void extractArchiveTo(Path extractionRoot, File archive, boolean includeGlobal, boolean isOldBackup, + @Nullable String worldName) throws IOException { + validateArchiveTo(extractionRoot, archive, includeGlobal, isOldBackup, worldName); + + Path root = extractionRoot.toAbsolutePath().normalize(); + Files.createDirectories(root); + verifyNoLinkedPath(root, root); + long usableSpace = Files.getFileStore(root).getUsableSpace(); + long reserve = Math.min(MIN_FREE_SPACE_RESERVE, usableSpace / 10L); + ExtractionBudget budget = new ExtractionBudget(Math.min(MAX_EXTRACTED_BYTES, usableSpace - reserve)); + + try (A zip = openArchive(archive)) { + Enumeration entries = getEntries(zip); + int entryCount = 0; + while (entries.hasMoreElements()) { + if (++entryCount > MAX_ARCHIVE_ENTRIES) { + throw new IOException("Archive contains too many entries"); + } + E entry = entries.nextElement(); + BackupArchivePathPolicy.Target target = BackupArchivePathPolicy + .resolve(extractionRoot, getEntryName(entry), isOldBackup); + if (!BackupArchivePathPolicy.shouldExtract(target.relative, includeGlobal, worldName)) { + continue; + } + + verifyNoLinkedPath(extractionRoot, target.destination); + if (isDirectory(entry)) { + Files.createDirectories(target.destination); + continue; + } + + Path parent = target.destination.getParent(); + if (parent != null) { + Files.createDirectories(parent); + verifyNoLinkedPath(root, parent); + } + + Path temporary = Files.createTempFile(parent, ".restore-", ".tmp"); + try { + try (InputStream input = openEntry(zip, entry); + OutputStream output = Files.newOutputStream(temporary)) { + copyWithBudget(input, output, budget); + } + moveReplacing(temporary, target.destination); + temporary = null; + } finally { + if (temporary != null) { + Files.deleteIfExists(temporary); + } + } + } + } + } + + void validateArchiveTo(Path extractionRoot, File archive, boolean includeGlobal, boolean isOldBackup) + throws IOException { + validateArchiveTo(extractionRoot, archive, includeGlobal, isOldBackup, null); + } + + void validateArchiveTo(Path extractionRoot, File archive, boolean includeGlobal, boolean isOldBackup, + @Nullable String worldName) throws IOException { + try (A zip = openArchive(archive)) { + Enumeration entries = getEntries(zip); + int entryCount = 0; + while (entries.hasMoreElements()) { + if (++entryCount > MAX_ARCHIVE_ENTRIES) { + throw new IOException("Archive contains too many entries"); + } + E entry = entries.nextElement(); + BackupArchivePathPolicy.Target target = BackupArchivePathPolicy + .resolve(extractionRoot, getEntryName(entry), isOldBackup); + if (BackupArchivePathPolicy.shouldExtract(target.relative, includeGlobal, worldName)) { + verifyNoLinkedPath(extractionRoot, target.destination); + } + } + } + } + + private static void copyWithBudget(InputStream input, OutputStream output, ExtractionBudget budget) + throws IOException { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + budget.consume(read); + output.write(buffer, 0, read); + } + } + + private static void moveReplacing(Path source, Path destination) throws IOException { + try { + Files.move(source, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static final class ExtractionBudget { + + private final long maximum; + private long consumed; + + private ExtractionBudget(long maximum) { + this.maximum = Math.max(0L, maximum); + } + + private void consume(int bytes) throws IOException { + if (bytes > maximum - consumed) { + throw new IOException("Archive exceeds the safe extraction-size limit"); + } + consumed += bytes; + } + } + + private static void verifyNoLinkedPath(Path extractionRoot, Path destination) throws IOException { + Path root = extractionRoot.toAbsolutePath().normalize(); + Path current = root; + rejectLink(current); + + for (Path segment : root.relativize(destination)) { + current = current.resolve(segment); + rejectLink(current); + } + } + + private static void rejectLink(Path path) throws IOException { + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + return; + } + + BasicFileAttributes attributes = Files + .readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (Files.isSymbolicLink(path) || attributes.isOther()) { + throw new IOException("Archive entry traverses a linked path: " + path); + } + } + + @Override + public @Nullable String getWorldName(File file) throws IOException { + if (file.isDirectory() || !file.getName().endsWith(".zip")) return null; + + try (java.util.zip.ZipFile zipFile = new java.util.zip.ZipFile(file)) { + return zipFile.getComment(); + } + } +} diff --git a/src/main/java/serverutils/lib/util/compression/BackupArchivePathPolicy.java b/src/main/java/serverutils/lib/util/compression/BackupArchivePathPolicy.java new file mode 100644 index 000000000..7033026d1 --- /dev/null +++ b/src/main/java/serverutils/lib/util/compression/BackupArchivePathPolicy.java @@ -0,0 +1,127 @@ +package serverutils.lib.util.compression; + +import static serverutils.ServerUtilitiesConfig.backups; + +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.Paths; + +import serverutils.lib.util.BackupGlobUtils; + +final class BackupArchivePathPolicy { + + static final class Target { + + final Path relative; + final Path destination; + + private Target(Path relative, Path destination) { + this.relative = relative; + this.destination = destination; + } + } + + private BackupArchivePathPolicy() {} + + static Target resolve(Path extractionRoot, String entryName, boolean oldBackup) throws IOException { + if (entryName == null || entryName.indexOf('\0') >= 0) { + throw new IOException("Archive entry has an invalid name"); + } + + String normalizedName = entryName.replace('\\', '/'); + if (normalizedName.startsWith("/") || normalizedName.startsWith("//") + || normalizedName.matches("^[A-Za-z]:.*")) { + throw new IOException("Archive entry is absolute: " + entryName); + } + + try { + Path archiveRelative = Paths.get(normalizedName).normalize(); + if (archiveRelative.toString().isEmpty() || archiveRelative.isAbsolute() + || archiveRelative.startsWith("..")) { + throw new IOException("Archive entry escapes the restore directory: " + entryName); + } + + Path relative = oldBackup ? Paths.get("saves").resolve(archiveRelative) : archiveRelative; + + Path root = extractionRoot.toAbsolutePath().normalize(); + Path destination = root.resolve(relative).normalize(); + if (!destination.startsWith(root)) { + throw new IOException("Archive entry escapes the restore directory: " + entryName); + } + + return new Target(relative, destination); + } catch (InvalidPathException ex) { + throw new IOException("Archive entry has an invalid path: " + entryName, ex); + } + } + + static boolean shouldExtract(Path relative, boolean includeGlobal) { + return shouldExtract(relative, includeGlobal, null); + } + + static boolean shouldExtract(Path relative, boolean includeGlobal, String worldName) { + if (includeGlobal) { + return true; + } + + if (worldName != null) { + if (!isSingleWorldName(worldName)) { + return false; + } + + Path worldRoot = Paths.get("saves").resolve(worldName); + if (relative.equals(worldRoot) || relative.startsWith(worldRoot)) { + return true; + } + + for (String pattern : backups.additional_backup_files) { + if (!pattern.contains("$WORLDNAME")) { + continue; + } + + PathMatcher matcher = FileSystems.getDefault() + .getPathMatcher("glob:" + BackupGlobUtils.substituteGlob(pattern, worldName)); + if (matcher.matches(relative)) { + return true; + } + } + + return false; + } + + for (String pattern : backups.additional_backup_files) { + if (pattern.contains("$WORLDNAME")) { + continue; + } + + PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern); + if (matcher.matches(relative)) { + return false; + } + } + + return true; + } + + private static boolean isSingleWorldName(String worldName) { + if (worldName.isEmpty() || worldName.equals(".") + || worldName.equals("..") + || worldName.indexOf('/') >= 0 + || worldName.indexOf('\\') >= 0 + || worldName.indexOf(':') >= 0 + || worldName.indexOf('\0') >= 0) { + return false; + } + + try { + Path path = Paths.get(worldName); + return !path.isAbsolute() && path.getNameCount() == 1; + } catch (InvalidPathException ex) { + return false; + } + } + +} diff --git a/src/main/java/serverutils/lib/util/compression/CommonsCompressor.java b/src/main/java/serverutils/lib/util/compression/CommonsCompressor.java index bc0ab823a..45e917dfa 100644 --- a/src/main/java/serverutils/lib/util/compression/CommonsCompressor.java +++ b/src/main/java/serverutils/lib/util/compression/CommonsCompressor.java @@ -4,17 +4,11 @@ import java.io.File; import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; -import java.nio.file.FileSystems; -import java.nio.file.PathMatcher; import java.util.Enumeration; import java.util.zip.ZipEntry; -import javax.annotation.Nullable; - import net.minecraftforge.common.DimensionManager; import org.apache.commons.compress.archivers.ArchiveEntry; @@ -22,11 +16,8 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; import org.apache.commons.compress.archivers.zip.ZipFile; -import org.apache.commons.io.IOUtils; - -import serverutils.lib.util.FileUtils; -public class CommonsCompressor implements ICompress { +public class CommonsCompressor extends AbstractZipCompressor { private ArchiveOutputStream output; @@ -52,73 +43,34 @@ public void addFileToArchive(File file, String name) throws IOException { ArchiveEntry entry = output.createArchiveEntry(file, name); output.putArchiveEntry(entry); try (FileInputStream fis = new FileInputStream(file)) { - IOUtils.copy(fis, output); + copyArchiveInput(fis, output); } output.closeArchiveEntry(); } - private static boolean shouldExtract(File file, boolean includeGlobal) { - if (includeGlobal) { - return true; - } - for (String pattern : backups.additional_backup_files) { - if (pattern.contains("$WORLDNAME")) { - continue; - } - PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern); - if (matcher.matches(file.toPath())) { - return false; - } - } - return true; + @Override + protected ZipFile openArchive(File archive) throws IOException { + return new ZipFile(archive); } @Override - public boolean isOldBackup(File archive) throws IOException { - try (ZipFile zip = new ZipFile(archive)) { - boolean isOldBackup = true; - Enumeration entries = zip.getEntries(); - while (entries.hasMoreElements()) { - ZipArchiveEntry entry = entries.nextElement(); - if (entry.getName().replace('\\', '/').startsWith("saves/")) { - return false; - } - } - } - return true; + protected Enumeration getEntries(ZipFile archive) { + return archive.getEntries(); } @Override - public void extractArchive(File archive, boolean includeGlobal, boolean isOldBackup) throws IOException { - - try (ZipFile zip = new ZipFile(archive)) { - Enumeration entries = zip.getEntries(); - String prefix = isOldBackup ? "saves/" : ""; - while (entries.hasMoreElements()) { - ZipArchiveEntry entry = entries.nextElement(); - - File file = new File(prefix + entry.getName()); - if (shouldExtract(file, includeGlobal)) { - file = FileUtils.newFile(file); - InputStream in = zip.getInputStream(entry); - OutputStream out = new FileOutputStream(file); - IOUtils.copy(in, out); - - in.close(); - out.close(); - } - } - } + protected String getEntryName(ZipArchiveEntry entry) { + return entry.getName(); } @Override - public @Nullable String getWorldName(File file) throws IOException { - if (file.isDirectory() || !file.getName().endsWith(".zip")) return null; - // uses native zip file implementation because reading the - // comment from a commons compress ZipFile is significantly slower - try (java.util.zip.ZipFile zipFile = new java.util.zip.ZipFile(file)) { - return zipFile.getComment(); - } + protected boolean isDirectory(ZipArchiveEntry entry) { + return entry.isDirectory(); + } + + @Override + protected InputStream openEntry(ZipFile archive, ZipArchiveEntry entry) throws IOException { + return archive.getInputStream(entry); } @Override diff --git a/src/main/java/serverutils/lib/util/compression/ICompress.java b/src/main/java/serverutils/lib/util/compression/ICompress.java index 243e44f9b..765504f82 100644 --- a/src/main/java/serverutils/lib/util/compression/ICompress.java +++ b/src/main/java/serverutils/lib/util/compression/ICompress.java @@ -17,6 +17,20 @@ public interface ICompress extends AutoCloseable { void extractArchive(File archive, boolean includeGlobal, boolean isOldBackup) throws IOException; + default void extractArchiveTo(File extractionRoot, File archive, boolean includeGlobal, boolean isOldBackup) + throws IOException { + if (!extractionRoot.getAbsoluteFile().equals(new File("").getAbsoluteFile())) { + throw new IOException("This compressor does not support an alternate extraction directory"); + } + + extractArchive(archive, includeGlobal, isOldBackup); + } + + default void extractArchiveTo(File extractionRoot, File archive, boolean includeGlobal, boolean isOldBackup, + @Nullable String worldName) throws IOException { + extractArchiveTo(extractionRoot, archive, includeGlobal, isOldBackup); + } + boolean isOldBackup(File archive) throws IOException; @Nullable diff --git a/src/main/java/serverutils/lib/util/compression/LegacyCompressor.java b/src/main/java/serverutils/lib/util/compression/LegacyCompressor.java index e541c928e..74de639dd 100644 --- a/src/main/java/serverutils/lib/util/compression/LegacyCompressor.java +++ b/src/main/java/serverutils/lib/util/compression/LegacyCompressor.java @@ -7,23 +7,14 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; -import java.nio.file.FileSystems; -import java.nio.file.PathMatcher; import java.util.Enumeration; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; -import javax.annotation.Nullable; - import net.minecraftforge.common.DimensionManager; -import org.apache.commons.io.IOUtils; - -import serverutils.lib.util.FileUtils; - -public class LegacyCompressor implements ICompress { +public class LegacyCompressor extends AbstractZipCompressor { private ZipOutputStream output; @@ -47,69 +38,34 @@ public void addFileToArchive(File file, String name) throws IOException { ZipEntry entry = new ZipEntry(name); output.putNextEntry(entry); try (FileInputStream fis = new FileInputStream(file)) { - IOUtils.copy(fis, output); + copyArchiveInput(fis, output); } output.closeEntry(); } - private static boolean shouldExtract(File file, boolean includeGlobal) { - if (includeGlobal) { - return true; - } - for (String pattern : backups.additional_backup_files) { - if (pattern.contains("$WORLDNAME")) { - continue; - } - PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern); - if (matcher.matches(file.toPath())) { - return false; - } - } - return true; + @Override + protected ZipFile openArchive(File archive) throws IOException { + return new ZipFile(archive); } @Override - public boolean isOldBackup(File archive) throws IOException { - try (ZipFile zip = new ZipFile(archive)) { - Enumeration entries = zip.entries(); - while (entries.hasMoreElements()) { - ZipEntry entry = entries.nextElement(); - if (entry.getName().replace('\\', '/').startsWith("saves/")) { - return false; - } - } - } - - return true; + protected Enumeration getEntries(ZipFile archive) { + return archive.entries(); } @Override - public void extractArchive(File archive, boolean includeGlobal, boolean isOldBackup) throws IOException { - try (ZipFile zip = new ZipFile(archive)) { - Enumeration entries = zip.entries(); - String prefix = isOldBackup ? "saves/" : ""; - while (entries.hasMoreElements()) { - ZipEntry entry = entries.nextElement(); - File file = new File(prefix + entry.getName()); - if (shouldExtract(file, includeGlobal)) { - file = FileUtils.newFile(file); - InputStream in = zip.getInputStream(entry); - OutputStream out = new FileOutputStream(file); - IOUtils.copy(in, out); + protected String getEntryName(ZipEntry entry) { + return entry.getName(); + } - in.close(); - out.close(); - } - } - } + @Override + protected boolean isDirectory(ZipEntry entry) { + return entry.isDirectory(); } @Override - public @Nullable String getWorldName(File file) throws IOException { - if (file.isDirectory() || !file.getName().endsWith(".zip")) return null; - try (ZipFile zipFile = new ZipFile(file)) { - return zipFile.getComment(); - } + protected InputStream openEntry(ZipFile archive, ZipEntry entry) throws IOException { + return archive.getInputStream(entry); } @Override diff --git a/src/main/java/serverutils/net/MessageAdminPanelAction.java b/src/main/java/serverutils/net/MessageAdminPanelAction.java index 0e4d7bdf5..de7b9f82b 100644 --- a/src/main/java/serverutils/net/MessageAdminPanelAction.java +++ b/src/main/java/serverutils/net/MessageAdminPanelAction.java @@ -4,7 +4,7 @@ import net.minecraft.nbt.NBTTagCompound; import net.minecraft.util.ResourceLocation; -import serverutils.ServerUtilitiesRegistry; +import serverutils.api.ServerUtilitiesRegistry; import serverutils.lib.data.Action; import serverutils.lib.data.ForgePlayer; import serverutils.lib.data.Universe; @@ -40,7 +40,7 @@ public void readData(DataIn data) { @Override public void onMessage(EntityPlayerMP player) { - Action a = ServerUtilitiesRegistry.ADMIN_PANEL_ACTIONS.get(action); + Action a = ServerUtilitiesRegistry.findAdminPanelAction(action); if (a != null) { ForgePlayer p = Universe.get().getPlayer(player); diff --git a/src/main/java/serverutils/net/MessageAdminPanelGui.java b/src/main/java/serverutils/net/MessageAdminPanelGui.java index 02bd6fff4..a9b9847ee 100644 --- a/src/main/java/serverutils/net/MessageAdminPanelGui.java +++ b/src/main/java/serverutils/net/MessageAdminPanelGui.java @@ -6,7 +6,7 @@ import net.minecraft.entity.player.EntityPlayerMP; import net.minecraft.nbt.NBTTagCompound; -import serverutils.ServerUtilitiesRegistry; +import serverutils.api.ServerUtilitiesRegistry; import serverutils.lib.data.Action; import serverutils.lib.data.ForgePlayer; import serverutils.lib.data.Universe; @@ -26,7 +26,7 @@ public void onMessage(EntityPlayerMP player) { ForgePlayer p = Universe.get().getPlayer(player); NBTTagCompound data = new NBTTagCompound(); - for (Action a : ServerUtilitiesRegistry.ADMIN_PANEL_ACTIONS.values()) { + for (Action a : ServerUtilitiesRegistry.adminPanelActionsView().values()) { Action.Type type = a.getType(p, data); if (type.isVisible()) { diff --git a/src/main/java/serverutils/net/MessageClaimedChunksModify.java b/src/main/java/serverutils/net/MessageClaimedChunksModify.java index f801428f2..29c09bb03 100644 --- a/src/main/java/serverutils/net/MessageClaimedChunksModify.java +++ b/src/main/java/serverutils/net/MessageClaimedChunksModify.java @@ -87,7 +87,7 @@ public void onMessage(EntityPlayerMP player) { for (ChunkCoordIntPair pair : chunks) { ChunkDimPos pos = new ChunkDimPos(pair, player.dimension); if (ClaimedChunks.instance.canPlayerModify(p, pos, ServerUtilitiesPermissions.CLAIMS_OTHER_LOAD)) { - ClaimedChunks.instance.loadChunk(p, p.team, pos); + ClaimedChunks.instance.loadChunk(p, p.getTeam(), pos); } } break; diff --git a/src/main/java/serverutils/net/MessageClaimedChunksUpdate.java b/src/main/java/serverutils/net/MessageClaimedChunksUpdate.java index 2b2207489..2f77e56d1 100644 --- a/src/main/java/serverutils/net/MessageClaimedChunksUpdate.java +++ b/src/main/java/serverutils/net/MessageClaimedChunksUpdate.java @@ -43,7 +43,7 @@ public MessageClaimedChunksUpdate(int sx, int sz, EntityPlayer player) { startZ = sz; ForgePlayer p = Universe.get().getPlayer(player); - ServerUtilitiesTeamData teamData = ServerUtilitiesTeamData.get(p.team); + ServerUtilitiesTeamData teamData = ServerUtilitiesTeamData.get(p.getTeam()); Collection chunks = teamData.team.isValid() ? ClaimedChunks.instance.getTeamChunks(teamData.team, OptionalInt.empty()) diff --git a/src/main/java/serverutils/net/MessageEditNBTRequest.java b/src/main/java/serverutils/net/MessageEditNBTRequest.java index d0edca800..6fb12071d 100644 --- a/src/main/java/serverutils/net/MessageEditNBTRequest.java +++ b/src/main/java/serverutils/net/MessageEditNBTRequest.java @@ -32,9 +32,16 @@ public static void editNBT() { } if (ray.typeOfHit == MovingObjectPosition.MovingObjectType.BLOCK && Minecraft.getMinecraft().theWorld.getTileEntity(ray.blockX, ray.blockY, ray.blockZ) != null) { - ClientUtils.execClientCommand(String.format("/nbtedit block %d %d %d", ray.blockX, ray.blockY, ray.blockZ)); + ClientUtils.execClientCommand( + String.format( + java.util.Locale.ROOT, + "/nbtedit block %d %d %d", + ray.blockX, + ray.blockY, + ray.blockZ)); } else if (ray.typeOfHit == MovingObjectPosition.MovingObjectType.ENTITY && ray.entityHit != null) { - ClientUtils.execClientCommand(String.format("/nbtedit entity %s", ray.entityHit.getEntityId())); + ClientUtils.execClientCommand( + String.format(java.util.Locale.ROOT, "/nbtedit entity %s", ray.entityHit.getEntityId())); } else if (Minecraft.getMinecraft().thePlayer.inventory.getCurrentItem() != null) { ClientUtils.execClientCommand("/nbtedit item"); } else { diff --git a/src/main/java/serverutils/net/MessageMyTeamAction.java b/src/main/java/serverutils/net/MessageMyTeamAction.java index 7d6bf485e..99736b181 100644 --- a/src/main/java/serverutils/net/MessageMyTeamAction.java +++ b/src/main/java/serverutils/net/MessageMyTeamAction.java @@ -4,7 +4,7 @@ import net.minecraft.nbt.NBTTagCompound; import net.minecraft.util.ResourceLocation; -import serverutils.ServerUtilitiesRegistry; +import serverutils.api.ServerUtilitiesRegistry; import serverutils.lib.data.Action; import serverutils.lib.data.ForgePlayer; import serverutils.lib.data.Universe; @@ -44,7 +44,7 @@ public void readData(DataIn data) { @Override public void onMessage(EntityPlayerMP player) { - Action a = ServerUtilitiesRegistry.TEAM_GUI_ACTIONS.get(action); + Action a = ServerUtilitiesRegistry.findTeamAction(action); if (a != null) { ForgePlayer p = Universe.get().getPlayer(player); diff --git a/src/main/java/serverutils/net/MessageMyTeamGuiResponse.java b/src/main/java/serverutils/net/MessageMyTeamGuiResponse.java index 5518fbca2..62d6fb282 100644 --- a/src/main/java/serverutils/net/MessageMyTeamGuiResponse.java +++ b/src/main/java/serverutils/net/MessageMyTeamGuiResponse.java @@ -8,7 +8,7 @@ import cpw.mods.fml.relauncher.Side; import cpw.mods.fml.relauncher.SideOnly; -import serverutils.ServerUtilitiesRegistry; +import serverutils.api.ServerUtilitiesRegistry; import serverutils.lib.data.Action; import serverutils.lib.data.ForgePlayer; import serverutils.lib.gui.misc.GuiActionList; @@ -25,11 +25,11 @@ public class MessageMyTeamGuiResponse extends MessageToClient { public MessageMyTeamGuiResponse() {} public MessageMyTeamGuiResponse(ForgePlayer player) { - title = player.team.getTitle(); + title = player.getTeam().getTitle(); actions = new ArrayList<>(); NBTTagCompound emptyData = new NBTTagCompound(); - for (Action action : ServerUtilitiesRegistry.TEAM_GUI_ACTIONS.values()) { + for (Action action : ServerUtilitiesRegistry.teamActionsView().values()) { Action.Type type = action.getType(player, emptyData); if (type.isVisible()) { diff --git a/src/main/java/serverutils/net/MessageMyTeamPlayerList.java b/src/main/java/serverutils/net/MessageMyTeamPlayerList.java index c534ec8c3..c5c9c8bd4 100644 --- a/src/main/java/serverutils/net/MessageMyTeamPlayerList.java +++ b/src/main/java/serverutils/net/MessageMyTeamPlayerList.java @@ -76,12 +76,12 @@ public MessageMyTeamPlayerList(ResourceLocation _id, ForgePlayer player, Predica id = _id; entries = new ArrayList<>(); - for (ForgePlayer p : player.team.universe.getPlayers()) { + for (ForgePlayer p : player.getUniverse().getPlayers()) { if (p != player) { - EnumTeamStatus status = player.team.getHighestStatus(p); + EnumTeamStatus status = player.getTeam().getHighestStatus(p); if (status != EnumTeamStatus.OWNER && predicate.test(status)) { - entries.add(new Entry(p, status, player.team.isRequestingInvite(p))); + entries.add(new Entry(p, status, player.getTeam().isRequestingInvite(p))); } } } diff --git a/src/main/java/serverutils/net/MessageNavigatorUpdate.java b/src/main/java/serverutils/net/MessageNavigatorUpdate.java index 5dfe9a6ad..4c2d13aec 100644 --- a/src/main/java/serverutils/net/MessageNavigatorUpdate.java +++ b/src/main/java/serverutils/net/MessageNavigatorUpdate.java @@ -46,7 +46,7 @@ public MessageNavigatorUpdate(int minX, int maxX, int minZ, int maxZ, EntityPlay continue; } - if (!canSeeOtherJourneymap && !p.team.equalsTeam(chunkTeam)) { + if (!canSeeOtherJourneymap && !p.getTeam().equalsTeam(chunkTeam)) { continue; } diff --git a/src/main/java/serverutils/net/MessageSyncData.java b/src/main/java/serverutils/net/MessageSyncData.java index bde636c53..bdd4d91b5 100644 --- a/src/main/java/serverutils/net/MessageSyncData.java +++ b/src/main/java/serverutils/net/MessageSyncData.java @@ -15,7 +15,7 @@ import cpw.mods.fml.relauncher.SideOnly; import serverutils.ServerUtilities; import serverutils.ServerUtilitiesConfig; -import serverutils.ServerUtilitiesRegistry; +import serverutils.api.ServerUtilitiesRegistry; import serverutils.events.SyncGamerulesEvent; import serverutils.lib.client.ClientUtils; import serverutils.lib.data.ForgePlayer; @@ -45,10 +45,10 @@ public MessageSyncData(boolean login, EntityPlayerMP player, ForgePlayer forgePl boolean op = MinecraftServer.getServer().getConfigurationManager().func_152596_g(player.getGameProfile()); flags = Bits.setFlag(0, LOGIN, login); flags = Bits.setFlag(flags, OP, op); - universeId = forgePlayer.team.universe.getUUID(); + universeId = forgePlayer.getUniverse().getUUID(); syncData = new NBTTagCompound(); - for (Map.Entry entry : ServerUtilitiesRegistry.SYNCED_DATA.entrySet()) { + for (Map.Entry entry : ServerUtilitiesRegistry.syncDataView().entrySet()) { syncData.setTag(entry.getKey(), entry.getValue().writeSyncData(player, forgePlayer)); } @@ -89,7 +89,7 @@ public void readData(DataIn data) { @SideOnly(Side.CLIENT) public void onMessage() { for (String key : syncData.func_150296_c()) { - ISyncData nbt = ServerUtilitiesRegistry.SYNCED_DATA.get(key); + ISyncData nbt = ServerUtilitiesRegistry.findSyncData(key); if (nbt != null) { nbt.readSyncData(syncData.getCompoundTag(key)); diff --git a/src/main/java/serverutils/net/MessageViewCrash.java b/src/main/java/serverutils/net/MessageViewCrash.java index 7a5fb0337..8b4341cbc 100644 --- a/src/main/java/serverutils/net/MessageViewCrash.java +++ b/src/main/java/serverutils/net/MessageViewCrash.java @@ -50,7 +50,7 @@ public void onMessage(EntityPlayerMP player) { } } catch (Exception ex) { if (ServerUtilitiesConfig.debugging.print_more_errors) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to read a crash report", ex); } } } diff --git a/src/main/java/serverutils/net/MessageViewCrashDelete.java b/src/main/java/serverutils/net/MessageViewCrashDelete.java index ad23bb189..b0e1e9ae6 100644 --- a/src/main/java/serverutils/net/MessageViewCrashDelete.java +++ b/src/main/java/serverutils/net/MessageViewCrashDelete.java @@ -51,7 +51,7 @@ public void onMessage(EntityPlayerMP player) { } } catch (Exception ex) { if (ServerUtilitiesConfig.debugging.print_more_errors) { - ex.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to delete a crash report", ex); } } } diff --git a/src/main/java/serverutils/pregenerator/ChunkLoaderManager.java b/src/main/java/serverutils/pregenerator/ChunkLoaderManager.java index a4c68a7dc..b9e2bf419 100644 --- a/src/main/java/serverutils/pregenerator/ChunkLoaderManager.java +++ b/src/main/java/serverutils/pregenerator/ChunkLoaderManager.java @@ -70,7 +70,7 @@ public boolean initializeFromPregeneratorFiles(MinecraftServer server, int dimen } catch (IOException e) { this.reset(true); - e.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to initialize the pregenerator", e); } return false; } diff --git a/src/main/java/serverutils/pregenerator/filemanager/PregeneratorFileManager.java b/src/main/java/serverutils/pregenerator/filemanager/PregeneratorFileManager.java index 3f9cc778d..9733aa360 100644 --- a/src/main/java/serverutils/pregenerator/filemanager/PregeneratorFileManager.java +++ b/src/main/java/serverutils/pregenerator/filemanager/PregeneratorFileManager.java @@ -58,8 +58,9 @@ public Optional getCommandInfo() { commandReadWriter.readInt(), commandReadWriter.readInt(), iterationReadWriter.readInt())); - } catch (IOException ignored) {} // Ignoring this because often there's just nothing in the file if you're - // loading a world + } catch (IOException ignored) { + // A world commonly has no saved pregenerator command. + } return Optional.empty(); } @@ -67,7 +68,7 @@ public void saveIteration(int iteration) { try { iterationReadWriter.writeAndCommitIntAfterIterations(iteration); } catch (IOException e) { - e.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to update the pregenerator iteration file", e); } } @@ -78,7 +79,7 @@ public void closeAndRemoveAllFiles() { commandReadWriter.close(); commandReadWriter.deleteFile(); } catch (IOException e) { - e.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to delete the pregenerator command file", e); } } @@ -87,7 +88,7 @@ public void closeAllFiles() { iterationReadWriter.close(); commandReadWriter.close(); } catch (IOException e) { - e.printStackTrace(); + serverutils.ServerUtilities.LOGGER.error("Failed to close pregenerator files", e); } } diff --git a/src/main/java/serverutils/ranks/ICommandWithPermission.java b/src/main/java/serverutils/ranks/ICommandWithPermission.java index 3eff49429..95d29c9c7 100644 --- a/src/main/java/serverutils/ranks/ICommandWithPermission.java +++ b/src/main/java/serverutils/ranks/ICommandWithPermission.java @@ -46,7 +46,8 @@ public interface ICommandWithPermission { if (this instanceof CommandTreeBase tree) { for (ICommand c : tree.getSubCommands()) { ICommandWithPermission child = (ICommandWithPermission) c; - child.serverutilities$setPermissionNode(node.toLowerCase() + '.' + c.getCommandName()); + child.serverutilities$setPermissionNode( + node.toLowerCase(java.util.Locale.ROOT) + '.' + c.getCommandName()); child.serverutilities$setModName(this.serverutilities$getModName()); child.serverUtilities$registerPermissions(); } diff --git a/src/main/java/serverutils/task/CleanupTask.java b/src/main/java/serverutils/task/CleanupTask.java index 3dafa58e8..7cfa3ac69 100644 --- a/src/main/java/serverutils/task/CleanupTask.java +++ b/src/main/java/serverutils/task/CleanupTask.java @@ -87,7 +87,7 @@ private IChatComponent getNotificationString(int seconds) { String finalString = StatCollector.translateToLocalFormatted( "serverutilities.task.cleanup_entity", - builder.toString().toLowerCase(), + builder.toString().toLowerCase(java.util.Locale.ROOT), seconds); return StringUtils.color(new ChatComponentText(finalString), EnumChatFormatting.LIGHT_PURPLE); diff --git a/src/main/java/serverutils/task/ShutdownTask.java b/src/main/java/serverutils/task/ShutdownTask.java index 250ccef94..af9ebd735 100644 --- a/src/main/java/serverutils/task/ShutdownTask.java +++ b/src/main/java/serverutils/task/ShutdownTask.java @@ -38,14 +38,28 @@ public ShutdownTask() { try { String[] s = s0.split(":", 2); - int t = Integer.parseInt(s[0]) * 3600 + Integer.parseInt(s[1]) * 60; + if (s.length != 2) { + throw new NumberFormatException("Expected HH:mm"); + } + + int hour = Integer.parseInt(s[0]); + int minute = Integer.parseInt(s[1]); + + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + throw new NumberFormatException("Time is outside the 00:00-23:59 range"); + } + + int t = hour * 3600 + minute * 60; if (t <= currentTime) { t += 24 * 3600; } times.add(t); - } catch (Exception ignored) {} + } catch (NumberFormatException ex) { + ServerUtilities.LOGGER + .warn("Ignoring invalid automatic shutdown time '" + s0 + "': " + ex.getMessage()); + } } times.sort(null); diff --git a/src/main/java/serverutils/task/Task.java b/src/main/java/serverutils/task/Task.java index 63e27bafa..754dd47ae 100644 --- a/src/main/java/serverutils/task/Task.java +++ b/src/main/java/serverutils/task/Task.java @@ -52,7 +52,7 @@ public void setNextTime(long time) { public void queueNotifications(Universe universe) { List notifications = getNotifications(); if (notifications == null || notifications.isEmpty()) return; - getNotifications().forEach(universe::scheduleTask); + notifications.forEach(universe::scheduleTask); } protected List getNotifications() { diff --git a/src/main/java/serverutils/task/backup/BackupLifecycle.java b/src/main/java/serverutils/task/backup/BackupLifecycle.java new file mode 100644 index 000000000..7d802286e --- /dev/null +++ b/src/main/java/serverutils/task/backup/BackupLifecycle.java @@ -0,0 +1,38 @@ +package serverutils.task.backup; + +/** Coordinates ownership of the single backup slot without exposing mutable run state. */ +final class BackupLifecycle { + + static final class Run { + + private Run() {} + } + + private Run activeRun; + + synchronized Run tryBegin() { + if (activeRun != null) { + return null; + } + + activeRun = new Run(); + return activeRun; + } + + synchronized boolean isInProgress() { + return activeRun != null; + } + + synchronized boolean isCurrent(Run run) { + return activeRun == run; + } + + synchronized boolean complete(Run run) { + if (activeRun != run) { + return false; + } + + activeRun = null; + return true; + } +} diff --git a/src/main/java/serverutils/task/backup/BackupSaveStateSnapshot.java b/src/main/java/serverutils/task/backup/BackupSaveStateSnapshot.java new file mode 100644 index 000000000..f59c3c805 --- /dev/null +++ b/src/main/java/serverutils/task/backup/BackupSaveStateSnapshot.java @@ -0,0 +1,30 @@ +package serverutils.task.backup; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Immutable copy of the world auto-save states changed for one backup execution. */ +final class BackupSaveStateSnapshot { + + static final class WorldState { + + final int worldIndex; + final boolean levelSaving; + + WorldState(int worldIndex, boolean levelSaving) { + this.worldIndex = worldIndex; + this.levelSaving = levelSaving; + } + } + + private final List worldStates; + + BackupSaveStateSnapshot(List worldStates) { + this.worldStates = Collections.unmodifiableList(new ArrayList<>(worldStates)); + } + + List worldStates() { + return worldStates; + } +} diff --git a/src/main/java/serverutils/task/backup/BackupScope.java b/src/main/java/serverutils/task/backup/BackupScope.java new file mode 100644 index 000000000..4d21d98f3 --- /dev/null +++ b/src/main/java/serverutils/task/backup/BackupScope.java @@ -0,0 +1,15 @@ +package serverutils.task.backup; + +enum BackupScope { + + FULL_WORLD, + CLAIMED_CHUNKS; + + static BackupScope select(boolean forceOnlyClaimed, boolean configuredOnlyClaimed, boolean claimedChunksActive) { + return claimedChunksActive && (forceOnlyClaimed || configuredOnlyClaimed) ? CLAIMED_CHUNKS : FULL_WORLD; + } + + boolean isClaimedChunksOnly() { + return this == CLAIMED_CHUNKS; + } +} diff --git a/src/main/java/serverutils/task/backup/BackupTask.java b/src/main/java/serverutils/task/backup/BackupTask.java index 458e4038f..9f497c844 100644 --- a/src/main/java/serverutils/task/backup/BackupTask.java +++ b/src/main/java/serverutils/task/backup/BackupTask.java @@ -5,6 +5,8 @@ import static serverutils.lib.util.FileUtils.SizeUnit; import java.io.File; +import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.HashSet; @@ -23,8 +25,6 @@ import net.minecraft.world.storage.ThreadedFileIOBase; import net.minecraftforge.common.DimensionManager; -import it.unimi.dsi.fastutil.ints.Int2BooleanArrayMap; -import it.unimi.dsi.fastutil.ints.Int2BooleanMap; import serverutils.ServerUtilities; import serverutils.ServerUtilitiesConfig; import serverutils.data.ClaimedChunks; @@ -42,13 +42,30 @@ public class BackupTask extends Task { public static final Pattern BACKUP_NAME_PATTERN = Pattern.compile("\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}-\\d{2}(.*)"); public static final File BACKUP_TEMP_FOLDER = new File("serverutilities/temp/"); public static final File BACKUP_FOLDER; - private static final Int2BooleanMap dimSaveStates = new Int2BooleanArrayMap(); - public static ThreadBackup thread; + private static final BackupLifecycle LIFECYCLE = new BackupLifecycle(); + private static volatile BackupExecution activeExecution; + public static volatile ThreadBackup thread; public static boolean hadPlayer = false; private ICommandSender sender; private String customName = ""; private boolean post = false; private boolean forceOnlyClaimed = false; + private BackupExecution cleanupExecution; + + private static final class BackupExecution { + + private final BackupLifecycle.Run run; + private final BackupSaveStateSnapshot saveStates; + @Nullable + private final ThreadBackup worker; + + private BackupExecution(BackupLifecycle.Run run, BackupSaveStateSnapshot saveStates, + @Nullable ThreadBackup worker) { + this.run = run; + this.saveStates = saveStates; + this.worker = worker; + } + } static { BACKUP_FOLDER = backups.backup_folder_path.isEmpty() ? new File("/backups/") @@ -75,6 +92,15 @@ public BackupTask(@Nullable ICommandSender ics, String customName) { public BackupTask(boolean postCleanup) { super(0); this.post = postCleanup; + if (postCleanup) { + this.cleanupExecution = activeExecution; + } + } + + private BackupTask(BackupExecution execution) { + super(0); + post = true; + cleanupExecution = execution; } @Override @@ -85,10 +111,11 @@ public boolean isRepeatable() { @Override public void execute(Universe universe) { if (post) { - postBackup(universe); + if (cleanupExecution != null) { + postBackup(universe, cleanupExecution); + } return; } - if (thread != null) return; boolean auto = sender == null; if (auto && !backups.enable_backups) return; @@ -99,17 +126,20 @@ public void execute(Universe universe) { hadPlayer = false; } - dimSaveStates.clear(); + BackupLifecycle.Run run = LIFECYCLE.tryBegin(); + if (run == null) return; - // Must run before saveAllChunks so level.dat is written with the current host inventory, otherwise - // the single-player host's inventory in the backup is stale and items can dupe/vanish on restore. - server.getConfigurationManager().saveAllPlayerData(); + List saveStates = new ArrayList<>(); try { + // Must run before saveAllChunks so level.dat is written with the current host inventory, otherwise + // the single-player host's inventory in the backup is stale and items can dupe/vanish on restore. + server.getConfigurationManager().saveAllPlayerData(); + for (int i = 0; i < server.worldServers.length; ++i) { WorldServer world = server.worldServers[i]; if (world != null) { - dimSaveStates.put(i, world.levelSaving); + saveStates.add(new BackupSaveStateSnapshot.WorldState(i, world.levelSaving)); world.saveAllChunks(true, null); world.levelSaving = true; } @@ -120,7 +150,9 @@ public void execute(Universe universe) { null, new ChatComponentText( EnumChatFormatting.RED + "An error occurred while preparing backup. " + ex.getMessage())); - ServerUtilities.LOGGER.info("An error occurred while preparing backup, Aborting!", ex); + ServerUtilities.LOGGER.error("An error occurred while preparing backup, aborting", ex); + restoreSaveStates(server, new BackupSaveStateSnapshot(saveStates)); + abandonRun(run); return; } @@ -130,27 +162,111 @@ public void execute(Universe universe) { } catch (InterruptedException ex) { ServerUtilities.LOGGER.warn("Interrupted while flushing pending world writes before backup", ex); Thread.currentThread().interrupt(); + restoreSaveStates(server, new BackupSaveStateSnapshot(saveStates)); + abandonRun(run); + return; } - if (!backups.silent_backup) { - BACKUP.sendAll(StringUtils.color("cmd.backup_start", EnumChatFormatting.LIGHT_PURPLE)); + BackupSaveStateSnapshot saveStateSnapshot = new BackupSaveStateSnapshot(saveStates); + ICompress compressor = null; + boolean backupStarted = false; + BackupExecution execution = null; + try { + if (!backups.silent_backup) { + BACKUP.sendAll(StringUtils.color("cmd.backup_start", EnumChatFormatting.LIGHT_PURPLE)); + } + + Set backupChunks = new HashSet<>(); + boolean onlyClaimedChunks = BackupScope + .select(forceOnlyClaimed, backups.only_backup_claimed_chunks, ClaimedChunks.isActive()) + .isClaimedChunksOnly(); + if (onlyClaimedChunks) { + backupChunks.addAll(ClaimedChunks.instance.getAllClaimedPositions()); + // noinspection ResultOfMethodCallIgnored + BACKUP_TEMP_FOLDER.mkdirs(); + } + + File worldDir = DimensionManager.getCurrentSaveRootDirectory(); + compressor = ICompress.createCompressor(); + if (backups.use_separate_thread) { + ThreadBackup backupThread = new ThreadBackup( + compressor, + worldDir, + customName, + backupChunks, + onlyClaimedChunks); + execution = new BackupExecution(run, saveStateSnapshot, backupThread); + publishExecution(execution); + universe.scheduleTask(new BackupTask(execution)); + backupThread.start(); + compressor = null; // The worker owns and closes it. + } else { + execution = new BackupExecution(run, saveStateSnapshot, null); + publishExecution(execution); + ThreadBackup.doBackup(compressor, worldDir, customName, backupChunks, onlyClaimedChunks); + compressor = null; // doBackup closes it. + universe.scheduleTask(new BackupTask(execution)); + } + backupStarted = true; + } catch (Exception ex) { + ServerUtilities.LOGGER.error("An error occurred while starting the backup", ex); + ServerUtils.notifyChat( + server, + null, + new ChatComponentText( + EnumChatFormatting.RED + "An error occurred while starting backup. " + ex.getMessage())); + } finally { + if (compressor != null) { + try { + compressor.close(); + } catch (Exception closeError) { + ServerUtilities.LOGGER.warn("Failed to close an unused backup compressor", closeError); + } + } + + if (!backupStarted) { + restoreSaveStates(server, saveStateSnapshot); + abandonRun(run); + } } - Set backupChunks = new HashSet<>(); - if ((this.forceOnlyClaimed || backups.only_backup_claimed_chunks) && ClaimedChunks.isActive()) { - backupChunks.addAll(ClaimedChunks.instance.getAllClaimedPositions()); - // noinspection ResultOfMethodCallIgnored - BACKUP_TEMP_FOLDER.mkdirs(); + } + + public static boolean isBackupInProgress() { + return LIFECYCLE.isInProgress(); + } + + public static boolean cancelRunningBackup() { + ThreadBackup currentWorker; + synchronized (LIFECYCLE) { + currentWorker = thread; + if (currentWorker == null || currentWorker.isDone) { + return false; + } } - File worldDir = DimensionManager.getCurrentSaveRootDirectory(); - ICompress compressor = ICompress.createCompressor(); - if (backups.use_separate_thread) { - thread = new ThreadBackup(compressor, worldDir, customName, backupChunks); - thread.start(); - } else { - ThreadBackup.doBackup(compressor, worldDir, customName, backupChunks); + currentWorker.interrupt(); + return true; + } + + private static void publishExecution(BackupExecution execution) throws IOException { + synchronized (LIFECYCLE) { + if (!LIFECYCLE.isCurrent(execution.run)) { + throw new IOException("Backup execution lost ownership before it started"); + } + activeExecution = execution; + thread = execution.worker; + } + } + + private static void abandonRun(BackupLifecycle.Run run) { + synchronized (LIFECYCLE) { + if (!LIFECYCLE.isCurrent(run)) { + return; + } + thread = null; + activeExecution = null; + LIFECYCLE.complete(run); } - universe.scheduleTask(new BackupTask(true)); } public static void clearOldBackups() { @@ -198,33 +314,38 @@ private boolean hasOnlinePlayers(MinecraftServer server) { return !server.getConfigurationManager().playerEntityList.isEmpty(); } - private void postBackup(Universe universe) { - if (thread != null && !thread.isDone) { + private void postBackup(Universe universe, BackupExecution execution) { + if (!LIFECYCLE.isCurrent(execution.run)) { + return; + } + + if (execution.worker != null && !execution.worker.isDone) { setNextTime(System.currentTimeMillis() + Ticks.SECOND.millis()); universe.scheduleTask(this); return; } - clearOldBackups(); - FileUtils.delete(BACKUP_TEMP_FOLDER); - - thread = null; try { - MinecraftServer server = ServerUtils.getServer(); + clearOldBackups(); + FileUtils.delete(BACKUP_TEMP_FOLDER); + } finally { + restoreSaveStates(universe.server, execution.saveStates); + abandonRun(execution.run); + } + } - for (int i = 0; i < server.worldServers.length; ++i) { - WorldServer world = server.worldServers[i]; - if (world != null) { - if (dimSaveStates.containsKey(i)) { - world.levelSaving = dimSaveStates.get(i); - } else { - world.levelSaving = false; + private static void restoreSaveStates(MinecraftServer server, BackupSaveStateSnapshot saveStates) { + try { + for (BackupSaveStateSnapshot.WorldState saveState : saveStates.worldStates()) { + if (saveState.worldIndex >= 0 && saveState.worldIndex < server.worldServers.length) { + WorldServer world = server.worldServers[saveState.worldIndex]; + if (world != null) { + world.levelSaving = saveState.levelSaving; } - } } } catch (Exception ex) { - ServerUtilities.LOGGER.info("An error occurred while turning on auto-save.", ex); + ServerUtilities.LOGGER.error("An error occurred while restoring world auto-save state", ex); } } } diff --git a/src/main/java/serverutils/task/backup/ThreadBackup.java b/src/main/java/serverutils/task/backup/ThreadBackup.java index 97aa44f11..6e44d7ebd 100644 --- a/src/main/java/serverutils/task/backup/ThreadBackup.java +++ b/src/main/java/serverutils/task/backup/ThreadBackup.java @@ -8,14 +8,15 @@ import java.io.DataOutputStream; import java.io.File; import java.io.IOException; +import java.io.InterruptedIOException; import java.nio.file.FileSystems; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.PathMatcher; -import java.nio.file.Paths; -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.Calendar; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Locale; import java.util.Set; import net.minecraft.nbt.CompressedStreamTools; @@ -39,6 +40,7 @@ import serverutils.ServerUtilitiesConfig; import serverutils.lib.math.ChunkDimPos; import serverutils.lib.math.Ticks; +import serverutils.lib.util.BackupGlobUtils; import serverutils.lib.util.FileUtils; import serverutils.lib.util.ServerUtils; import serverutils.lib.util.StringUtils; @@ -46,50 +48,61 @@ public class ThreadBackup extends Thread { - private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss"); + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter + .ofPattern("yyyy-MM-dd-HH-mm-ss", Locale.ROOT); private static long logMillis; private final File src0; private final String customName; private final Set chunksToBackup; - public boolean isDone = false; + private final boolean onlyClaimedChunks; + public volatile boolean isDone = false; private final ICompress compressor; public ThreadBackup(ICompress compress, File sourceFile, String backupName, Set backupChunks) { + this(compress, sourceFile, backupName, backupChunks, !backupChunks.isEmpty()); + } + + public ThreadBackup(ICompress compress, File sourceFile, String backupName, Set backupChunks, + boolean backupOnlyClaimedChunks) { src0 = sourceFile; customName = backupName; chunksToBackup = backupChunks; + onlyClaimedChunks = backupOnlyClaimedChunks; compressor = compress; + setName("ServerUtilities Backup"); setPriority(7); } public void run() { isDone = false; - doBackup(compressor, src0, customName, chunksToBackup); - isDone = true; + try { + doBackup(compressor, src0, customName, chunksToBackup, onlyClaimedChunks); + } finally { + isDone = true; + } } - private static void addBaseFolderFiles(List files, File saveFile) { + private static void addBaseFolderFiles(List files, File saveFile) throws IOException { String saveName = saveFile.getName(); for (String pattern : backups.additional_backup_files) { - pattern = pattern.replace("$WORLDNAME", saveName); + checkCancelled(); + String resolvedPattern = BackupGlobUtils.substituteLiteralPath(pattern, saveName); int firstWildcardIndex = pattern.indexOf('*'); if (firstWildcardIndex == -1) { - files.addAll(FileUtils.listTree(new File(pattern))); + files.addAll(FileUtils.listTree(new File(resolvedPattern))); + checkCancelled(); continue; } - Path rootFolder = Paths.get(pattern.substring(0, firstWildcardIndex)); - - // If wildcard was not at the start of a directory, get the parent - if (firstWildcardIndex != 0 && (pattern.charAt(firstWildcardIndex - 1) != '/')) { - rootFolder = rootFolder.getParent(); - } + Path rootFolder = BackupGlobUtils.searchRoot(pattern, saveName); - PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern); + PathMatcher matcher = FileSystems.getDefault() + .getPathMatcher("glob:" + BackupGlobUtils.substituteGlob(pattern, saveName)); List fileCandidates = FileUtils.listTree(rootFolder.toFile()); for (File file : fileCandidates) { + checkCancelled(); if (matcher.matches(file.toPath())) { files.add(file); } @@ -98,57 +111,100 @@ private static void addBaseFolderFiles(List files, File saveFile) { } public static void doBackup(ICompress compressor, File src, String customName, Set chunks) { - String outName = (customName.isEmpty() ? DATE_FORMAT.format(Calendar.getInstance().getTime()) : customName) - + ".zip"; + doBackup(compressor, src, customName, chunks, !chunks.isEmpty()); + } + + public static void doBackup(ICompress compressor, File src, String customName, Set chunks, + boolean backupOnlyClaimedChunks) { File dstFile = null; - try { + try (ICompress ignored = compressor) { + checkCancelled(); + dstFile = resolveBackupFile(BackupTask.BACKUP_FOLDER, customName); List files = FileUtils.listTree(src); + checkCancelled(); addBaseFolderFiles(files, src); long start = System.currentTimeMillis(); logMillis = start + Ticks.SECOND.x(5).millis(); - dstFile = FileUtils.newFile(new File(BackupTask.BACKUP_FOLDER, outName)); - try (compressor) { - compressor.createOutputStream(dstFile); - if (!chunks.isEmpty() && backups.only_backup_claimed_chunks) { - backupRegions(files, chunks, compressor); + Path destinationParent = dstFile.toPath().getParent(); + if (destinationParent != null) { + Files.createDirectories(destinationParent); + } + checkCancelled(); + compressor.createOutputStream(dstFile); + compressSelectedFiles(files, chunks, compressor, backupOnlyClaimedChunks); + checkCancelled(); + + String backupSize = FileUtils.getSizeString(dstFile); + ServerUtilities.LOGGER.info("Backup done in {} seconds ({})!", getDoneTime(start), backupSize); + ServerUtilities.LOGGER.info("Created {} from {}", dstFile.getAbsolutePath(), src.getAbsolutePath()); + + if (!backups.silent_backup) { + if (backups.display_file_size) { + String sizeT = FileUtils.getSizeString(BackupTask.BACKUP_FOLDER); + BACKUP.sendAll( + StringUtils.color( + "cmd.backup_end_2", + EnumChatFormatting.LIGHT_PURPLE, + getDoneTime(start), + (backupSize.equals(sizeT) ? backupSize : (backupSize + " | " + sizeT)))); } else { - compressFiles(files, compressor); - } - - String backupSize = FileUtils.getSizeString(dstFile); - ServerUtilities.LOGGER.info("Backup done in {} seconds ({})!", getDoneTime(start), backupSize); - ServerUtilities.LOGGER.info("Created {} from {}", dstFile.getAbsolutePath(), src.getAbsolutePath()); - - if (!backups.silent_backup) { - if (backups.display_file_size) { - String sizeT = FileUtils.getSizeString(BackupTask.BACKUP_FOLDER); - BACKUP.sendAll( - StringUtils.color( - "cmd.backup_end_2", - EnumChatFormatting.LIGHT_PURPLE, - getDoneTime(start), - (backupSize.equals(sizeT) ? backupSize : (backupSize + " | " + sizeT)))); - } else { - BACKUP.sendAll( - StringUtils.color( - "cmd.backup_end_1", - EnumChatFormatting.LIGHT_PURPLE, - getDoneTime(start))); - } + BACKUP.sendAll( + StringUtils.color("cmd.backup_end_1", EnumChatFormatting.LIGHT_PURPLE, getDoneTime(start))); } } + } catch (InterruptedIOException e) { + ServerUtilities.LOGGER.info("Backup cancelled"); + if (dstFile != null) FileUtils.delete(dstFile); } catch (Exception e) { ServerUtils.notifyChat( ServerUtils.getServer(), null, StringUtils.color("cmd.backup_fail", EnumChatFormatting.RED, e.getMessage())); ServerUtilities.LOGGER.error("Error while backing up", e); - if (dstFile != null) FileUtils.delete(dstFile); } } + static File resolveBackupFile(File backupFolder, String customName) throws IOException { + if (customName == null) { + throw new IOException("Backup name cannot be null"); + } + + String baseName = customName.isEmpty() ? DATE_FORMAT.format(LocalDateTime.now()) : customName; + if (!customName.isEmpty()) { + validateCustomName(customName); + } + + File canonicalRoot = backupFolder.getCanonicalFile(); + File destination = new File(canonicalRoot, baseName + ".zip").getCanonicalFile(); + if (!canonicalRoot.equals(destination.getParentFile())) { + throw new IOException("Backup name must resolve directly inside the backup folder"); + } + return destination; + } + + private static void validateCustomName(String customName) throws IOException { + if (customName.equals(".") || customName.equals("..") || customName.endsWith(" ") || customName.endsWith(".")) { + throw new IOException("Invalid backup name: " + customName); + } + + for (int i = 0; i < customName.length(); i++) { + char character = customName.charAt(i); + if (character < 32 || character == '/' + || character == '\\' + || character == ':' + || character == '*' + || character == '?' + || character == '"' + || character == '<' + || character == '>' + || character == '|') { + throw new IOException("Invalid backup name: " + customName); + } + } + } + private static void logProgress(int i, int allFiles, String name) { long millis = System.currentTimeMillis(); boolean first = i == 0; @@ -163,9 +219,19 @@ private static void logProgress(int i, int allFiles, String name) { } } + static void compressSelectedFiles(List files, Set chunks, ICompress compressor, + boolean backupOnlyClaimedChunks) throws IOException { + if (backupOnlyClaimedChunks) { + backupRegions(files, chunks, compressor); + } else { + compressFiles(files, compressor); + } + } + private static void compressFiles(List files, ICompress compressor) throws IOException { int allFiles = files.size(); for (int i = 0; i < allFiles; i++) { + checkCancelled(); File file = files.get(i); compressFile(FileUtils.getRelativePath(file), file, compressor, i, allFiles); } @@ -173,12 +239,15 @@ private static void compressFiles(List files, ICompress compressor) throws private static void compressFile(String entryName, File file, ICompress compressor, int index, int totalFiles) throws IOException { + checkCancelled(); logProgress(index, totalFiles, file.getAbsolutePath()); compressor.addFileToArchive(file, entryName); + checkCancelled(); } private static void backupRegions(List files, Set chunksToBackup, ICompress compressor) throws IOException { + checkCancelled(); Object2ObjectMap> dimRegionClaims = mapClaimsToRegionFile(chunksToBackup); files.removeIf(f -> f.getName().endsWith(".mca")); @@ -190,6 +259,7 @@ private static void backupRegions(List files, Set chunksToBac if (backups.backup_entire_regions_with_claims) { // Backup entire region files that contain claimed chunks for (Object2ObjectMap.Entry> entry : dimRegionClaims.object2ObjectEntrySet()) { + checkCancelled(); File regionFile = entry.getKey(); ObjectSet claimedChunks = entry.getValue(); savedChunks += claimedChunks.size(); @@ -202,40 +272,55 @@ private static void backupRegions(List files, Set chunksToBac } else { // Standard behavior: reconstruct temporary region files with only claimed chunks for (Object2ObjectMap.Entry> entry : dimRegionClaims.object2ObjectEntrySet()) { + checkCancelled(); File file = entry.getKey(); File dimensionRoot = file.getParentFile().getParentFile(); File tempFile = FileUtils.newFile(new File(BACKUP_TEMP_FOLDER, file.getName())); - RegionFile tempRegion = new RegionFile(tempFile); boolean hasData = false; + try { + RegionFile tempRegion = new RegionFile(tempFile); + try { + for (ChunkDimPos pos : entry.getValue()) { + checkCancelled(); + try (DataInputStream in = RegionFileCache + .getChunkInputStream(dimensionRoot, pos.posX, pos.posZ)) { + if (in == null) continue; + savedChunks++; + hasData = true; + NBTTagCompound tag = CompressedStreamTools.read(in); + try (DataOutputStream tempOut = tempRegion + .getChunkDataOutputStream(pos.posX & 31, pos.posZ & 31)) { + CompressedStreamTools.write(tag, tempOut); + } + } + } + } finally { + tempRegion.close(); + } - for (ChunkDimPos pos : entry.getValue()) { - DataInputStream in = RegionFileCache.getChunkInputStream(dimensionRoot, pos.posX, pos.posZ); - if (in == null) continue; - savedChunks++; - hasData = true; - NBTTagCompound tag = CompressedStreamTools.read(in); - DataOutputStream tempOut = tempRegion.getChunkDataOutputStream(pos.posX & 31, pos.posZ & 31); - CompressedStreamTools.write(tag, tempOut); - tempOut.close(); - } - - tempRegion.close(); - if (hasData) { - compressFile(FileUtils.getRelativePath(file), tempFile, compressor, index++, totalFiles); + if (hasData) { + compressFile(FileUtils.getRelativePath(file), tempFile, compressor, index++, totalFiles); + } + } finally { + FileUtils.delete(tempFile); } - - FileUtils.delete(tempFile); } ServerUtilities.LOGGER.info("Backed up {} regions containing {} claimed chunks", regionFiles, savedChunks); } for (File file : files) { + checkCancelled(); compressFile(FileUtils.getRelativePath(file), file, compressor, index++, totalFiles); } } - private static Object2ObjectMap> mapClaimsToRegionFile( - Set chunksToBackup) { + private static Object2ObjectMap> mapClaimsToRegionFile(Set chunksToBackup) + throws IOException { + if (chunksToBackup.isEmpty()) { + return new Object2ObjectOpenHashMap<>(); + } + + checkCancelled(); Int2ObjectMap>> regionClaimsByDim = new Int2ObjectOpenHashMap<>(); chunksToBackup.forEach( pos -> regionClaimsByDim.computeIfAbsent(pos.dim, k -> new Long2ObjectOpenHashMap<>()) @@ -244,6 +329,7 @@ private static Object2ObjectMap> mapClaimsToRegionF Object2ObjectMap> regionFilesToBackup = new Object2ObjectOpenHashMap<>(); for (WorldServer worldserver : ServerUtils.getServer().worldServers) { + checkCancelled(); if (worldserver == null) continue; int dim = worldserver.provider.dimensionId; @@ -255,6 +341,7 @@ private static Object2ObjectMap> mapClaimsToRegionF if (regions == null) continue; for (File file : regions) { + checkCancelled(); int[] coords = getRegionCoords(file); if (coords == null) continue; long key = CoordinatePacker.pack(coords[0], 0, coords[1]); @@ -271,6 +358,12 @@ private static Object2ObjectMap> mapClaimsToRegionF return regionFilesToBackup; } + static void checkCancelled() throws InterruptedIOException { + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedIOException("Backup cancelled"); + } + } + private static int[] getRegionCoords(File file) { if (!file.getName().endsWith(".mca")) return null; diff --git a/src/main/java/serverutils/watchdog/ServerHangWatchdog.java b/src/main/java/serverutils/watchdog/ServerHangWatchdog.java index ee7328fb5..a5d26fe52 100644 --- a/src/main/java/serverutils/watchdog/ServerHangWatchdog.java +++ b/src/main/java/serverutils/watchdog/ServerHangWatchdog.java @@ -55,14 +55,15 @@ public void run() { if (k > this.maxTickTime) { LOGGER.fatal( "A single server tick took {} seconds (should be max {})", - String.format("%.2f", (float) k / 1000.0F), - String.format("%.2f", 0.05F)); + String.format(java.util.Locale.ROOT, "%.2f", (float) k / 1000.0F), + String.format(java.util.Locale.ROOT, "%.2f", 0.05F)); LOGGER.fatal("Considering it to be crashed, server will forcibly shutdown."); ThreadMXBean threadmxbean = ManagementFactory.getThreadMXBean(); ThreadInfo[] athreadinfo = threadmxbean.dumpAllThreads(true, true); StringBuilder stringbuilder = new StringBuilder(); Error error = new Error( String.format( + java.util.Locale.ROOT, "ServerHangWatchdog detected that a single server tick took %.2f seconds (should be max 0.05)", k / 1000F)); // Forge: don't just make a crash report with a seemingly-inexplicable // Error @@ -95,8 +96,9 @@ public void run() { try { Thread.sleep(i + this.maxTickTime - j); - } catch (InterruptedException var15) { - ; + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return; } } } diff --git a/src/test/java/serverutils/api/ServerUtilitiesRegistryTest.java b/src/test/java/serverutils/api/ServerUtilitiesRegistryTest.java new file mode 100644 index 000000000..b05b6b39b --- /dev/null +++ b/src/test/java/serverutils/api/ServerUtilitiesRegistryTest.java @@ -0,0 +1,58 @@ +package serverutils.api; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import net.minecraft.util.ResourceLocation; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import serverutils.events.IReloadHandler; + +class ServerUtilitiesRegistryTest { + + private static final ResourceLocation TEST_ID = new ResourceLocation("serverutils", "registry_test"); + + @AfterEach + void cleanUp() { + serverutils.ServerUtilitiesRegistry.RELOAD_IDS.remove(TEST_ID); + } + + @Test + void checkedRegistrationRejectsDuplicatesAndExposesReadOnlyLiveView() { + IReloadHandler first = event -> true; + IReloadHandler second = event -> false; + + ServerUtilitiesRegistry.registerServerReloadHandler(TEST_ID, first); + + assertSame(first, ServerUtilitiesRegistry.findReloadHandler(TEST_ID)); + assertSame(first, ServerUtilitiesRegistry.reloadHandlersView().get(TEST_ID)); + assertThrows( + IllegalArgumentException.class, + () -> ServerUtilitiesRegistry.registerServerReloadHandler(TEST_ID, second)); + assertThrows( + UnsupportedOperationException.class, + () -> ServerUtilitiesRegistry.reloadHandlersView().put(TEST_ID, second)); + } + + @Test + void legacyMapMutationsRemainVisibleForCompatibility() { + IReloadHandler handler = event -> true; + + serverutils.ServerUtilitiesRegistry.RELOAD_IDS.put(TEST_ID, handler); + + assertSame(handler, ServerUtilitiesRegistry.findReloadHandler(TEST_ID)); + assertSame(handler, ServerUtilitiesRegistry.reloadHandlersView().get(TEST_ID)); + } + + @Test + void checkedRegistrationRejectsNulls() { + assertThrows( + NullPointerException.class, + () -> ServerUtilitiesRegistry.registerServerReloadHandler(null, event -> true)); + assertThrows( + NullPointerException.class, + () -> ServerUtilitiesRegistry.registerServerReloadHandler(TEST_ID, null)); + } +} diff --git a/src/test/java/serverutils/aurora/AuroraServerTest.java b/src/test/java/serverutils/aurora/AuroraServerTest.java new file mode 100644 index 000000000..b9e8ecac7 --- /dev/null +++ b/src/test/java/serverutils/aurora/AuroraServerTest.java @@ -0,0 +1,21 @@ +package serverutils.aurora; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.ServerSocket; + +import org.junit.jupiter.api.Test; + +class AuroraServerTest { + + @Test + void bindFailureShutsDownBothEventLoopGroups() throws Exception { + try (ServerSocket occupied = new ServerSocket(0)) { + AuroraServer server = new AuroraServer(null, occupied.getLocalPort()); + + assertFalse(server.start()); + assertTrue(server.eventLoopsAreShuttingDown()); + } + } +} diff --git a/src/test/java/serverutils/client/gui/GuiToggleCheatsButtonTest.java b/src/test/java/serverutils/client/gui/GuiToggleCheatsButtonTest.java new file mode 100644 index 000000000..35135bef1 --- /dev/null +++ b/src/test/java/serverutils/client/gui/GuiToggleCheatsButtonTest.java @@ -0,0 +1,65 @@ +package serverutils.client.gui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import net.minecraft.nbt.CompressedStreamTools; +import net.minecraft.nbt.NBTTagCompound; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class GuiToggleCheatsButtonTest { + + @TempDir + Path temporaryDirectory; + + @Test + void replacementKeepsPreviousMetadataAsOldCopy() throws Exception { + Path level = temporaryDirectory.resolve("level.dat"); + write(level, metadata((byte) 0)); + + GuiToggleCheatsButton.replaceLevelData(level.toFile(), metadata((byte) 1)); + + assertEquals(1, readAllowCommands(level)); + assertEquals(0, readAllowCommands(temporaryDirectory.resolve("level.dat_old"))); + } + + @Test + void failedOldCopyRotationLeavesLiveMetadataUntouched() throws Exception { + Path level = temporaryDirectory.resolve("level.dat"); + write(level, metadata((byte) 0)); + Files.createDirectory(temporaryDirectory.resolve("level.dat_old")); + + assertThrows( + IOException.class, + () -> GuiToggleCheatsButton.replaceLevelData(level.toFile(), metadata((byte) 1))); + assertEquals(0, readAllowCommands(level)); + } + + private static NBTTagCompound metadata(byte allowCommands) { + NBTTagCompound parent = new NBTTagCompound(); + NBTTagCompound data = new NBTTagCompound(); + data.setByte("allowCommands", allowCommands); + parent.setTag("Data", data); + return parent; + } + + private static void write(Path path, NBTTagCompound nbt) throws IOException { + try (FileOutputStream output = new FileOutputStream(path.toFile())) { + CompressedStreamTools.writeCompressed(nbt, output); + } + } + + private static int readAllowCommands(Path path) throws IOException { + try (FileInputStream input = new FileInputStream(path.toFile())) { + return CompressedStreamTools.readCompressed(input).getCompoundTag("Data").getByte("allowCommands"); + } + } +} diff --git a/src/test/java/serverutils/client/gui/RestoreTransactionTest.java b/src/test/java/serverutils/client/gui/RestoreTransactionTest.java new file mode 100644 index 000000000..56d25906a --- /dev/null +++ b/src/test/java/serverutils/client/gui/RestoreTransactionTest.java @@ -0,0 +1,119 @@ +package serverutils.client.gui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class RestoreTransactionTest { + + @TempDir + Path temporaryDirectory; + + @Test + void partialInstallCanRestoreWorldAndGlobalFiles() throws Exception { + Path root = Files.createDirectory(temporaryDirectory.resolve("server")); + Path world = write(root.resolve("saves/world/level.dat"), "old-world").getParent(); + Path config = write(root.resolve("config/server.cfg"), "old-config"); + Path staging = Files.createDirectories(root.resolve("serverutilities/restore-staging/restore-test")); + write(staging.resolve("saves/world/level.dat"), "new-world"); + write(staging.resolve("config/server.cfg"), "new-config"); + + Path rollbackRoot = root.resolve("backups_before_restore/test"); + RestoreTransaction transaction = new RestoreTransaction(root, rollbackRoot); + transaction.protect(root.resolve("serverutilities/restore-staging")); + transaction.protect(root.resolve("backups_before_restore")); + transaction.moveAside(world, root.resolve("saves/world_old")); + transaction.moveAside(config); + transaction.install(staging); + + assertEquals("new-world", read(root.resolve("saves/world/level.dat"))); + assertEquals("new-config", read(root.resolve("config/server.cfg"))); + + transaction.rollback(); + + assertEquals("old-world", read(root.resolve("saves/world/level.dat"))); + assertEquals("old-config", read(root.resolve("config/server.cfg"))); + assertFalse(Files.exists(root.resolve("saves/world_old"))); + } + + @Test + void transactionDataCannotBeInstalledOver() throws Exception { + Path root = Files.createDirectory(temporaryDirectory.resolve("server")); + Path staging = Files.createDirectories(root.resolve("serverutilities/restore-staging/restore-test")); + write(staging.resolve("backups_before_restore/evil.txt"), "evil"); + + RestoreTransaction transaction = new RestoreTransaction(root, root.resolve("backups_before_restore/test")); + transaction.protect(root.resolve("backups_before_restore")); + + assertThrows(IOException.class, () -> transaction.install(staging)); + assertFalse(Files.exists(root.resolve("backups_before_restore/evil.txt"))); + } + + @Test + void ancestorOfProtectedTransactionDataCannotBeMoved() throws Exception { + Path root = Files.createDirectory(temporaryDirectory.resolve("server-protected-ancestor")); + Path stagingBase = Files.createDirectories(root.resolve("serverutilities/restore-staging")); + write(stagingBase.resolve("active/file.dat"), "staged"); + + RestoreTransaction transaction = new RestoreTransaction(root, root.resolve("backups_before_restore/test")); + transaction.protect(stagingBase); + + assertThrows(IOException.class, () -> transaction.moveAside(root.resolve("serverutilities"))); + assertEquals("staged", read(stagingBase.resolve("active/file.dat"))); + transaction.rollback(); + } + + @Test + void interruptedProcessCanRecoverFromPersistentJournal() throws Exception { + Path root = Files.createDirectory(temporaryDirectory.resolve("server-recovery")); + Path world = write(root.resolve("saves/world/level.dat"), "old-world").getParent(); + Path staging = Files.createDirectories(root.resolve("serverutilities/restore-staging/recovery")); + write(staging.resolve("saves/world/level.dat"), "new-world"); + + RestoreTransaction transaction = new RestoreTransaction(root, root.resolve("backups_before_restore/recovery")); + transaction.moveAside(world, root.resolve("saves/world_old")); + transaction.install(staging); + + assertEquals("new-world", read(root.resolve("saves/world/level.dat"))); + RestoreRecovery.recoverPending(root); + + assertEquals("old-world", read(root.resolve("saves/world/level.dat"))); + assertFalse(Files.exists(root.resolve("saves/world_old"))); + } + + @Test + void midInstallFailureRollsBackEveryCompletedMove() throws Exception { + Path root = Files.createDirectory(temporaryDirectory.resolve("server-partial")); + write(root.resolve("config/one.cfg"), "old-one"); + write(root.resolve("config/two.cfg"), "old-two"); + Path staging = Files.createDirectories(root.resolve("serverutilities/restore-staging/partial")); + write(staging.resolve("config/one.cfg"), "new-one"); + write(staging.resolve("config/two.cfg"), "new-two"); + + RestoreTransaction transaction = new RestoreTransaction(root, root.resolve("backups_before_restore/partial")); + assertThrows( + IOException.class, + () -> transaction.install(staging, destination -> { throw new IOException("injected failure"); })); + transaction.rollback(); + + assertEquals("old-one", read(root.resolve("config/one.cfg"))); + assertEquals("old-two", read(root.resolve("config/two.cfg"))); + } + + private Path write(Path path, String value) throws IOException { + Files.createDirectories(path.getParent()); + return Files.write(path, value.getBytes(StandardCharsets.UTF_8)); + } + + private String read(Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/serverutils/data/ClaimedChunkMutationTest.java b/src/test/java/serverutils/data/ClaimedChunkMutationTest.java new file mode 100644 index 000000000..809be8364 --- /dev/null +++ b/src/test/java/serverutils/data/ClaimedChunkMutationTest.java @@ -0,0 +1,36 @@ +package serverutils.data; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import serverutils.lib.data.ForgeTeam; +import serverutils.lib.data.TeamType; +import serverutils.lib.data.Universe; +import serverutils.lib.math.ChunkDimPos; + +class ClaimedChunkMutationTest { + + @Test + void claimedChunkMutationsValidateOwnershipAndMarkTeamDirty() { + Universe universe = new Universe(null); + ForgeTeam team = new ForgeTeam(universe, (short) 2, "team", TeamType.SERVER); + ForgeTeam otherTeam = new ForgeTeam(universe, (short) 3, "other", TeamType.SERVER); + ClaimedChunk ownedChunk = new ClaimedChunk(new ChunkDimPos(1, 2, 0), new ServerUtilitiesTeamData(team)); + ClaimedChunk foreignChunk = new ClaimedChunk(new ChunkDimPos(3, 4, 0), new ServerUtilitiesTeamData(otherTeam)); + + team.markSaved(); + assertTrue(team.addClaimedChunk(ownedChunk)); + assertTrue(team.isDirty()); + assertFalse(team.addClaimedChunk(ownedChunk)); + assertThrows(IllegalArgumentException.class, () -> team.addClaimedChunk(foreignChunk)); + + team.markSaved(); + assertTrue(team.removeClaimedChunk(ownedChunk)); + assertTrue(team.isDirty()); + assertFalse(team.removeClaimedChunk(ownedChunk)); + assertThrows(IllegalArgumentException.class, () -> team.removeClaimedChunk(foreignChunk)); + } +} diff --git a/src/test/java/serverutils/lib/data/ActionTest.java b/src/test/java/serverutils/lib/data/ActionTest.java new file mode 100644 index 000000000..c05b25814 --- /dev/null +++ b/src/test/java/serverutils/lib/data/ActionTest.java @@ -0,0 +1,54 @@ +package serverutils.lib.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.ChatComponentText; +import net.minecraft.util.ResourceLocation; + +import org.junit.jupiter.api.Test; + +import serverutils.lib.icon.Icon; + +class ActionTest { + + @Test + void instancesSortByOrderThenTitle() { + Action.Inst later = instance("later", "Alpha", 10); + Action.Inst firstAlphabetically = instance("alpha", "Alpha", 5); + Action.Inst secondAlphabetically = instance("beta", "Beta", 5); + + assertEquals(1, Integer.signum(later.compareTo(firstAlphabetically))); + assertEquals(-1, Integer.signum(firstAlphabetically.compareTo(secondAlphabetically))); + } + + @Test + void clearerAliasesPreserveIdentitySemantics() { + Action first = action("same", "First", 0).setRequiresConfirm(); + Action sameId = action("same", "Second", 1); + + assertTrue(first.requiresConfirmation()); + assertTrue(first.getRequireConfirm()); + assertTrue(first.hasSameId(sameId)); + assertFalse(first.equals(sameId)); + } + + private static Action.Inst instance(String id, String title, int order) { + return new Action.Inst(action(id, title, order), Action.Type.ENABLED); + } + + private static Action action(String id, String title, int order) { + return new Action(new ResourceLocation("serverutils", id), new ChatComponentText(title), Icon.EMPTY, order) { + + @Override + public Type getType(ForgePlayer player, NBTTagCompound data) { + return Type.ENABLED; + } + + @Override + public void onAction(ForgePlayer player, NBTTagCompound data) {} + }; + } +} diff --git a/src/test/java/serverutils/lib/data/ForgeTeamTest.java b/src/test/java/serverutils/lib/data/ForgeTeamTest.java new file mode 100644 index 000000000..fbc69b358 --- /dev/null +++ b/src/test/java/serverutils/lib/data/ForgeTeamTest.java @@ -0,0 +1,196 @@ +package serverutils.lib.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; + +import net.minecraft.nbt.NBTTagCompound; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import serverutils.lib.EnumTeamColor; +import serverutils.lib.EnumTeamStatus; + +class ForgeTeamTest { + + private Universe universe; + private ForgePlayer player; + private TestForgeTeam team; + + @BeforeEach + void setUp() { + universe = new Universe(null); + universe.fakePlayer = new FakeForgePlayer(universe); + + player = new ForgePlayer(universe, UUID.randomUUID(), "TestPlayer"); + universe.registerPlayer(player); + + team = new TestForgeTeam(universe, (short) 2, "test_team", TeamType.SERVER); + universe.addTeam(team); + player.setTeam(team); + } + + @Test + void removingLastMemberPostsOneEventAndClearsMembership() { + assertEquals(1, team.getMembers().size()); + + assertTrue(team.removeMember(player)); + + assertEquals(1, team.playerLeftEventCount); + assertSame(universe.getTeam(""), player.getTeam()); + assertFalse(team.isMember(player)); + assertTrue(player.isDirty()); + assertTrue(team.isDirty()); + } + + @Test + void domainCollectionsExposeReadOnlyViews() { + assertThrows(UnsupportedOperationException.class, () -> universe.getPlayersView().clear()); + assertThrows(UnsupportedOperationException.class, () -> universe.getVanishedPlayersView().clear()); + assertThrows(UnsupportedOperationException.class, () -> universe.getTeams().clear()); + assertThrows(UnsupportedOperationException.class, () -> team.getPlayerStatusesView().clear()); + assertThrows(UnsupportedOperationException.class, () -> team.getClaimedChunksView().clear()); + assertSame(team, universe.getTeam(team.getId())); + assertSame(team, universe.getTeam(team.getUID())); + } + + @Test + void teamEqualityUsesUidWithinOneUniverseOnly() { + ForgeTeam sameIdentity = new ForgeTeam(universe, team.getUID(), "same_uid", TeamType.SERVER); + Universe otherUniverse = new Universe(null); + ForgeTeam otherUniverseTeam = new ForgeTeam(otherUniverse, team.getUID(), "test_team", TeamType.SERVER); + + assertEquals(team, sameIdentity); + assertEquals(team.hashCode(), sameIdentity.hashCode()); + assertNotEquals(team, otherUniverseTeam); + assertNotEquals(team, Integer.valueOf(team.getUID())); + assertNotEquals(Integer.valueOf(team.getUID()), team); + } + + @Test + void changingTeamValidatesOwnershipAndMaintainsMutationState() { + ForgeTeam nextTeam = new ForgeTeam(universe, (short) 3, "next_team", TeamType.SERVER); + universe.addTeam(nextTeam); + player.markSaved(); + team.markSaved(); + nextTeam.markSaved(); + player.cachedPlayerNBT = new NBTTagCompound(); + + player.setTeam(nextTeam); + + assertSame(nextTeam, player.getTeam()); + assertNull(player.cachedPlayerNBT); + assertTrue(player.isDirty()); + assertTrue(team.isDirty()); + assertTrue(nextTeam.isDirty()); + assertThrows(NullPointerException.class, () -> player.setTeam(null)); + + Universe otherUniverse = new Universe(null); + ForgeTeam foreignTeam = new ForgeTeam(otherUniverse, (short) 4, "foreign", TeamType.SERVER); + assertThrows(IllegalArgumentException.class, () -> player.setTeam(foreignTeam)); + assertSame(nextTeam, player.getTeam()); + } + + @Test + void loadTimeTeamHydrationAvoidsFalseDirtyState() { + ForgeTeam loadedTeam = new ForgeTeam(universe, (short) 3, "loaded_team", TeamType.SERVER); + universe.addTeam(loadedTeam); + player.markSaved(); + loadedTeam.markSaved(); + + player.setTeamFromLoad(loadedTeam); + + assertSame(loadedTeam, player.getTeam()); + assertFalse(player.isDirty()); + assertFalse(loadedTeam.isDirty()); + } + + @Test + void initializingOwnerKeepsOwnerAndMembershipConsistent() { + ForgeTeam playerTeam = new ForgeTeam(universe, (short) 3, "owned_team", TeamType.PLAYER); + universe.addTeam(playerTeam); + player.markSaved(); + team.markSaved(); + playerTeam.markSaved(); + + playerTeam.initializeOwner(player); + + assertSame(player, playerTeam.getOwner()); + assertSame(playerTeam, player.getTeam()); + assertTrue(player.isDirty()); + assertTrue(playerTeam.isDirty()); + assertThrows(IllegalStateException.class, () -> team.initializeOwner(player)); + + Universe otherUniverse = new Universe(null); + ForgePlayer foreignPlayer = new ForgePlayer(otherUniverse, UUID.randomUUID(), "Foreign"); + assertThrows(IllegalArgumentException.class, () -> playerTeam.setStoredOwner(foreignPlayer)); + } + + @Test + void vanishedStateIsChangedThroughUniverse() { + assertTrue(universe.setVanished(player, true)); + assertTrue(universe.getVanishedPlayersView().contains(player)); + assertTrue(universe.setVanished(player, false)); + assertFalse(universe.getVanishedPlayersView().contains(player)); + } + + @Test + void persistedTeamStateAndMembershipRoundTripWithoutChangingNbtKeys() { + ForgePlayer enemy = registerPlayer("Enemy"); + ForgePlayer requester = registerPlayer("Requester"); + team.setTitle("Test title"); + team.setDesc("Test description"); + team.setColor(EnumTeamColor.RED); + team.setIcon("serverutils:settings"); + team.setFreeToJoin(true); + team.setStatus(enemy, EnumTeamStatus.ENEMY); + team.setRequestingInvite(requester, true); + + NBTTagCompound serialized = team.serializeNBT(); + assertEquals("Test title", serialized.getString("Title")); + assertEquals("Test description", serialized.getString("Desc")); + assertEquals("red", serialized.getString("Color")); + assertEquals("serverutils:settings", serialized.getString("Icon")); + assertTrue(serialized.hasKey("Players")); + assertTrue(serialized.hasKey("RequestingInvite")); + assertTrue(serialized.hasKey("Data")); + + ForgeTeam restored = new ForgeTeam(universe, (short) 3, "restored", TeamType.SERVER); + universe.addTeam(restored); + restored.deserializeNBT(serialized); + + assertEquals("Test description", restored.getDesc()); + assertEquals(EnumTeamColor.RED, restored.getColor()); + assertTrue(restored.isFreeToJoin()); + assertTrue(restored.isEnemy(enemy)); + assertTrue(restored.isRequestingInvite(requester)); + assertEquals(EnumTeamStatus.ENEMY, restored.getPlayerStatusesView().get(enemy)); + } + + private ForgePlayer registerPlayer(String name) { + ForgePlayer registered = new ForgePlayer(universe, UUID.randomUUID(), name); + universe.registerPlayer(registered); + return registered; + } + + private static final class TestForgeTeam extends ForgeTeam { + + private int playerLeftEventCount; + + private TestForgeTeam(Universe universe, short id, String name, TeamType type) { + super(universe, id, name, type); + } + + @Override + void postPlayerLeftEvent(ForgePlayer player) { + playerLeftEventCount++; + } + } +} diff --git a/src/test/java/serverutils/lib/data/UniverseTaskSchedulerTest.java b/src/test/java/serverutils/lib/data/UniverseTaskSchedulerTest.java new file mode 100644 index 000000000..e96ab460d --- /dev/null +++ b/src/test/java/serverutils/lib/data/UniverseTaskSchedulerTest.java @@ -0,0 +1,81 @@ +package serverutils.lib.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import serverutils.task.NotifyTask; +import serverutils.task.Task; + +class UniverseTaskSchedulerTest { + + @Test + void tasksQueuedDuringExecutionWaitUntilTheNextTick() { + Universe universe = new Universe(null); + UniverseTaskScheduler scheduler = new UniverseTaskScheduler(); + CountingTask second = new CountingTask(null); + CountingTask first = new CountingTask(() -> scheduler.schedule(universe, second, true)); + + scheduler.schedule(universe, first, true); + scheduler.tick(universe); + + assertEquals(1, first.executions); + assertEquals(0, second.executions); + + scheduler.tick(universe); + assertEquals(1, second.executions); + } + + @Test + void notificationListIsCapturedOnceBeforeScheduling() { + Universe universe = new Universe(null); + SingleReadNotificationTask task = new SingleReadNotificationTask(); + + task.queueNotifications(universe); + + assertEquals(1, task.notificationReads); + } + + private static final class CountingTask extends Task { + + private final Runnable afterExecution; + private int executions; + + private CountingTask(Runnable afterExecution) { + super(0L); + this.afterExecution = afterExecution; + } + + @Override + public void execute(Universe universe) { + executions++; + if (afterExecution != null) { + afterExecution.run(); + } + } + } + + private static final class SingleReadNotificationTask extends Task { + + private int notificationReads; + + private SingleReadNotificationTask() { + super(0L); + } + + @Override + public void execute(Universe universe) {} + + @Override + protected List getNotifications() { + notificationReads++; + if (notificationReads > 1) { + throw new AssertionError("Notifications must be captured once"); + } + return Collections.singletonList(new NotifyTask(0L, null)); + } + } +} diff --git a/src/test/java/serverutils/lib/data/UniverseTest.java b/src/test/java/serverutils/lib/data/UniverseTest.java new file mode 100644 index 000000000..a09913136 --- /dev/null +++ b/src/test/java/serverutils/lib/data/UniverseTest.java @@ -0,0 +1,106 @@ +package serverutils.lib.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; +import java.util.UUID; +import java.util.stream.Collectors; + +import net.minecraft.nbt.NBTTagCompound; + +import org.junit.jupiter.api.Test; + +class UniverseTest { + + @Test + void explicitSingletonAccessReportsUnloadedLifecycle() { + assertNull(Universe.getIfLoaded()); + assertThrows(IllegalStateException.class, Universe::requireLoaded); + } + + @Test + void exactAndFuzzyPlayerSearchesAreDeterministic() { + Universe universe = new Universe(null); + universe.fakePlayer = new FakeForgePlayer(universe); + ForgePlayer zed = player(universe, "ZedOne", "00000000-0000-0000-0000-000000000002"); + ForgePlayer alpha = player(universe, "AlphaOne", "00000000-0000-0000-0000-000000000001"); + + assertSame(alpha, universe.findPlayerExact("AlphaOne")); + assertSame(zed, universe.findPlayerExact(zed.getId().toString())); + assertEquals( + Arrays.asList("AlphaOne", "ZedOne"), + universe.searchPlayers("one").stream().map(ForgePlayer::getName).collect(Collectors.toList())); + assertSame(alpha, universe.getPlayer("one")); + } + + @Test + void ambiguousExactNamesAreReported() { + Universe universe = new Universe(null); + universe.fakePlayer = new FakeForgePlayer(universe); + player(universe, "Duplicate", "00000000-0000-0000-0000-000000000001"); + player(universe, "duplicate", "00000000-0000-0000-0000-000000000002"); + + assertNull(universe.findPlayerExact("DUPLICATE")); + assertEquals(2, universe.searchPlayers("DUPLICATE").size()); + } + + @Test + void legacyLookupKeepsFakePlayerPriority() { + Universe universe = new Universe(null); + universe.fakePlayer = new FakeForgePlayer(universe); + player(universe, universe.fakePlayer.getName(), "00000000-0000-0000-0000-000000000001"); + + assertSame(universe.fakePlayer, universe.getPlayer(universe.fakePlayer.getName())); + assertNull(universe.findPlayerExact(universe.fakePlayer.getName())); + } + + @Test + void teamActionPayloadAcceptsUuidAndLegacyName() { + Universe universe = new Universe(null); + universe.fakePlayer = new FakeForgePlayer(universe); + ForgePlayer actor = player(universe, "Actor", "00000000-0000-0000-0000-000000000001"); + ForgePlayer target = player(universe, "Target", "00000000-0000-0000-0000-000000000002"); + NBTTagCompound payload = new NBTTagCompound(); + + payload.setString("player", target.getId().toString()); + assertSame(target, ServerUtilitiesTeamGuiActions.getPayloadPlayer(actor, payload)); + + payload.setString("player", target.getName()); + assertSame(target, ServerUtilitiesTeamGuiActions.getPayloadPlayer(actor, payload)); + } + + @Test + void duplicateTeamIndexesAreRejectedAtomically() { + Universe universe = new Universe(null); + ForgeTeam original = new ForgeTeam(universe, (short) 2, "original", TeamType.SERVER); + universe.addTeam(original); + + ForgeTeam duplicateId = new ForgeTeam(universe, (short) 3, "original", TeamType.SERVER); + assertThrows(IllegalArgumentException.class, () -> universe.addTeam(duplicateId)); + assertSame(original, universe.getTeam("original")); + assertSame(original, universe.getTeam((short) 2)); + + ForgeTeam duplicateUid = new ForgeTeam(universe, (short) 2, "duplicate_uid", TeamType.SERVER); + assertThrows(IllegalArgumentException.class, () -> universe.addTeam(duplicateUid)); + assertSame(original, universe.getTeam("original")); + assertSame(original, universe.getTeam((short) 2)); + } + + @Test + void teamsFromOtherUniversesCannotBeRegistered() { + Universe universe = new Universe(null); + Universe otherUniverse = new Universe(null); + ForgeTeam foreignTeam = new ForgeTeam(otherUniverse, (short) 2, "foreign", TeamType.SERVER); + + assertThrows(IllegalArgumentException.class, () -> universe.addTeam(foreignTeam)); + } + + private ForgePlayer player(Universe universe, String name, String id) { + ForgePlayer player = new ForgePlayer(universe, UUID.fromString(id), name); + universe.registerPlayer(player); + return player; + } +} diff --git a/src/test/java/serverutils/lib/io/DataSerializationTest.java b/src/test/java/serverutils/lib/io/DataSerializationTest.java new file mode 100644 index 000000000..7494fcc69 --- /dev/null +++ b/src/test/java/serverutils/lib/io/DataSerializationTest.java @@ -0,0 +1,34 @@ +package serverutils.lib.io; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; + +class DataSerializationTest { + + @Test + void primitivesAndIdentifiersRoundTrip() { + ByteBuf buffer = Unpooled.buffer(); + UUID expectedId = UUID.fromString("12345678-1234-5678-9abc-def012345678"); + DataOut output = new DataOut(buffer); + + output.writeBoolean(true); + output.writeVarInt(123456); + output.writeString("Server Utilities"); + output.writeUUID(expectedId); + + DataIn input = new DataIn(buffer); + assertTrue(input.readBoolean()); + assertEquals(123456, input.readVarInt()); + assertEquals("Server Utilities", input.readString()); + assertEquals(expectedId, input.readUUID()); + assertFalse(input.isReadable()); + } +} diff --git a/src/test/java/serverutils/lib/util/NBTUtilsTest.java b/src/test/java/serverutils/lib/util/NBTUtilsTest.java new file mode 100644 index 000000000..9fda93a82 --- /dev/null +++ b/src/test/java/serverutils/lib/util/NBTUtilsTest.java @@ -0,0 +1,41 @@ +package serverutils.lib.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; + +import net.minecraft.nbt.NBTTagCompound; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class NBTUtilsTest { + + @TempDir + Path temporaryDirectory; + + @Test + void checkedWriteReportsSuccessOnlyAfterReadableReplacement() { + Path target = temporaryDirectory.resolve("data.dat"); + NBTTagCompound tag = new NBTTagCompound(); + tag.setString("value", "saved"); + + assertTrue(NBTUtils.writeNBTChecked(target.toFile(), tag)); + assertEquals("saved", NBTUtils.readNBT(target.toFile()).getString("value")); + } + + @Test + void checkedWriteReportsReplacementFailureAndCleansTemporaryFile() throws Exception { + Path targetDirectory = Files.createDirectory(temporaryDirectory.resolve("data.dat")); + NBTTagCompound tag = new NBTTagCompound(); + + assertFalse(NBTUtils.writeNBTChecked(targetDirectory.toFile(), tag)); + assertTrue(Files.isDirectory(targetDirectory)); + try (java.util.stream.Stream children = Files.list(temporaryDirectory)) { + assertEquals(1L, children.count()); + } + } +} diff --git a/src/test/java/serverutils/lib/util/StringUtilsTest.java b/src/test/java/serverutils/lib/util/StringUtilsTest.java new file mode 100644 index 000000000..126755f46 --- /dev/null +++ b/src/test/java/serverutils/lib/util/StringUtilsTest.java @@ -0,0 +1,105 @@ +package serverutils.lib.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class StringUtilsTest { + + private Locale previousLocale; + + @BeforeEach + void rememberLocale() { + previousLocale = Locale.getDefault(); + } + + @AfterEach + void restoreLocale() { + Locale.setDefault(previousLocale); + } + + @Test + void identifiersAreNormalizedUsingExistingRules() { + assertEquals("mixed_case_id", StringUtils.getID("Mixed Case-ID", StringUtils.FLAG_ID_DEFAULTS)); + assertEquals( + "mixed_case", + StringUtils.getID("Mixed Case", StringUtils.FLAG_ID_FIX | StringUtils.FLAG_ID_ONLY_UNDERLINE)); + assertThrows( + IllegalArgumentException.class, + () -> StringUtils.getID("Uppercase", StringUtils.FLAG_ID_ONLY_UNDERLINE)); + } + + @Test + void compactUuidRoundTrips() { + UUID expected = UUID.fromString("12345678-1234-5678-9abc-def012345678"); + + assertEquals(expected, StringUtils.fromString(StringUtils.fromUUID(expected))); + assertNull(StringUtils.fromString("not-a-uuid")); + } + + @Test + void identifiersAndNumbersDoNotDependOnDefaultLocale() { + Locale.setDefault(new Locale("tr", "TR")); + + assertEquals("identifier", StringUtils.getID("IDENTIFIER", StringUtils.FLAG_ID_DEFAULTS)); + assertEquals("1.2", StringUtils.formatDouble0(1.29D)); + assertEquals("1.29", StringUtils.formatDouble00(1.299D)); + assertEquals("1", StringUtils.formatDouble00(1D)); + } + + @Test + void numberFormattingIsSafeAcrossThreads() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(8); + try { + List> tasks = new ArrayList<>(); + for (int i = 0; i < 200; i++) { + tasks.add(() -> StringUtils.formatDouble00(1234.567D)); + } + + for (Future result : executor.invokeAll(tasks)) { + assertEquals("1234.56", result.get()); + } + } finally { + executor.shutdownNow(); + } + } + + @Test + void deprecatedFormattingStateRemainsBehaviorallyCompatible() { + String previousPattern; + synchronized (StringUtils.DOUBLE_FORMATTER_00) { + previousPattern = StringUtils.DOUBLE_FORMATTER_00.toPattern(); + StringUtils.DOUBLE_FORMATTER_00.applyPattern("0.000"); + } + + int previousFirstThreshold = StringUtils.INT_SIZE_TABLE[0]; + try { + assertEquals("1.234", StringUtils.formatDouble00(1.2349D)); + StringUtils.INT_SIZE_TABLE[0] = 0; + assertEquals(2, StringUtils.stringSize(1)); + } finally { + synchronized (StringUtils.DOUBLE_FORMATTER_00) { + StringUtils.DOUBLE_FORMATTER_00.applyPattern(previousPattern); + } + StringUtils.INT_SIZE_TABLE[0] = previousFirstThreshold; + } + } + + @Test + void motdStyleFormattingPreservesHalfEvenRounding() { + assertEquals("20.0", NumberFormatUtils.formatRoundedOneDecimal(19.96D)); + } +} diff --git a/src/test/java/serverutils/lib/util/compression/CompressorTest.java b/src/test/java/serverutils/lib/util/compression/CompressorTest.java new file mode 100644 index 000000000..9c0fefc95 --- /dev/null +++ b/src/test/java/serverutils/lib/util/compression/CompressorTest.java @@ -0,0 +1,225 @@ +package serverutils.lib.util.compression; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static serverutils.ServerUtilitiesConfig.backups; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.function.Supplier; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class CompressorTest { + + @TempDir + Path temporaryDirectory; + + private String[] previousAdditionalFiles; + + @BeforeEach + void setUp() { + previousAdditionalFiles = backups.additional_backup_files; + backups.additional_backup_files = new String[0]; + } + + @AfterEach + void tearDown() { + backups.additional_backup_files = previousAdditionalFiles; + } + + @Test + void bothBackendsRestoreOldAndNewLayouts() throws Exception { + for (Supplier> factory : compressors()) { + AbstractZipCompressor compressor = factory.get(); + Path oldArchive = createArchive("old.zip", "Legacy World", "world/level.dat", "old"); + Path oldDestination = Files + .createDirectory(temporaryDirectory.resolve(factory.get().getClass().getSimpleName())); + + assertTrue(compressor.isOldBackup(oldArchive.toFile())); + compressor.extractArchiveTo(oldDestination, oldArchive.toFile(), true, true); + assertEquals("old", read(oldDestination.resolve("saves/world/level.dat"))); + + Path newArchive = createArchive( + factory.get().getClass().getSimpleName() + "-new.zip", + "Current World", + "saves/world/level.dat", + "new"); + Path newDestination = Files + .createDirectory(temporaryDirectory.resolve(factory.get().getClass().getSimpleName() + "-new")); + + assertFalse(compressor.isOldBackup(newArchive.toFile())); + compressor.extractArchiveTo(newDestination, newArchive.toFile(), true, false); + assertEquals("new", read(newDestination.resolve("saves/world/level.dat"))); + assertEquals("Current World", compressor.getWorldName(newArchive.toFile())); + } + } + + @Test + void bothBackendsRejectTraversalAndAbsoluteEntries() throws Exception { + List unsafeNames = Arrays.asList("../escape.txt", "..\\escape.txt", "/absolute.txt", "C:/drive.txt"); + + for (Supplier> factory : compressors()) { + for (String unsafeName : unsafeNames) { + for (boolean oldLayout : Arrays.asList(false, true)) { + AbstractZipCompressor compressor = factory.get(); + List entries = oldLayout ? Arrays.asList(unsafeName) + : Arrays.asList("saves/world/level.dat", unsafeName); + Path archive = createArchive( + factory.get().getClass().getSimpleName() + '-' + + oldLayout + + '-' + + Math.abs(unsafeName.hashCode()) + + ".zip", + null, + entries, + "unsafe"); + Path destination = Files.createDirectories( + temporaryDirectory.resolve( + factory.get().getClass().getSimpleName() + "-unsafe-" + + oldLayout + + '-' + + Math.abs(unsafeName.hashCode()))); + + boolean detectedOldLayout = compressor.isOldBackup(archive.toFile()); + assertEquals(oldLayout, detectedOldLayout); + assertThrows( + IOException.class, + () -> compressor.extractArchiveTo(destination, archive.toFile(), true, detectedOldLayout)); + assertFalse(Files.exists(destination.resolve("saves/world/level.dat"))); + assertFalse(Files.exists(temporaryDirectory.resolve("escape.txt"))); + } + } + } + } + + @Test + void globalBackupPatternsAreExcludedWhenRequested() throws Exception { + backups.additional_backup_files = new String[] { "config/**" }; + + for (Supplier> factory : compressors()) { + AbstractZipCompressor compressor = factory.get(); + Path archive = createArchive( + factory.get().getClass().getSimpleName() + "-global.zip", + null, + "config/server.cfg", + "config"); + Path destination = Files.createDirectory( + temporaryDirectory.resolve(factory.get().getClass().getSimpleName() + "-filtered")); + + compressor.extractArchiveTo(destination, archive.toFile(), false, false); + assertFalse(Files.exists(destination.resolve("config/server.cfg"))); + } + } + + @Test + void worldOnlyRestoreCannotWriteAnotherWorldOrUnrelatedGlobalFiles() throws Exception { + backups.additional_backup_files = new String[] { "config/$WORLDNAME/**", "mods/**" }; + + for (Supplier> factory : compressors()) { + AbstractZipCompressor compressor = factory.get(); + Path archive = createArchive( + factory.get().getClass().getSimpleName() + "-scoped.zip", + "world", + Arrays.asList( + "saves/world/level.dat", + "saves/other/level.dat", + "config/world/settings.cfg", + "config/other/settings.cfg", + "mods/unrelated.jar"), + "content"); + Path destination = Files + .createDirectory(temporaryDirectory.resolve(factory.get().getClass().getSimpleName() + "-scoped")); + + compressor.extractArchiveTo(destination.toFile(), archive.toFile(), false, false, "world"); + + assertTrue(Files.exists(destination.resolve("saves/world/level.dat"))); + assertTrue(Files.exists(destination.resolve("config/world/settings.cfg"))); + assertFalse(Files.exists(destination.resolve("saves/other/level.dat"))); + assertFalse(Files.exists(destination.resolve("config/other/settings.cfg"))); + assertFalse(Files.exists(destination.resolve("mods/unrelated.jar"))); + } + } + + @Test + void worldNameIsLiteralWhenAppliedToConfiguredGlob() throws Exception { + backups.additional_backup_files = new String[] { "config/$WORLDNAME/**" }; + + for (Supplier> factory : compressors()) { + AbstractZipCompressor compressor = factory.get(); + Path archive = createArchive( + factory.get().getClass().getSimpleName() + "-literal-world.zip", + "world[1]", + Arrays.asList( + "saves/world[1]/level.dat", + "config/world[1]/settings.cfg", + "config/world1/settings.cfg"), + "content"); + Path destination = Files.createDirectory( + temporaryDirectory.resolve(factory.get().getClass().getSimpleName() + "-literal-world")); + + compressor.extractArchiveTo(destination.toFile(), archive.toFile(), false, false, "world[1]"); + + assertTrue(Files.exists(destination.resolve("saves/world[1]/level.dat"))); + assertTrue(Files.exists(destination.resolve("config/world[1]/settings.cfg"))); + assertFalse(Files.exists(destination.resolve("config/world1/settings.cfg"))); + } + } + + @Test + void archiveCopyStopsPromptlyWhenItsThreadIsInterrupted() { + Thread.currentThread().interrupt(); + try { + assertThrows( + InterruptedIOException.class, + () -> AbstractZipCompressor.copyArchiveInput( + new ByteArrayInputStream(new byte[128 * 1024]), + new ByteArrayOutputStream())); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + } + + private List>> compressors() { + return Arrays.asList(LegacyCompressor::new, CommonsCompressor::new); + } + + private Path createArchive(String name, String comment, String entryName, String content) throws IOException { + return createArchive(name, comment, Arrays.asList(entryName), content); + } + + private Path createArchive(String name, String comment, List entryNames, String content) + throws IOException { + Path archive = temporaryDirectory.resolve(name); + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(archive))) { + if (comment != null) { + output.setComment(comment); + } + for (String entryName : entryNames) { + output.putNextEntry(new ZipEntry(entryName)); + output.write(content.getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + } + return archive; + } + + private String read(Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/serverutils/task/backup/BackupLifecycleTest.java b/src/test/java/serverutils/task/backup/BackupLifecycleTest.java new file mode 100644 index 000000000..d32e170eb --- /dev/null +++ b/src/test/java/serverutils/task/backup/BackupLifecycleTest.java @@ -0,0 +1,61 @@ +package serverutils.task.backup; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class BackupLifecycleTest { + + @Test + void backToBackRunsCannotOverlapOrReleaseEachOthersSlot() { + BackupLifecycle lifecycle = new BackupLifecycle(); + BackupLifecycle.Run first = lifecycle.tryBegin(); + + assertNotNull(first); + assertTrue(lifecycle.isInProgress()); + assertNull(lifecycle.tryBegin()); + + assertTrue(lifecycle.complete(first)); + BackupLifecycle.Run second = lifecycle.tryBegin(); + assertNotNull(second); + + assertFalse(lifecycle.complete(first)); + assertTrue(lifecycle.isCurrent(second)); + assertTrue(lifecycle.complete(second)); + assertFalse(lifecycle.isInProgress()); + } + + @Test + void saveStateSnapshotDefensivelyCopiesEachExecution() { + List source = new ArrayList<>(); + source.add(new BackupSaveStateSnapshot.WorldState(2, true)); + + BackupSaveStateSnapshot snapshot = new BackupSaveStateSnapshot(source); + source.clear(); + + assertEquals(1, snapshot.worldStates().size()); + assertEquals(2, snapshot.worldStates().get(0).worldIndex); + assertTrue(snapshot.worldStates().get(0).levelSaving); + assertThrows( + UnsupportedOperationException.class, + () -> snapshot.worldStates().add(new BackupSaveStateSnapshot.WorldState(3, false))); + } + + @Test + void claimedScopeHonorsForcedRunsAndKeepsZeroClaimRunsFiltered() { + assertEquals(BackupScope.CLAIMED_CHUNKS, BackupScope.select(true, false, true)); + assertEquals(BackupScope.CLAIMED_CHUNKS, BackupScope.select(false, true, true)); + assertTrue(BackupScope.CLAIMED_CHUNKS.isClaimedChunksOnly()); + + assertEquals(BackupScope.FULL_WORLD, BackupScope.select(false, false, true)); + assertEquals(BackupScope.FULL_WORLD, BackupScope.select(true, false, false)); + } +} diff --git a/src/test/java/serverutils/task/backup/ThreadBackupTest.java b/src/test/java/serverutils/task/backup/ThreadBackupTest.java new file mode 100644 index 000000000..73af213c8 --- /dev/null +++ b/src/test/java/serverutils/task/backup/ThreadBackupTest.java @@ -0,0 +1,134 @@ +package serverutils.task.backup; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import serverutils.lib.math.ChunkDimPos; +import serverutils.lib.util.BackupGlobUtils; +import serverutils.lib.util.compression.ICompress; + +class ThreadBackupTest { + + @TempDir + Path temporaryDirectory; + + @Test + void customBackupNamesStayDirectlyInsideTheBackupFolder() throws Exception { + File destination = ThreadBackup.resolveBackupFile(temporaryDirectory.toFile(), "nightly"); + + assertEquals(temporaryDirectory.resolve("nightly.zip").toFile().getCanonicalFile(), destination); + + for (String unsafeName : new String[] { "../escape", "..\\escape", "nested/name", "C:escape", ".", "name " }) { + assertThrows( + IOException.class, + () -> ThreadBackup.resolveBackupFile(temporaryDirectory.toFile(), unsafeName), + unsafeName); + } + } + + @Test + void wildcardWithoutAParentFallsBackToTheWorkingDirectory() { + String rootLevelPattern = "server*.properties"; + + assertEquals(Paths.get(""), BackupGlobUtils.searchRoot(rootLevelPattern, "")); + } + + @Test + void worldFolderIsEscapedWhenInsertedIntoBackupGlob() { + PathMatcher matcher = java.nio.file.FileSystems.getDefault() + .getPathMatcher("glob:" + BackupGlobUtils.substituteGlob("config/$WORLDNAME/**", "world[1]")); + + assertTrue(matcher.matches(Paths.get("config/world[1]/settings.cfg"))); + assertFalse(matcher.matches(Paths.get("config/world1/settings.cfg"))); + assertEquals(Paths.get("config/world[1]"), BackupGlobUtils.searchRoot("config/$WORLDNAME/**", "world[1]")); + } + + @Test + void claimedModeWithNoClaimsExcludesEveryRegionInsteadOfFallingBackToAFullBackup() throws Exception { + Path levelData = Files.write(temporaryDirectory.resolve("level.dat"), new byte[] { 1 }); + Path region = Files.write(temporaryDirectory.resolve("r.0.0.mca"), new byte[] { 2 }); + List files = new ArrayList<>(); + files.add(levelData.toFile()); + files.add(region.toFile()); + RecordingCompressor compressor = new RecordingCompressor(false); + + ThreadBackup.compressSelectedFiles(files, Collections.emptySet(), compressor, true); + + assertEquals(Collections.singletonList("level.dat"), compressor.archivedFileNames); + assertFalse(files.contains(region.toFile())); + } + + @Test + void interruptionStopsCompressionBeforeTheNextFile() throws Exception { + Path first = Files.write(temporaryDirectory.resolve("first.dat"), new byte[] { 1 }); + Path second = Files.write(temporaryDirectory.resolve("second.dat"), new byte[] { 2 }); + RecordingCompressor compressor = new RecordingCompressor(true); + + try { + assertThrows( + InterruptedIOException.class, + () -> ThreadBackup.compressSelectedFiles( + new ArrayList<>(java.util.Arrays.asList(first.toFile(), second.toFile())), + Collections.emptySet(), + compressor, + false)); + assertEquals(1, compressor.archivedFileNames.size()); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + } + + private static final class RecordingCompressor implements ICompress { + + private final List archivedFileNames = new ArrayList<>(); + private final boolean interruptAfterFirstFile; + + private RecordingCompressor(boolean interruptAfterFirstFile) { + this.interruptAfterFirstFile = interruptAfterFirstFile; + } + + @Override + public void createOutputStream(File file) {} + + @Override + public void addFileToArchive(File file, String name) { + archivedFileNames.add(file.getName()); + if (interruptAfterFirstFile && archivedFileNames.size() == 1) { + Thread.currentThread().interrupt(); + } + } + + @Override + public void extractArchive(File archive, boolean includeGlobal, boolean isOldBackup) {} + + @Override + public boolean isOldBackup(File archive) { + return false; + } + + @Override + public String getWorldName(File file) { + return null; + } + + @Override + public void close() {} + } +}