From 562fb014fa8f0084cb67d11bbc4f166d89c7af44 Mon Sep 17 00:00:00 2001 From: Shiewk Date: Sat, 25 Jan 2025 14:20:19 +0100 Subject: [PATCH] Initial Commit (v3.0.0) --- .gitignore | 65 ++++++ LICENSE | 201 +++++++++++++++++ README.md | 34 +++ build.gradle | 49 +++++ docs/commands.md | 6 + docs/permissions.md | 6 + gradle.properties | 0 gradle/wrapper/gradle-wrapper.properties | 7 + settings.gradle | 1 + .../blockhistory/v3/BlockHistoryPlugin.java | 89 ++++++++ .../blockhistory/v3/HistoryManager.java | 162 ++++++++++++++ .../shiewk/blockhistory/v3/StatManager.java | 46 ++++ .../v3/command/BlockHistoryCommand.java | 158 ++++++++++++++ .../v3/exception/LowDiskSpaceException.java | 15 ++ .../v3/history/BlockHistoryElement.java | 153 +++++++++++++ .../history/BlockHistorySearchCallback.java | 11 + .../v3/history/BlockHistoryType.java | 20 ++ .../v3/listener/BlockListener.java | 202 ++++++++++++++++++ .../v3/util/BlockHistoryFileNames.java | 81 +++++++ .../blockhistory/v3/util/CommandUtil.java | 35 +++ .../v3/util/NamedLoggingThreadFactory.java | 34 +++ .../blockhistory/v3/util/PlayerUtil.java | 30 +++ .../shiewk/blockhistory/v3/util/UnitUtil.java | 49 +++++ src/main/resources/plugin.yml | 22 ++ 24 files changed, 1476 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.gradle create mode 100644 docs/commands.md create mode 100644 docs/permissions.md create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 settings.gradle create mode 100644 src/main/java/de/shiewk/blockhistory/v3/BlockHistoryPlugin.java create mode 100644 src/main/java/de/shiewk/blockhistory/v3/HistoryManager.java create mode 100644 src/main/java/de/shiewk/blockhistory/v3/StatManager.java create mode 100644 src/main/java/de/shiewk/blockhistory/v3/command/BlockHistoryCommand.java create mode 100644 src/main/java/de/shiewk/blockhistory/v3/exception/LowDiskSpaceException.java create mode 100644 src/main/java/de/shiewk/blockhistory/v3/history/BlockHistoryElement.java create mode 100644 src/main/java/de/shiewk/blockhistory/v3/history/BlockHistorySearchCallback.java create mode 100644 src/main/java/de/shiewk/blockhistory/v3/history/BlockHistoryType.java create mode 100644 src/main/java/de/shiewk/blockhistory/v3/listener/BlockListener.java create mode 100644 src/main/java/de/shiewk/blockhistory/v3/util/BlockHistoryFileNames.java create mode 100644 src/main/java/de/shiewk/blockhistory/v3/util/CommandUtil.java create mode 100644 src/main/java/de/shiewk/blockhistory/v3/util/NamedLoggingThreadFactory.java create mode 100644 src/main/java/de/shiewk/blockhistory/v3/util/PlayerUtil.java create mode 100644 src/main/java/de/shiewk/blockhistory/v3/util/UnitUtil.java create mode 100644 src/main/resources/plugin.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c6fc70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +.idea/ +*.iml +*.ipr +*.iws +out/ +.idea_modules/ +atlassian-ide-plugin.xml +*.class +*.log +*.ctxt +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar +hs_err_pid* +*~ +.fuse_hidden* +.directory +.Trash-* +.nfs* +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +*.stackdump +[Dd]esktop.ini +$RECYCLE.BIN/ +*.cab +*.msi +*.msix +*.msm +*.msp +*.lnk +.gradle +build/ +gradle-app.setting +.gradletasknamecache +**/build/ +run/ +runs/ +gradle-wrapper.jar +./gradlew +./gradlew.bat +gradlew +gradlew.bat \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f49a4e1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffeae25 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# BlockHistory + +A simple but effective Paper Minecraft plugin that allows you to find griefers on your server more easily. + +This plugin can help you find griefers more easily. The plugin saves when a block is broken or placed. That way you can easily see who broke or placed blocks. + +## Usage +Install the plugin on your server, and it will start to track every block's history. + +### Block history command +Then, if you need to view the history of a block, run **[/blockhistory history \ \ \](https://github.com/Shiewk/BlockHistory3/blob/main/docs/commands.md)**. This will display the history of the block. It should look like this: + +![BlockHistory history command chat output](https://github.com/user-attachments/assets/a89e2eb2-f34e-4834-98b3-412ca19c8f25) + +This way, you can easily see changes like someone destroying your builds etc. + +### Stats command + +You can use the **[/blockhistory stats](https://github.com/Shiewk/BlockHistory3/blob/main/docs/commands.md)** command to see statistics about the plugin's disk usage and uptime. This is what the command's output looks like: + +![BlockHistory stats command chat output](https://github.com/user-attachments/assets/cbcfb8a6-01a8-4737-a7a1-52427b2b891a) + +## Supported interactions +Currently, the following block interactions are supported: +- Placing blocks +- Breaking blocks +- Buckets +- Exploding with end crystal / TNT / Creeper / Respawn Anchor / Bed +- Sign changes +- Opening containers + +## Further documentation +- [Command list](https://github.com/Shiewk/BlockHistory3/blob/main/docs/commands.md) +- [Permission list](https://github.com/Shiewk/BlockHistory3/blob/main/docs/permissions.md) \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..ee814ea --- /dev/null +++ b/build.gradle @@ -0,0 +1,49 @@ +plugins { + id 'java' +} + +group = 'de.shiewk' +version = '3.0.0' + +repositories { + mavenCentral() + maven { + name = "papermc-repo" + url = "https://repo.papermc.io/repository/maven-public/" + } + maven { + name = "sonatype" + url = "https://oss.sonatype.org/content/groups/public/" + } +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21-R0.1-SNAPSHOT") +} + +def targetJavaVersion = 21 +java { + def javaVersion = JavaVersion.toVersion(targetJavaVersion) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + if (JavaVersion.current() < javaVersion) { + toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion) + } +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + + if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) { + options.release.set(targetJavaVersion) + } +} + +processResources { + def props = [version: version] + inputs.properties props + filteringCharset 'UTF-8' + filesMatching('plugin.yml') { + expand props + } +} diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..71cdf54 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,6 @@ +# BlockHistory commands +| Command | Description | Permission | +|-----------------------------------------------|-------------------------------------------------------------------------------------|------------------------------| +| /blockhistory stats | Shows stats about the plugin. | blockhistory.command.stats | +| /blockhistory history \ \ \ | Shows the history of the block at the specified coordinates. | blockhistory.command.history | +| /blockhistory history \ \ \ \ | Shows the history of the block at the specified coordinates in the specified world. | blockhistory.command.history | diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 0000000..8bbbdf3 --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,6 @@ +# BlockHistory Permissions +| Permission | Description | Default | Child permissions | +|------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------|---------------------------| +| blockhistory.command.stats | Allows the player to use /blockhistory stats | Only server operators | blockhistory.command.root | +| blockhistory.command.history | Allows the player to use /blockhistory history | Only server operators | blockhistory.command.root | +| blockhistory.command.root | Allows the player to access the root /blockhistory command node (you can't do anything with this unless you have one of the other permissions as well) | Only server operators | *None* | \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..e69de29 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a441313 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..811ef02 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'BlockHistory3' diff --git a/src/main/java/de/shiewk/blockhistory/v3/BlockHistoryPlugin.java b/src/main/java/de/shiewk/blockhistory/v3/BlockHistoryPlugin.java new file mode 100644 index 0000000..46a2f0e --- /dev/null +++ b/src/main/java/de/shiewk/blockhistory/v3/BlockHistoryPlugin.java @@ -0,0 +1,89 @@ +package de.shiewk.blockhistory.v3; + +import de.shiewk.blockhistory.v3.command.BlockHistoryCommand; +import de.shiewk.blockhistory.v3.listener.BlockListener; +import io.papermc.paper.command.brigadier.Commands; +import io.papermc.paper.plugin.lifecycle.event.registrar.ReloadableRegistrarEvent; +import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.logger.slf4j.ComponentLogger; +import org.bukkit.Bukkit; +import org.bukkit.event.Listener; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static net.kyori.adventure.text.Component.text; + +public final class BlockHistoryPlugin extends JavaPlugin { + + private static ComponentLogger LOGGER = null; + private static BlockHistoryPlugin INSTANCE = null; + + public static final TextColor COLOR_PRIMARY = TextColor.color(0xff8500), + COLOR_SECONDARY = TextColor.color(0xff0011), + COLOR_FAIL = TextColor.color(0xCF0000); + + public static final Component CHAT_PREFIX = text("BlockHistory \u00BB ", COLOR_SECONDARY); + + private HistoryManager historyManager; + private StatManager statManager; + + @Override + public void onLoad() { + INSTANCE = this; + LOGGER = getComponentLogger(); + } + + @Override + public void onEnable() { + statManager = new StatManager(); + Path saveDirectory = Path.of(getDataFolder().getPath(), "history"); + try { + Files.createDirectories(saveDirectory); + } catch (IOException e) { + throw new RuntimeException(e); + } + historyManager = new HistoryManager(LOGGER, saveDirectory, statManager); + + getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS, this::registerCommands); + + Bukkit.getScheduler().scheduleSyncRepeatingTask(this, BlockListener::clearCache, 6000, 6000); + + listen(new BlockListener()); + } + + private void registerCommands(@NotNull ReloadableRegistrarEvent event) { + Commands commands = event.registrar(); + commands.register(new BlockHistoryCommand().getCommandNode()); + } + + private void listen(Listener listener){ + Bukkit.getPluginManager().registerEvents(listener, this); + } + + @Override + public void onDisable() { + historyManager.shutdown(); + } + + public HistoryManager getHistoryManager() { + return historyManager; + } + + public StatManager getStatManager() { + return statManager; + } + + public static BlockHistoryPlugin instance() { + return INSTANCE; + } + + public static ComponentLogger logger() { + return LOGGER; + } +} diff --git a/src/main/java/de/shiewk/blockhistory/v3/HistoryManager.java b/src/main/java/de/shiewk/blockhistory/v3/HistoryManager.java new file mode 100644 index 0000000..8c99d8c --- /dev/null +++ b/src/main/java/de/shiewk/blockhistory/v3/HistoryManager.java @@ -0,0 +1,162 @@ +package de.shiewk.blockhistory.v3; + +import de.shiewk.blockhistory.v3.exception.LowDiskSpaceException; +import de.shiewk.blockhistory.v3.history.BlockHistoryElement; +import de.shiewk.blockhistory.v3.history.BlockHistorySearchCallback; +import de.shiewk.blockhistory.v3.util.BlockHistoryFileNames; +import de.shiewk.blockhistory.v3.util.NamedLoggingThreadFactory; +import org.bukkit.Location; +import org.bukkit.World; +import org.slf4j.Logger; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public final class HistoryManager { + + private final Path saveDirectory; + private final Logger logger; + private final StatManager statManager; + + private final ThreadPoolExecutor writeExecutor; + private final ThreadPoolExecutor readExecutor; + + HistoryManager(Logger logger, Path saveDirectory, StatManager statManager) { + this.saveDirectory = saveDirectory; + this.logger = logger; + this.statManager = statManager; + AtomicInteger threadNumber = new AtomicInteger(); + String threadName = "BlockHistoryIO"; + int threadPriority = 2; + writeExecutor = new ThreadPoolExecutor( + 1, + 1, + 1, + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(0xff), + new NamedLoggingThreadFactory( + threadName, + threadPriority, + logger, + "BlockHistory write I/O", + threadNumber + ) + ); + readExecutor = new ThreadPoolExecutor( + 0, + 3, + 30, + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(3), + new NamedLoggingThreadFactory( + threadName, + threadPriority, + logger, + "BlockHistory read I/O", + threadNumber + ) + ); + } + + public Path getSaveDirectory() { + return saveDirectory; + } + + void shutdown() { + logger.info("Shutting down I/O executor..."); + long n = System.nanoTime(); + readExecutor.shutdown(); + writeExecutor.shutdown(); + try { + if (!readExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + logger.warn("Read executor shutdown timed out after {}ms", (System.nanoTime() - n) / 1000000); + } + if (!writeExecutor.awaitTermination(10, TimeUnit.SECONDS)) { + logger.warn("Read executor shutdown timed out after {}ms", (System.nanoTime() - n) / 1000000); + } + } catch (InterruptedException e){ + logger.warn("Thread interrupted while waiting for shutdown"); + } + logger.info("Shutdown finished ({}ms)", (System.nanoTime() - n) / 1000000); + } + + public CompletableFuture addHistoryElement(BlockHistoryElement element){ + Objects.requireNonNull(element); + return CompletableFuture.runAsync(() -> { + try { + byte[] saveData = element.saveData(); + writeToDisk( + BlockHistoryFileNames.encode(saveDirectory, element.getLocation()), + saveData + ); + statManager.bytesWritten(saveData.length); + statManager.elementWritten(); + } catch (LowDiskSpaceException e) { + BlockHistoryPlugin.logger().warn("Free disk space is too low to safely write further history elements: {} bytes", e.getFreeBytes()); + } catch (Exception e) { + + StringWriter strw = new StringWriter(); + e.printStackTrace(new PrintWriter(strw)); + + BlockHistoryPlugin.logger().warn("Exception while writing history element:"); + for (String s : strw.toString().split("\n")) { + BlockHistoryPlugin.logger().warn(s); + } + } + }, writeExecutor); + } + + private void writeToDisk(Path path, byte[] saveData) throws LowDiskSpaceException, IOException { + long usableDiskSpace = getUsableDiskSpace(); + if (usableDiskSpace < 8192){ + throw new LowDiskSpaceException(usableDiskSpace); + } + Files.createDirectories(path.getParent()); + Files.write(path, saveData, StandardOpenOption.APPEND, StandardOpenOption.CREATE); + } + + public long getUsableDiskSpace() throws IOException { + return Files.getFileStore(saveDirectory).getUsableSpace(); + } + + public CompletableFuture searchAsync(BlockHistorySearchCallback callback, World world, int x, int y, int z) { + return CompletableFuture.runAsync(() -> { + try { + this.search(callback, world, x, y, z); + } catch (FileNotFoundException e) { + callback.onNoFilePresent(e); + } catch (Throwable e) { + throw new RuntimeException(e); + } + }, readExecutor); + } + + private void search(BlockHistorySearchCallback callback, World world, int x, int y, int z) throws IOException { + Location location = new Location(world, x, y, z); + Path path = BlockHistoryFileNames.encode(saveDirectory, location); + try (FileInputStream fin = new FileInputStream(path.toFile())){ + try (DataInputStream dataIn = new DataInputStream(fin)){ + while (true) { + try { + int b = dataIn.readUnsignedByte(); + BlockHistoryElement element = BlockHistoryElement.read(b, dataIn, world, location.getChunk().getX(), location.getChunk().getZ()); + if (element.x() == x && element.y() == y && element.z() == z){ + callback.onElementFound(element); + } + } catch (EOFException e) { + // done reading + return; + } + } + } + } + } +} diff --git a/src/main/java/de/shiewk/blockhistory/v3/StatManager.java b/src/main/java/de/shiewk/blockhistory/v3/StatManager.java new file mode 100644 index 0000000..2ca1932 --- /dev/null +++ b/src/main/java/de/shiewk/blockhistory/v3/StatManager.java @@ -0,0 +1,46 @@ +package de.shiewk.blockhistory.v3; + +public final class StatManager { + + private final long startTime = System.nanoTime(); + private long elementsWritten = 0; + private long bytesWritten = 0; + + StatManager(){} + + void elementWritten(){ + elementsWritten++; + } + + void bytesWritten(int bytes){ + bytesWritten += bytes; + } + + public long getElementsWritten() { + return elementsWritten; + } + + public long getStartTime() { + return startTime; + } + + public long getTimeMsSinceStart(){ + return (System.nanoTime() - getStartTime()) / 1000000; + } + + public long getTimeSecondsSinceStart(){ + return getTimeMsSinceStart() / 1000; + } + + public float getElementsWrittenPerSecond() { + return Math.round((float) getElementsWritten() / getTimeSecondsSinceStart() * 10f) / 10f; + } + + public long getBytesWritten() { + return bytesWritten; + } + + public float getBytesWrittenPerSecond() { + return Math.round((float) getBytesWritten() / getTimeSecondsSinceStart() * 10f) / 10f; + } +} diff --git a/src/main/java/de/shiewk/blockhistory/v3/command/BlockHistoryCommand.java b/src/main/java/de/shiewk/blockhistory/v3/command/BlockHistoryCommand.java new file mode 100644 index 0000000..2d25fdf --- /dev/null +++ b/src/main/java/de/shiewk/blockhistory/v3/command/BlockHistoryCommand.java @@ -0,0 +1,158 @@ +package de.shiewk.blockhistory.v3.command; + +import com.mojang.brigadier.Command; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.tree.LiteralCommandNode; +import de.shiewk.blockhistory.v3.BlockHistoryPlugin; +import de.shiewk.blockhistory.v3.StatManager; +import de.shiewk.blockhistory.v3.history.BlockHistoryElement; +import de.shiewk.blockhistory.v3.history.BlockHistorySearchCallback; +import de.shiewk.blockhistory.v3.util.CommandUtil; +import de.shiewk.blockhistory.v3.util.PlayerUtil; +import de.shiewk.blockhistory.v3.util.UnitUtil; +import io.papermc.paper.command.brigadier.CommandSourceStack; +import io.papermc.paper.command.brigadier.argument.ArgumentTypes; +import io.papermc.paper.command.brigadier.argument.resolvers.BlockPositionResolver; +import io.papermc.paper.math.BlockPosition; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.World; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.NotNull; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicInteger; + +import static de.shiewk.blockhistory.v3.BlockHistoryPlugin.*; +import static io.papermc.paper.command.brigadier.Commands.argument; +import static io.papermc.paper.command.brigadier.Commands.literal; +import static net.kyori.adventure.text.Component.*; + +public final class BlockHistoryCommand { + + public @NotNull LiteralCommandNode getCommandNode() { + return literal("blockhistory") + .requires(CommandUtil.requirePermission("blockhistory.command.root")) + .then(literal("stats") + .requires(CommandUtil.requirePermission("blockhistory.command.stats")) + .executes(this::statsCommand) + ) + .then(literal("history") + .requires(CommandUtil.requirePermission("blockhistory.command.history")) + .then(argument("location", ArgumentTypes.blockPosition()) + .executes(this::historyCommand) + .then(argument("world", ArgumentTypes.world()) + .executes(this::historyCommand) + ) + ) + ) + .build(); + } + + private int statsCommand(CommandContext context) { + CommandSender sender = context.getSource().getSender(); + StatManager statManager = BlockHistoryPlugin.instance().getStatManager(); + long millisSinceStart = statManager.getTimeMsSinceStart(); + sender.sendMessage(CHAT_PREFIX.append( + text("The plugin has started up ", COLOR_PRIMARY) + .append(text(UnitUtil.formatTime(millisSinceStart), COLOR_SECONDARY)) + .append(text(" ago.")) + )); + sender.sendMessage(CHAT_PREFIX.append( + text("So far, we have written ", COLOR_PRIMARY) + .append(text(statManager.getElementsWritten(), COLOR_SECONDARY)) + .append(text(" history elements to disk. ")) + .append(text("(%s elements per second)".formatted(statManager.getElementsWrittenPerSecond()), NamedTextColor.GRAY)) + )); + sender.sendMessage(CHAT_PREFIX.append( + text("These elements have a total size of ", COLOR_PRIMARY) + .append(text(UnitUtil.formatDataSize(statManager.getBytesWritten()), COLOR_SECONDARY)) + .append(text(". ")) + .append(text("(%s per second)".formatted(UnitUtil.formatDataSize(statManager.getBytesWrittenPerSecond())), NamedTextColor.GRAY)) + )); + try { + long usableDiskSpace = instance().getHistoryManager().getUsableDiskSpace(); + sender.sendMessage(CHAT_PREFIX.append( + text("There are ", COLOR_PRIMARY) + .append(text(UnitUtil.formatDataSize(usableDiskSpace), COLOR_SECONDARY)) + .append(text(" of disk space available.")) + )); + } catch (IOException e) { + sender.sendMessage(CHAT_PREFIX.append( + text("Failed to get usable disk space", COLOR_FAIL) + )); + StringWriter strw = new StringWriter(); + e.printStackTrace(new PrintWriter(strw)); + + BlockHistoryPlugin.logger().warn("Exception while getting usable disk space:"); + for (String s : strw.toString().split("\n")) { + BlockHistoryPlugin.logger().warn(s); + } + } + return Command.SINGLE_SUCCESS; + } + + private int historyCommand(CommandContext context) throws CommandSyntaxException { + CommandSourceStack stack = context.getSource(); + CommandSender sender = stack.getSender(); + BlockPosition blockPosition = context.getArgument("location", BlockPositionResolver.class).resolve(stack); + World world; + try { + world = context.getArgument("world", World.class); + } catch (IllegalArgumentException ignored) { + world = CommandUtil.getPlayerOf(stack, "You need to provide a world to search").getWorld(); + } + int blockZ = blockPosition.blockZ(); + int blockY = blockPosition.blockY(); + int blockX = blockPosition.blockX(); + sender.sendMessage(CHAT_PREFIX.append(text("Searching in world %s at x=%s y=%s z=%s, please wait...\n".formatted(world.key(), blockX, blockY, blockZ), COLOR_PRIMARY))); + try { + World searchedWorld = world; + long n = System.nanoTime(); + AtomicInteger foundElements = new AtomicInteger(0); + BlockHistoryPlugin.instance().getHistoryManager().searchAsync( + new BlockHistoryCommandCallback(sender, foundElements), + world, + blockX, + blockY, + blockZ + ).whenComplete((unused, throwable) -> { + if (throwable != null){ + StringWriter strw = new StringWriter(); + throwable.printStackTrace(new PrintWriter(strw)); + + BlockHistoryPlugin.logger().warn("Exception while searching block at world {} x {} y {} z {}:", searchedWorld, blockX, blockY, blockZ); + for (String s : strw.toString().split("\n")) { + BlockHistoryPlugin.logger().warn(s); + } + sender.sendMessage(CHAT_PREFIX.append(text("An error occurred while searching, please check the server console.\n", COLOR_FAIL))); + } else { + int elementsFound = foundElements.get(); + sender.sendMessage((elementsFound > 0 ? newline() : empty()).append(CHAT_PREFIX.append(text("Search completed in %s ms, %s elements found".formatted((System.nanoTime() - n) / 1000000, elementsFound), COLOR_PRIMARY)))); + } + }); + } catch (RejectedExecutionException e) { + sender.sendMessage(text("The searching system is currently too busy, please try again later.", COLOR_FAIL)); + } + return Command.SINGLE_SUCCESS; + } + + private record BlockHistoryCommandCallback(CommandSender sender, AtomicInteger foundElements) implements BlockHistorySearchCallback { + + @Override + public void onElementFound(BlockHistoryElement element) { + sender.sendMessage(CHAT_PREFIX.append(element.toComponent(PlayerUtil::playerName))); + foundElements.getAndIncrement(); + } + + @Override + public void onNoFilePresent(FileNotFoundException e) { + BlockHistoryPlugin.logger().info("No file present"); + } + + } +} diff --git a/src/main/java/de/shiewk/blockhistory/v3/exception/LowDiskSpaceException.java b/src/main/java/de/shiewk/blockhistory/v3/exception/LowDiskSpaceException.java new file mode 100644 index 0000000..0210bed --- /dev/null +++ b/src/main/java/de/shiewk/blockhistory/v3/exception/LowDiskSpaceException.java @@ -0,0 +1,15 @@ +package de.shiewk.blockhistory.v3.exception; + +public class LowDiskSpaceException extends Exception { + + private final long freeBytes; + + public LowDiskSpaceException(long freeBytes){ + super("Free disk space is too low to safely execute this action: " + freeBytes + " bytes"); + this.freeBytes = freeBytes; + } + + public long getFreeBytes() { + return freeBytes; + } +} diff --git a/src/main/java/de/shiewk/blockhistory/v3/history/BlockHistoryElement.java b/src/main/java/de/shiewk/blockhistory/v3/history/BlockHistoryElement.java new file mode 100644 index 0000000..7fdf7f8 --- /dev/null +++ b/src/main/java/de/shiewk/blockhistory/v3/history/BlockHistoryElement.java @@ -0,0 +1,153 @@ +package de.shiewk.blockhistory.v3.history; + +import de.shiewk.blockhistory.v3.util.UnitUtil; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.text.DateFormat; +import java.time.Instant; +import java.util.Date; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Function; + +import static de.shiewk.blockhistory.v3.BlockHistoryPlugin.COLOR_PRIMARY; +import static de.shiewk.blockhistory.v3.BlockHistoryPlugin.COLOR_SECONDARY; +import static net.kyori.adventure.text.Component.text; +import static net.kyori.adventure.text.Component.translatable; + +public record BlockHistoryElement( + @NotNull World world, + int x, + int y, + int z, + @NotNull BlockHistoryType type, + long timestamp, + @Nullable UUID player, + @NotNull Material material, + byte[] additionalData +) { + + + public BlockHistoryElement( + @NotNull World world, + int x, + int y, + int z, + @NotNull BlockHistoryType type, + long timestamp, + @Nullable UUID player, + @NotNull Material material, + byte @Nullable [] additionalData + ) { + Objects.requireNonNull(world, "world"); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(material, "material"); + this.world = world; + this.x = x; + this.y = y; + this.z = z; + this.type = type; + this.timestamp = timestamp; + this.player = player; + this.material = material; + this.additionalData = Objects.requireNonNullElse(additionalData, new byte[0]); + } + + public static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.LONG); + + public Component toComponent(Function playerNameResolver) { + Component playerName = playerNameResolver.apply(player); + Date date = Date.from(Instant.ofEpochMilli(timestamp)); + TextComponent component = text("Block ", COLOR_PRIMARY) + .append(translatable(material, COLOR_SECONDARY)) + .append(text(" was ")) + .append(text(type.displayName, COLOR_SECONDARY)) + .append(text(" by ")) + .append(playerName.colorIfAbsent(COLOR_SECONDARY)) + .append(text(" at ")) + .append(text(dateFormat.format(date))); + if (additionalData.length > 0 && type == BlockHistoryType.SIGN){ + component = component.append( + text(": \"") + .append(MiniMessage.miniMessage().deserialize(new String(additionalData))) + .append(text("\"")) + ); + } + return component; + } + + public static BlockHistoryElement read(int dataVersion, DataInputStream dataIn, World world, int chunkX, int chunkZ) throws IOException { + return switch (dataVersion) { + case 0 -> readv0(dataIn, world, chunkX, chunkZ); + default -> throw new IllegalStateException("Unknown data version: " + dataVersion); + }; + } + + private static BlockHistoryElement readv0(DataInputStream dataIn, World world, int chunkX, int chunkZ) throws IOException { + byte chunkRelativeX = dataIn.readByte(); + int realX = chunkX < 0 ? (chunkX+1) * 16 - (16 - chunkRelativeX) : chunkX * 16 + chunkRelativeX; + byte chunkRelativeZ = dataIn.readByte(); + int realZ = chunkZ < 0 ? (chunkZ+1) * 16 - (16 - chunkRelativeZ) : chunkZ * 16 + chunkRelativeZ; + int y = dataIn.readInt(); + BlockHistoryType type = BlockHistoryType.values()[dataIn.readInt()]; + long timestamp = dataIn.readLong(); + UUID player; + if (dataIn.readBoolean()){ + player = new UUID(dataIn.readLong(), dataIn.readLong()); + } else player = null; + String materialName = dataIn.readUTF(); + Material material = Material.getMaterial(materialName); + if (material == null){ + throw new IllegalStateException("Material " + materialName + " not found"); + } + int additionalLength = dataIn.readInt(); + byte[] additionalData; + if (additionalLength > 0){ + additionalData = dataIn.readNBytes(additionalLength); + } else { + additionalData = new byte[0]; + } + return new BlockHistoryElement( + world, realX, y, realZ, type, timestamp, player, material, additionalData + ); + } + + public byte[] saveData() throws IOException { + ByteArrayOutputStream arrayOut = new ByteArrayOutputStream(); + try (DataOutputStream dataOut = new DataOutputStream(arrayOut)) { + dataOut.writeByte(0); // data version + dataOut.writeByte(UnitUtil.getBlockChunkLocation(x)); + dataOut.writeByte(UnitUtil.getBlockChunkLocation(z)); + dataOut.writeInt(y); + dataOut.writeInt(type.ordinal()); + dataOut.writeLong(timestamp); + boolean writePlayerData = player != null; + dataOut.writeBoolean(writePlayerData); + if (writePlayerData){ + dataOut.writeLong(player.getMostSignificantBits()); + dataOut.writeLong(player.getLeastSignificantBits()); + } + dataOut.writeUTF(material.name()); + dataOut.writeInt(additionalData.length); + dataOut.flush(); + } + arrayOut.write(additionalData); + arrayOut.flush(); + return arrayOut.toByteArray(); + } + + public Location getLocation() { + return new Location(world, x, y, z); + } +} diff --git a/src/main/java/de/shiewk/blockhistory/v3/history/BlockHistorySearchCallback.java b/src/main/java/de/shiewk/blockhistory/v3/history/BlockHistorySearchCallback.java new file mode 100644 index 0000000..8b7af4d --- /dev/null +++ b/src/main/java/de/shiewk/blockhistory/v3/history/BlockHistorySearchCallback.java @@ -0,0 +1,11 @@ +package de.shiewk.blockhistory.v3.history; + +import java.io.FileNotFoundException; + +public interface BlockHistorySearchCallback { + + void onElementFound(BlockHistoryElement element); + + void onNoFilePresent(FileNotFoundException e); + +} diff --git a/src/main/java/de/shiewk/blockhistory/v3/history/BlockHistoryType.java b/src/main/java/de/shiewk/blockhistory/v3/history/BlockHistoryType.java new file mode 100644 index 0000000..acd6ec1 --- /dev/null +++ b/src/main/java/de/shiewk/blockhistory/v3/history/BlockHistoryType.java @@ -0,0 +1,20 @@ +package de.shiewk.blockhistory.v3.history; + +public enum BlockHistoryType { + PLACE("PLACED"), + BREAK("BROKEN"), + EMPTY_BUCKET("PLACED USING BUCKET"), + FILL_BUCKET("PICKED UP USING BUCKET"), + EXPLODE_END_CRYSTAL("EXPLODED USING END CRYSTAL"), + EXPLODE_TNT("EXPLODED USING TNT"), + EXPLODE_CREEPER("EXPLODED USING CREEPER"), + EXPLODE_BLOCK("EXPLODED USING BLOCK"), + SIGN("CHANGED"), + CHEST_OPEN("OPENED"); + + public final String displayName; + + BlockHistoryType(String displayName) { + this.displayName = displayName; + } +} diff --git a/src/main/java/de/shiewk/blockhistory/v3/listener/BlockListener.java b/src/main/java/de/shiewk/blockhistory/v3/listener/BlockListener.java new file mode 100644 index 0000000..016f921 --- /dev/null +++ b/src/main/java/de/shiewk/blockhistory/v3/listener/BlockListener.java @@ -0,0 +1,202 @@ +package de.shiewk.blockhistory.v3.listener; + +import de.shiewk.blockhistory.v3.BlockHistoryPlugin; +import de.shiewk.blockhistory.v3.history.BlockHistoryElement; +import de.shiewk.blockhistory.v3.history.BlockHistoryType; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.Container; +import org.bukkit.block.data.type.Bed; +import org.bukkit.entity.*; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.*; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.player.PlayerBucketEmptyEvent; +import org.bukkit.event.player.PlayerBucketFillEvent; +import org.bukkit.event.player.PlayerInteractAtEntityEvent; +import org.bukkit.event.player.PlayerInteractEvent; + +import java.util.UUID; + +public final class BlockListener implements Listener { + + private static final Object2ObjectOpenHashMap igniters = new Object2ObjectOpenHashMap<>(); + private static final Object2ObjectOpenHashMap blocks = new Object2ObjectOpenHashMap<>(); + + public static void clearCache() { + igniters.clear(); + blocks.clear(); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + public void onBlockBreak(BlockBreakEvent event){ + final Block block = event.getBlock(); + createAndAddEntry(BlockHistoryType.BREAK, event.getPlayer(), block); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + public void onBlockPlace(BlockPlaceEvent event){ + final Block block = event.getBlock(); + createAndAddEntry(BlockHistoryType.PLACE, event.getPlayer(), block); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + public void onBucketEmptied(PlayerBucketEmptyEvent event){ + Bukkit.getScheduler().scheduleSyncDelayedTask(BlockHistoryPlugin.instance(), () -> { + final Block block = event.getBlock(); + createAndAddEntry(BlockHistoryType.EMPTY_BUCKET, event.getPlayer(), block); + }); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + public void onBucketFilled(PlayerBucketFillEvent event){ + final Block block = event.getBlock(); + createAndAddEntry(BlockHistoryType.FILL_BUCKET, event.getPlayer(), block); + } + + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + public void onEntityExplode(EntityExplodeEvent event){ + Entity entity = event.getEntity(); + switch (entity) { + case TNTPrimed primed -> { + for (Block block : event.blockList()) { + createAndAddEntry(BlockHistoryType.EXPLODE_TNT, primed.getSource() instanceof Player player ? player : null, block); + } + } + case Creeper creeper -> { + final UUID igniter = igniters.remove(creeper.getUniqueId()); + for (Block block : event.blockList()) { + createAndAddEntry(BlockHistoryType.EXPLODE_CREEPER, igniter, block); + } + } + case EnderCrystal crystal -> { + final UUID igniter = igniters.remove(crystal.getUniqueId()); + for (Block block : event.blockList()) { + createAndAddEntry(BlockHistoryType.EXPLODE_END_CRYSTAL, igniter, block); + } + } + default -> {} + } + } + + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + public void onBlockExplode(BlockExplodeEvent event){ + Block explodedBlock = event.getBlock(); + UUID exploder = blocks.remove(explodedBlock); + BlockHistoryType type = BlockHistoryType.EXPLODE_BLOCK; + for (Block block : event.blockList()) { + createAndAddEntry(type, exploder, block); + } + } + + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + public void onEntityDamagedByEntity(EntityDamageByEntityEvent event){ + if (event.getDamager() instanceof Player player){ + if (event.getEntity() instanceof EnderCrystal enderCrystal){ + igniters.put(enderCrystal.getUniqueId(), player.getUniqueId()); + } + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + public void onPlayerInteractAtEntity(PlayerInteractAtEntityEvent event){ + if (event.getRightClicked() instanceof Creeper creeper){ + if (event.getPlayer().getEquipment().getItemInMainHand().getAmount() > 0 || event.getPlayer().getEquipment().getItemInOffHand().getAmount() > 0){ + igniters.put(creeper.getUniqueId(), event.getPlayer().getUniqueId()); + } + } + } + + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onPlayerInteract(PlayerInteractEvent event){ + if (event.getAction() == Action.RIGHT_CLICK_BLOCK){ + final Block clickedBlock = event.getClickedBlock(); + if (clickedBlock.getType() == Material.RESPAWN_ANCHOR) { + blocks.put(clickedBlock, event.getPlayer().getUniqueId()); + } else if (clickedBlock.getBlockData() instanceof Bed bed){ + final BlockFace facing = bed.getFacing(); + if (bed.getPart() == Bed.Part.FOOT){ + Block actualBlock = new Location(clickedBlock.getWorld(), clickedBlock.getX() + (facing.getModX()), clickedBlock.getY() + (facing.getModY()), clickedBlock.getZ() + (facing.getModZ())).getBlock(); + if (actualBlock.getBlockData() instanceof Bed){ + blocks.put(actualBlock, event.getPlayer().getUniqueId()); + } else { + BlockHistoryPlugin.logger().warn("Error: Bed does not exist"); + } + } else { + blocks.put(clickedBlock, event.getPlayer().getUniqueId()); + } + } + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onSignChange(SignChangeEvent event){ + final Player player = event.getPlayer(); + final Block block = event.getBlock(); + final Location blockLocation = block.getLocation(); + final StringBuilder signData = new StringBuilder(); + final MiniMessage serializer = MiniMessage.miniMessage(); + for (Component line : event.lines()) { + if (!signData.isEmpty()){ + signData.append(" "); + } + signData.append(serializer.serialize(line)); + } + BlockHistoryPlugin.instance().getHistoryManager().addHistoryElement(new BlockHistoryElement( + block.getWorld(), + blockLocation.getBlockX(), + blockLocation.getBlockY(), + blockLocation.getBlockZ(), + BlockHistoryType.SIGN, + System.currentTimeMillis(), + player.getUniqueId(), + block.getType(), + signData.toString().getBytes() + )); + } + + private void addEntryToManager(BlockHistoryElement element){ + BlockHistoryPlugin.instance().getHistoryManager().addHistoryElement(element); + } + + private void createAndAddEntry(BlockHistoryType type, Player player, Block block) { + createAndAddEntry(type, player == null ? null : player.getUniqueId(), block); + } + + private void createAndAddEntry(BlockHistoryType type, UUID player, Block block) { + addEntryToManager(new BlockHistoryElement( + block.getWorld(), + block.getX(), + block.getY(), + block.getZ(), + type, + System.currentTimeMillis(), + player, + block.getType(), + null + )); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + public void onContainerOpen(PlayerInteractEvent event){ + if (event.hasBlock() && event.getAction().isRightClick()){ + final Block block = event.getClickedBlock(); + if (block.getState() instanceof Container){ + createAndAddEntry(BlockHistoryType.CHEST_OPEN, event.getPlayer(), block); + } + } + } +} diff --git a/src/main/java/de/shiewk/blockhistory/v3/util/BlockHistoryFileNames.java b/src/main/java/de/shiewk/blockhistory/v3/util/BlockHistoryFileNames.java new file mode 100644 index 0000000..57ae930 --- /dev/null +++ b/src/main/java/de/shiewk/blockhistory/v3/util/BlockHistoryFileNames.java @@ -0,0 +1,81 @@ +package de.shiewk.blockhistory.v3.util; + +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; + +import java.nio.file.Path; + +public final class BlockHistoryFileNames { + private BlockHistoryFileNames(){} + + private static char base32encode(short i){ + return switch (i){ + case 0 -> '0'; + case 1 -> '1'; + case 2 -> '2'; + case 3 -> '3'; + case 4 -> '4'; + case 5 -> '5'; + case 6 -> '6'; + case 7 -> '7'; + case 8 -> '8'; + case 9 -> '9'; + case 10 -> 'a'; + case 11 -> 'b'; + case 12 -> 'c'; + case 13 -> 'd'; + case 14 -> 'e'; + case 15 -> 'f'; + case 16 -> 'g'; + case 17 -> 'h'; + case 18 -> 'i'; + case 19 -> 'j'; + case 20 -> 'k'; + case 21 -> 'l'; + case 22 -> 'm'; + case 23 -> 'n'; + case 24 -> 'o'; + case 25 -> 'p'; + case 26 -> 'q'; + case 27 -> 'r'; + case 28 -> 's'; + case 29 -> 't'; + case 30 -> 'u'; + case 31 -> 'v'; + default -> throw new IllegalStateException("Unexpected value: " + i); + }; + } + + public static Path encode(Path parentDirectory, Location location){ + // encoded string is 13 characters long + World world = location.getWorld(); + Chunk chunk = location.getChunk(); + int chunkX = chunk.getX(); + int chunkZ = chunk.getZ(); + // 20 bytes + long packed = 0; + + packed |= chunkX & 0b1111111111111111111L; + if (chunkX < 0) packed |= 0b10000000000000000000; + + packed |= (chunkZ & 0b1111111111111111111L) << 20; + if (chunkZ < 0) packed |= 0b1000000000000000000000000000000000000000L; + + String encodedChunkFileName = new String(new char[]{ + base32encode((short) (packed >> 35 & 0b11111)), + base32encode((short) (packed >> 30 & 0b11111)), + base32encode((short) (packed >> 25 & 0b11111)), + base32encode((short) (packed >> 20 & 0b11111)), + base32encode((short) (packed >> 15 & 0b11111)), + base32encode((short) (packed >> 10 & 0b11111)), + base32encode((short) (packed >> 5 & 0b11111)), + base32encode((short) (packed & 0b11111)) + }); + + return Path.of(parentDirectory.toString(), + world.getWorldFolder().getName(), + encodedChunkFileName + ); + } +} diff --git a/src/main/java/de/shiewk/blockhistory/v3/util/CommandUtil.java b/src/main/java/de/shiewk/blockhistory/v3/util/CommandUtil.java new file mode 100644 index 0000000..0628768 --- /dev/null +++ b/src/main/java/de/shiewk/blockhistory/v3/util/CommandUtil.java @@ -0,0 +1,35 @@ +package de.shiewk.blockhistory.v3.util; + +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; +import io.papermc.paper.command.brigadier.CommandSourceStack; +import io.papermc.paper.command.brigadier.MessageComponentSerializer; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.function.Predicate; + +public final class CommandUtil { + private CommandUtil(){} + + public static @NotNull Predicate requirePermission(String permission) { + return stack -> stack.getSender().hasPermission(permission); + } + + public static @NotNull Player getPlayerOf(CommandSourceStack stack) throws CommandSyntaxException { + return getPlayerOf(stack, "Only players can execute this (sub)command"); + } + + public static @NotNull Player getPlayerOf(CommandSourceStack stack, String errorMessage) throws CommandSyntaxException { + if (stack.getSender() instanceof Player player){ + return player; + } else throw new CommandSyntaxException( + new SimpleCommandExceptionType(null), + MessageComponentSerializer.message().serialize( + Component.text(errorMessage, NamedTextColor.RED) + ) + ); + } +} diff --git a/src/main/java/de/shiewk/blockhistory/v3/util/NamedLoggingThreadFactory.java b/src/main/java/de/shiewk/blockhistory/v3/util/NamedLoggingThreadFactory.java new file mode 100644 index 0000000..b5ca4f5 --- /dev/null +++ b/src/main/java/de/shiewk/blockhistory/v3/util/NamedLoggingThreadFactory.java @@ -0,0 +1,34 @@ +package de.shiewk.blockhistory.v3.util; + +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; + +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +public final class NamedLoggingThreadFactory implements ThreadFactory { + + private final String threadName; + private final int threadPriority; + private final Logger logger; + private final String label; + private final AtomicInteger threadNumber; + + public NamedLoggingThreadFactory(String threadName, int threadPriority, Logger logger, String label, AtomicInteger threadNumber) { + this.threadName = threadName; + this.threadPriority = threadPriority; + this.logger = logger; + this.label = label; + this.threadNumber = threadNumber; + } + + @Override + public Thread newThread(@NotNull Runnable r) { + Thread thread = new Thread(r); + thread.setName(threadName + "-" + threadNumber.incrementAndGet()); + thread.setPriority(threadPriority); + logger.info("Created new thread for {}: {}", label, thread); + return thread; + } + +} diff --git a/src/main/java/de/shiewk/blockhistory/v3/util/PlayerUtil.java b/src/main/java/de/shiewk/blockhistory/v3/util/PlayerUtil.java new file mode 100644 index 0000000..8e7a340 --- /dev/null +++ b/src/main/java/de/shiewk/blockhistory/v3/util/PlayerUtil.java @@ -0,0 +1,30 @@ +package de.shiewk.blockhistory.v3.util; + +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; + +import java.util.UUID; + +import static net.kyori.adventure.text.Component.text; + +public final class PlayerUtil { + private PlayerUtil(){} + + + public static String offlinePlayerName(UUID uuid){ + if (uuid == null) return "Unknown Player"; + OfflinePlayer player = Bukkit.getOfflinePlayer(uuid); + return player.getName() == null ? uuid.toString() : player.getName(); + } + + public static Component playerName(UUID uuid) { + Player player; + if ((player = Bukkit.getPlayer(uuid)) != null){ + return player.displayName(); + } else { + return text(offlinePlayerName(uuid)); + } + } +} diff --git a/src/main/java/de/shiewk/blockhistory/v3/util/UnitUtil.java b/src/main/java/de/shiewk/blockhistory/v3/util/UnitUtil.java new file mode 100644 index 0000000..4bb6e8f --- /dev/null +++ b/src/main/java/de/shiewk/blockhistory/v3/util/UnitUtil.java @@ -0,0 +1,49 @@ +package de.shiewk.blockhistory.v3.util; + +public final class UnitUtil { + private UnitUtil(){} + + public static String formatTime(long totalMillis){ + long millis = totalMillis % 1000; + long seconds = totalMillis / 1000 % 60; + long minutes = totalMillis / 60000 % 60; + long hours = totalMillis / 3600000 % 24; + long days = totalMillis / 86400000; + + StringBuilder builder = new StringBuilder(); + + if (days != 0){ + builder.append(days).append("d "); + } + if (hours != 0){ + builder.append(hours).append("h "); + } + if (minutes != 0){ + builder.append(minutes).append("m "); + } + if (seconds != 0){ + builder.append(seconds).append("s "); + } + if (builder.isEmpty() || millis != 0){ + builder.append(millis).append("ms"); + } + return builder.toString().trim(); + } + + public static String formatDataSize(double bytes) { + String[] suffixes = new String[]{"KiB", "MiB", "GiB"}; + String suffix = "bytes"; + int i = -1; + while (bytes > 1024 && ++i < suffixes.length){ + bytes /= 1024; + suffix = suffixes[i]; + } + return Math.floor(bytes * 10d) / 10d + " " + suffix; + } + + public static byte getBlockChunkLocation(int x){ + byte b = (byte) (x % 16); + if (b < 0) b = (byte) (16 + b); + return b; + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..f8fe80f --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,22 @@ +name: BlockHistory +version: '${version}' +main: de.shiewk.blockhistory.v3.BlockHistoryPlugin +api-version: '1.21' +prefix: BlockHistory +load: STARTUP +authors: [ Shiewk ] +description: BlockHistory is a Paper plugin that helps you to find griefers more easily. +permissions: + blockhistory.command.root: + default: op + description: Player can access root BlockHistory command node + blockhistory.command.stats: + default: op + description: Player can view the BlockHistory stats + children: + blockhistory.command.root: true + blockhistory.command.history: + default: op + description: Player can search the history of a block + children: + blockhistory.command.root: true \ No newline at end of file