1
mirror of https://github.com/Shiewk/BlockHistory3.git synced 2026-04-28 04:24:17 +02:00

Initial Commit (v3.0.0)

This commit is contained in:
Shy
2025-01-25 14:20:19 +01:00
commit 562fb014fa
24 changed files with 1476 additions and 0 deletions
+65
View File
@@ -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
+201
View File
@@ -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.
+34
View File
@@ -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 \<x> \<y> \<z>](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)
+49
View File
@@ -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
}
}
+6
View File
@@ -0,0 +1,6 @@
# BlockHistory commands
| Command | Description | Permission |
|-----------------------------------------------|-------------------------------------------------------------------------------------|------------------------------|
| /blockhistory stats | Shows stats about the plugin. | blockhistory.command.stats |
| /blockhistory history \<x> \<y> \<z> | Shows the history of the block at the specified coordinates. | blockhistory.command.history |
| /blockhistory history \<x> \<y> \<z> \<world> | Shows the history of the block at the specified coordinates in the specified world. | blockhistory.command.history |
+6
View File
@@ -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* |
View File
+7
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
rootProject.name = 'BlockHistory3'
@@ -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<Commands> 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;
}
}
@@ -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<Void> 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<Void> 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;
}
}
}
}
}
}
@@ -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;
}
}
@@ -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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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");
}
}
}
@@ -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;
}
}
@@ -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<UUID, Component> 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);
}
}
@@ -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);
}
@@ -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;
}
}
@@ -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<UUID, UUID> igniters = new Object2ObjectOpenHashMap<>();
private static final Object2ObjectOpenHashMap<Block, UUID> 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);
}
}
}
}
@@ -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
);
}
}
@@ -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<CommandSourceStack> 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)
)
);
}
}
@@ -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;
}
}
@@ -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));
}
}
}
@@ -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;
}
}
+22
View File
@@ -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