Compare commits

...

11 Commits

22 changed files with 2092 additions and 60 deletions

View File

@ -13,70 +13,70 @@ public class ChatConfig {
/** /**
* The color used for decoration. * The color used for decoration.
*/ */
public static TextColor decorationColor = NamedTextColor.YELLOW; public static TextColor decorationColor = PandaTheme.CHAT_DECORATION_COLOR;
/** /**
* The character used as a pattern for decoration. * The character used as a pattern for decoration.
*/ */
public static char decorationChar = '-'; public static char decorationChar = PandaTheme.CHAT_DECORATION_CHAR;
/** /**
* The default margin for left and right aligned text. * The default margin for left and right aligned text.
*/ */
public static int nbCharMargin = 1; public static int nbCharMargin = PandaTheme.CHAT_NB_CHAR_MARGIN;
/** /**
* The color used for successful messages. * The color used for successful messages.
*/ */
public static TextColor successColor = NamedTextColor.GREEN; public static TextColor successColor = PandaTheme.CHAT_SUCCESS_COLOR;
/** /**
* The color used for error/failure messages. * The color used for error/failure messages.
*/ */
public static TextColor failureColor = NamedTextColor.RED; public static TextColor failureColor = PandaTheme.CHAT_FAILURE_COLOR;
/** /**
* the color used for informational messages. * the color used for informational messages.
*/ */
public static TextColor infoColor = NamedTextColor.GOLD; public static TextColor infoColor = PandaTheme.CHAT_INFO_COLOR;
/** /**
* The color used for warning messages. * The color used for warning messages.
*/ */
public static TextColor warningColor = NamedTextColor.GOLD; public static TextColor warningColor = PandaTheme.CHAT_WARNING_COLOR;
/** /**
* The color used to display data in a message. * The color used to display data in a message.
*/ */
public static TextColor dataColor = NamedTextColor.GRAY; public static TextColor dataColor = PandaTheme.CHAT_DATA_COLOR;
/** /**
* The color used for displayed URLs and clickable URLs. * The color used for displayed URLs and clickable URLs.
*/ */
public static TextColor urlColor = NamedTextColor.GREEN; public static TextColor urlColor = PandaTheme.CHAT_URL_COLOR;
/** /**
* The color used for displayed commands and clickable commands. * The color used for displayed commands and clickable commands.
*/ */
public static TextColor commandColor = NamedTextColor.GRAY; public static TextColor commandColor = PandaTheme.CHAT_COMMAND_COLOR;
/** /**
* The color sued to display a command that is highlighted. For example, the current page in a pagination. * The color sued to display a command that is highlighted. For example, the current page in a pagination.
*/ */
public static TextColor highlightedCommandColor = NamedTextColor.WHITE; public static TextColor highlightedCommandColor = PandaTheme.CHAT_COMMAND_HIGHLIGHTED_COLOR;
/** /**
* The color used for broadcasted messages. * The color used for broadcasted messages.
* It is often used in combination with {@link #prefix}. * It is often used in combination with {@link #prefix}.
*/ */
public static TextColor broadcastColor = NamedTextColor.YELLOW; public static TextColor broadcastColor = PandaTheme.CHAT_BROADCAST_COLOR;
/** /**
* The prefix used for prefixed messages. * The prefix used for prefixed messages.
* It can be a sylized name of the server, like {@code "[Pandacube] "}. * It can be a sylized name of the server, like {@code "[Pandacube] "}.
* It is often used in combination with {@link #broadcastColor}. * It is often used in combination with {@link #broadcastColor}.
*/ */
public static Supplier<Chat> prefix; public static Supplier<Chat> prefix = PandaTheme::CHAT_MESSAGE_PREFIX;
/** /**
* Gets the width of the configured {@link #prefix}. * Gets the width of the configured {@link #prefix}.
@ -87,4 +87,70 @@ public class ChatConfig {
Chat c; Chat c;
return prefix == null ? 0 : (c = prefix.get()) == null ? 0 : ChatUtil.componentWidth(c.getAdv(), console); return prefix == null ? 0 : (c = prefix.get()) == null ? 0 : ChatUtil.componentWidth(c.getAdv(), console);
} }
public static class PandaTheme {
public static final TextColor CHAT_GREEN_1_NORMAL = TextColor.fromHexString("#3db849"); // h=126 s=50 l=48
public static final TextColor CHAT_GREEN_2 = TextColor.fromHexString("#5ec969"); // h=126 s=50 l=58
public static final TextColor CHAT_GREEN_3 = TextColor.fromHexString("#85d68d"); // h=126 s=50 l=68
public static final TextColor CHAT_GREEN_4 = TextColor.fromHexString("#abe3b0"); // h=126 s=50 l=78
public static final TextColor CHAT_GREEN_SATMAX = TextColor.fromHexString("#00ff19"); // h=126 s=100 l=50
public static final TextColor CHAT_GREEN_1_SAT = TextColor.fromHexString("#20d532"); // h=126 s=50 l=48
public static final TextColor CHAT_GREEN_2_SAT = TextColor.fromHexString("#45e354"); // h=126 s=50 l=58
public static final TextColor CHAT_GREEN_3_SAT = TextColor.fromHexString("#71ea7d"); // h=126 s=50 l=68
public static final TextColor CHAT_GREEN_4_SAT = TextColor.fromHexString("#9df0a6"); // h=126 s=50 l=78
public static final TextColor CHAT_BROWN_1 = TextColor.fromHexString("#b26d3a"); // h=26 s=51 l=46
public static final TextColor CHAT_BROWN_2 = TextColor.fromHexString("#cd9265"); // h=26 s=51 l=60
public static final TextColor CHAT_BROWN_3 = TextColor.fromHexString("#e0bb9f"); // h=26 s=51 l=75
public static final TextColor CHAT_BROWN_1_SAT = TextColor.fromHexString("#b35c19"); // h=26 s=75 l=40
public static final TextColor CHAT_BROWN_2_SAT = TextColor.fromHexString("#e28136"); // h=26 s=51 l=55
public static final TextColor CHAT_BROWN_3_SAT = TextColor.fromHexString("#ecab79"); // h=26 s=51 l=70
public static final TextColor CHAT_GRAY_MID = TextColor.fromHexString("#888888");
public static final TextColor CHAT_BROADCAST_COLOR = NamedTextColor.YELLOW;
public static final TextColor CHAT_DECORATION_COLOR = CHAT_GREEN_1_NORMAL;
public static final char CHAT_DECORATION_CHAR = '-';
public static final TextColor CHAT_URL_COLOR = CHAT_GREEN_1_NORMAL;
public static final TextColor CHAT_COMMAND_COLOR = CHAT_GRAY_MID;
public static final TextColor CHAT_COMMAND_HIGHLIGHTED_COLOR = NamedTextColor.WHITE;
public static final TextColor CHAT_INFO_COLOR = CHAT_GREEN_4;
public static final TextColor CHAT_WARNING_COLOR = CHAT_BROWN_2_SAT;
public static final TextColor CHAT_SUCCESS_COLOR = CHAT_GREEN_SATMAX;
public static final TextColor CHAT_FAILURE_COLOR = TextColor.fromHexString("#ff3333");
public static final TextColor CHAT_DATA_COLOR = CHAT_GRAY_MID;
public static final TextColor CHAT_PM_PREFIX_DECORATION = CHAT_BROWN_2_SAT;
public static final TextColor CHAT_PM_SELF_MESSAGE = CHAT_GREEN_2;
public static final TextColor CHAT_PM_OTHER_MESSAGE = CHAT_GREEN_4;
public static final TextColor CHAT_DISCORD_LINK_COLOR = TextColor.fromHexString("#00aff4");
/**
* Number of decoration character to put between the text and the border of
* the line for left and right aligned text.
*/
public static final int CHAT_NB_CHAR_MARGIN = 1;
public static Chat CHAT_MESSAGE_PREFIX() {
return Chat.text("[")
.broadcastColor()
.thenDecoration("Serveur")
.thenText("] ");
}
}
} }

View File

@ -1,46 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>pandalib-parent</artifactId>
<groupId>fr.pandacube.lib</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>pandalib-paper-players</artifactId>
<packaging>jar</packaging>
<repositories>
<repository>
<id>papermc</id>
<url>https://papermc.io/repo/repository/maven-public/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>fr.pandacube.lib</groupId>
<artifactId>pandalib-players</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Paper -->
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>${paper.version}-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-mojangapi</artifactId>
<version>${paper.version}-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,40 @@
package fr.pandacube.lib.paper.reflect.util;
import fr.pandacube.lib.paper.reflect.wrapper.craftbukkit.CraftServer;
import fr.pandacube.lib.reflect.wrapper.ReflectWrapper;
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Utility method to get the list of the primary world of the Bukkit/Paper server.
* The primary worlds are the world that are loaded by the server at the startup, that the plugins cannot unload.
*/
public class PrimaryWorlds {
/**
* An unmodifiable list containing the names of the primary worlds of this server instance, in the order they are
* loaded. This list can be accessed even if the corresponding worlds are not yet loaded, for instance in the
* {@link Plugin#onLoad()} method.
*/
public static final List<String> PRIMARY_WORLDS;
static {
List<String> primaryWorlds = new ArrayList<>(3);
String world = ReflectWrapper.wrapTyped(Bukkit.getServer(), CraftServer.class).getServer().getLevelIdName();
primaryWorlds.add(world);
if (Bukkit.getAllowNether()) primaryWorlds.add(world + "_nether");
if (Bukkit.getAllowEnd()) primaryWorlds.add(world + "_the_end");
PRIMARY_WORLDS = Collections.unmodifiableList(primaryWorlds);
}
}

View File

@ -53,6 +53,12 @@
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>fr.pandacube.lib</groupId>
<artifactId>pandalib-players</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Paper --> <!-- Paper -->
<dependency> <dependency>
<groupId>io.papermc.paper</groupId> <groupId>io.papermc.paper</groupId>
@ -64,6 +70,13 @@
<artifactId>paper-mojangapi</artifactId> <artifactId>paper-mojangapi</artifactId>
<version>${paper.version}-SNAPSHOT</version> <version>${paper.version}-SNAPSHOT</version>
</dependency> </dependency>
<!-- Cron expression interpreter -->
<dependency>
<groupId>ch.eitchnet</groupId>
<artifactId>cron</artifactId>
<version>1.6.2</version>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@ -1,5 +1,6 @@
package fr.pandacube.lib.paper; package fr.pandacube.lib.paper;
import fr.pandacube.lib.paper.modules.PerformanceAnalysisManager;
import org.bukkit.plugin.Plugin; import org.bukkit.plugin.Plugin;
public class PandaLibPaper { public class PandaLibPaper {
@ -8,8 +9,15 @@ public class PandaLibPaper {
public static void init(Plugin plugin) { public static void init(Plugin plugin) {
PandaLibPaper.plugin = plugin; PandaLibPaper.plugin = plugin;
PerformanceAnalysisManager.getInstance(); // initialize
} }
public static void disable() {
PerformanceAnalysisManager.getInstance().cancelInternalBossBar();
}
public static Plugin getPlugin() { public static Plugin getPlugin() {
return plugin; return plugin;

View File

@ -0,0 +1,475 @@
package fr.pandacube.lib.paper.modules;
import com.destroystokyo.paper.event.server.ServerTickEndEvent;
import com.destroystokyo.paper.event.server.ServerTickStartEvent;
import fr.pandacube.lib.chat.Chat;
import fr.pandacube.lib.chat.ChatColorGradient;
import fr.pandacube.lib.chat.ChatColorUtil;
import fr.pandacube.lib.chat.ChatConfig.PandaTheme;
import fr.pandacube.lib.paper.PandaLibPaper;
import fr.pandacube.lib.paper.players.PaperOffPlayer;
import fr.pandacube.lib.paper.players.PaperOnlinePlayer;
import fr.pandacube.lib.paper.scheduler.SchedulerUtil;
import fr.pandacube.lib.paper.util.AutoUpdatedBossBar;
import fr.pandacube.lib.paper.util.AutoUpdatedBossBar.BarUpdater;
import fr.pandacube.lib.players.standalone.AbstractPlayerManager;
import fr.pandacube.lib.util.Log;
import fr.pandacube.lib.util.MemoryUtil;
import fr.pandacube.lib.util.MemoryUtil.MemoryUnit;
import fr.pandacube.lib.util.TimeUtil;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.bossbar.BossBar.Color;
import net.kyori.adventure.bossbar.BossBar.Overlay;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import net.md_5.bungee.api.ChatColor;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.command.ConsoleCommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.Plugin;
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import static fr.pandacube.lib.chat.ChatStatic.chat;
import static fr.pandacube.lib.chat.ChatStatic.failureText;
import static fr.pandacube.lib.chat.ChatStatic.infoText;
import static fr.pandacube.lib.chat.ChatStatic.successText;
import static fr.pandacube.lib.chat.ChatStatic.text;
public class PerformanceAnalysisManager implements Listener {
private static PerformanceAnalysisManager instance;
public static synchronized PerformanceAnalysisManager getInstance() {
if (instance == null)
instance = new PerformanceAnalysisManager();
return instance;
}
private static final int NB_TICK_HISTORY = 20 * 60 * 60; // 60 secondes;
private final Plugin plugin = PandaLibPaper.getPlugin();
private long firstRecord = 0;
private final ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
private long tickStartNanoTime = System.nanoTime();
private long tickStartCPUTime = 0;
private long tickEndNanoTime = System.nanoTime();
private long lastInterTPSDuration = 0;
private final LinkedList<Long> tpsTimes = new LinkedList<>();
private final LinkedList<Long> tpsDurations = new LinkedList<>();
private final LinkedList<Long> tpsCPUTimes = new LinkedList<>();
private final LinkedList<Long> interTPSDurations = new LinkedList<>();
private final AutoUpdatedBossBar tpsBar;
private final AutoUpdatedBossBar memoryBar;
private final List<Player> barPlayers = new ArrayList<>();
private final List<BossBar> relatedBossBars = new ArrayList<>();
public final ChatColorGradient tps1sGradient = new ChatColorGradient()
.add(0, NamedTextColor.BLACK)
.add(1, NamedTextColor.DARK_RED)
.add(5, NamedTextColor.RED)
.add(10, NamedTextColor.GOLD)
.add(14, NamedTextColor.YELLOW)
.add(20, PandaTheme.CHAT_DECORATION_COLOR)
.add(26, NamedTextColor.BLUE);
public final ChatColorGradient tps10sGradient = new ChatColorGradient()
.add(0, NamedTextColor.DARK_RED)
.add(5, NamedTextColor.RED)
.add(10, NamedTextColor.GOLD)
.add(14, NamedTextColor.YELLOW)
.add(18, PandaTheme.CHAT_DECORATION_COLOR);
public final ChatColorGradient tps1mGradient = new ChatColorGradient()
.add(0, NamedTextColor.DARK_RED)
.add(8, NamedTextColor.RED)
.add(12, NamedTextColor.GOLD)
.add(16, NamedTextColor.YELLOW)
.add(20, PandaTheme.CHAT_DECORATION_COLOR);
public final ChatColorGradient memoryUsageGradient = new ChatColorGradient()
.add(.60f, PandaTheme.CHAT_DECORATION_COLOR)
.add(.70f, NamedTextColor.YELLOW)
.add(.80f, NamedTextColor.GOLD)
.add(.90f, NamedTextColor.RED)
.add(.95f , NamedTextColor.DARK_RED);
private PerformanceAnalysisManager() {
Bukkit.getPluginManager().registerEvents(this, plugin);
BossBar bossBar = BossBar.bossBar(text("TPS Serveur"), 0, Color.GREEN, Overlay.NOTCHED_20);
tpsBar = new AutoUpdatedBossBar(bossBar, new TPSBossBarUpdater());
tpsBar.scheduleUpdateTimeSyncThreadAsync(1000, 100);
BossBar bossMemBar = BossBar.bossBar(text("Mémoire Serveur"), 0, Color.GREEN, Overlay.NOTCHED_10);
memoryBar = new AutoUpdatedBossBar(bossMemBar, new MemoryBossBarUpdater());
memoryBar.scheduleUpdateTimeSyncThreadAsync(1000, 100);
}
public boolean barsContainsPlayer(Player p) {
return barPlayers.contains(p);
}
public synchronized void addPlayerToBars(Player p) {
barPlayers.add(p);
p.showBossBar(tpsBar.bar);
p.showBossBar(memoryBar.bar);
for (BossBar bar : relatedBossBars)
p.showBossBar(bar);
}
public synchronized void removePlayerToBars(Player p) {
p.hideBossBar(tpsBar.bar);
p.hideBossBar(memoryBar.bar);
for (BossBar bar : relatedBossBars)
p.hideBossBar(bar);
barPlayers.remove(p);
}
public synchronized void addBossBar(BossBar bar) {
if (relatedBossBars.contains(bar))
return;
relatedBossBars.add(bar);
for (Player p : barPlayers)
p.showBossBar(bar);
}
public synchronized void removeBossBar(BossBar bar) {
if (!relatedBossBars.contains(bar))
return;
relatedBossBars.remove(bar);
for (Player p : barPlayers)
p.hideBossBar(bar);
}
public synchronized void cancelInternalBossBar() {
tpsBar.cancel();
memoryBar.cancel();
}
@EventHandler
public synchronized void onTickStart(ServerTickStartEvent event) {
tickStartNanoTime = System.nanoTime();
tickStartCPUTime = threadMXBean.isThreadCpuTimeSupported() ? threadMXBean.getCurrentThreadCpuTime() : 0;
lastInterTPSDuration = firstRecord == 0 ? 0 : (tickStartNanoTime - tickEndNanoTime);
}
@EventHandler
public synchronized void onTickEnd(ServerTickEndEvent event) {
tickEndNanoTime = System.nanoTime();
long tickEndCPUTime = threadMXBean.isThreadCpuTimeSupported() ? threadMXBean.getCurrentThreadCpuTime() : 0;
if (firstRecord == 0) firstRecord = System.currentTimeMillis();
tpsTimes.add(System.currentTimeMillis());
tpsDurations.add(tickEndNanoTime - tickStartNanoTime);
tpsCPUTimes.add(tickEndCPUTime - tickStartCPUTime);
interTPSDurations.add(lastInterTPSDuration);
while (tpsTimes.size() > NB_TICK_HISTORY + 1)
tpsTimes.poll();
while (tpsDurations.size() > NB_TICK_HISTORY + 1)
tpsDurations.poll();
while (tpsCPUTimes.size() > NB_TICK_HISTORY + 1)
tpsCPUTimes.poll();
while (interTPSDurations.size() > NB_TICK_HISTORY + 1)
interTPSDurations.poll();
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerJoin(PlayerJoinEvent event) {
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
@SuppressWarnings("unchecked")
AbstractPlayerManager<PaperOnlinePlayer, PaperOffPlayer> playerManager = (AbstractPlayerManager<PaperOnlinePlayer, PaperOffPlayer>) AbstractPlayerManager.getInstance();
PaperOffPlayer offP = playerManager.getOffline(event.getPlayer().getUniqueId());
try {
if ("true".equals(offP.getConfig("system.bar", "false"))) {
SchedulerUtil.runOnServerThread(() -> addPlayerToBars(event.getPlayer()));
}
} catch (Exception e) {
Log.severe("Cannot get player config", e);
}
});
}
@EventHandler(priority = EventPriority.LOWEST)
public void onPlayerQuit(PlayerQuitEvent event) {
removePlayerToBars(event.getPlayer());
}
private final long maxMem = Runtime.getRuntime().maxMemory();
private class MemoryBossBarUpdater implements BarUpdater {
@Override
public void update(AutoUpdatedBossBar bar) {
long allocMem = Runtime.getRuntime().totalMemory();
long freeMem = Runtime.getRuntime().freeMemory();
long usedMem = allocMem - freeMem;
double progress = usedMem / (double)maxMem;
progress = (progress < 0) ? 0 : (progress > 1) ? 1 : progress;
Color barColor = (progress >= 0.85) ? Color.RED
: (progress >= 0.65) ? Color.YELLOW
: Color.GREEN;
TextColor usedColor = memoryUsageGradient.pickColorAt((float)progress);
Chat display = infoText("Mémoire : ")
.then(text("Util:" + MemoryUtil.humanReadableSize(usedMem, MemoryUnit.MB, false)
+ "/" + MemoryUtil.humanReadableSize(maxMem, MemoryUnit.MB, false)
)
.color(usedColor)
)
.thenText(" Allouée:" + MemoryUtil.humanReadableSize(allocMem, MemoryUnit.MB, false));
bar.setColor(barColor);
bar.setProgress(progress);
bar.setTitle(display);
}
}
private class TPSBossBarUpdater implements BarUpdater {
@Override
public void update(AutoUpdatedBossBar bar) {
synchronized (PerformanceAnalysisManager.this) {
float tps1s = getTPS(1000);
Color barColor = (tps1s >= 25) ? Color.WHITE
: (tps1s >= 12) ? Color.GREEN
: (tps1s >= 6) ? Color.YELLOW
: Color.RED;
double barProgress = Double.isNaN(tps1s) ? 0 : tps1s/20d;
Chat title;
if (alteredTPSTitle != null) {
title = infoText("TPS : ").then(alteredTPSTitle);
}
else {
String tps1sDisp = Double.isNaN(tps1s) ? "N/A" : (Math.round(tps1s)) + "";
int[] tpsHistory = getTPSHistory();
// keep the legacy text when generating the bar to save space when converting to component
StringBuilder s = new StringBuilder();
ChatColor prevC = ChatColor.RESET;
for (int i = 58; i >= 0; i--) {
int t = tpsHistory[i];
ChatColor newC = ChatColorUtil.toBungee(tps1sGradient.pickColorAt(t));
if (newC != prevC) {
s.append(newC);
prevC = newC;
}
s.append("|");
}
// tick time measurement
Chat timings;
int nbTick1s = getTPS1s();
if (nbTick1s == 0) {
// we have a lag spike, so we need to display the time since lagging
long lagDurationSec = System.nanoTime() - tickEndNanoTime;
timings = text("(")
.thenFailure("lag:" + dispRound10(lagDurationSec / (double) 1_000_000_000) + "s")
.thenText(")");
}
else {
float avgTickDuration1s = getAvgNano(tpsDurations, nbTick1s)/1_000_000;
float avgTickCPUTime1s = getAvgNano(tpsCPUTimes, nbTick1s)/1_000_000;
TextColor avgTickCPUTime1sColor = (avgTickDuration1s < 46 || avgTickCPUTime1s < 20) ? PandaTheme.CHAT_DECORATION_COLOR
: (avgTickCPUTime1s < 30) ? NamedTextColor.YELLOW
: (avgTickCPUTime1s < 40) ? NamedTextColor.GOLD
: (avgTickCPUTime1s < 50) ? NamedTextColor.RED
: NamedTextColor.DARK_RED;
float avgTickWaitingTime1s = avgTickDuration1s - avgTickCPUTime1s;
TextColor avgTickWaitingTime1sColor = (avgTickDuration1s < 46 || avgTickWaitingTime1s < 20) ? PandaTheme.CHAT_DECORATION_COLOR
: (avgTickWaitingTime1s < 30) ? NamedTextColor.YELLOW
: (avgTickWaitingTime1s < 40) ? NamedTextColor.GOLD
: (avgTickWaitingTime1s < 50) ? NamedTextColor.RED
: NamedTextColor.DARK_RED;
float avgInterTickDuration1s = getAvgNano(interTPSDurations, nbTick1s)/1_000_000;
TextColor avgInterTickDuration1sColor = (avgInterTickDuration1s > 10) ? PandaTheme.CHAT_DECORATION_COLOR
: (avgInterTickDuration1s > 4) ? NamedTextColor.YELLOW
: (avgTickDuration1s < 46) ? NamedTextColor.GOLD
: NamedTextColor.RED;
timings = text("(Tr:")
.then(text(Math.round(avgTickCPUTime1s) + "ms").color(avgTickCPUTime1sColor))
.thenText(" Tw:")
.then(text(Math.round(avgTickWaitingTime1s) + "ms").color(avgTickWaitingTime1sColor))
.thenText(" S:")
.then(text(Math.round(avgInterTickDuration1s) + "ms").color(avgInterTickDuration1sColor))
.thenText(")");
}
title = infoText("TPS [")
.thenLegacyText(s.toString())
.thenText("] ")
.then(text(tps1sDisp+"/20 ").color(tps1sGradient.pickColorAt(tps1s)))
.then(timings);
}
bar.setTitle(title);
bar.setColor(barColor);
bar.setProgress(Math.max(0, Math.min(1, barProgress)));
}
}
}
private Chat alteredTPSTitle = null;
public synchronized void setAlteredTPSTitle(Chat title) {
alteredTPSTitle = title;
}
// special case where the getTPS method always returns a whole number when retrieving the TPS for 1 sec
public int getTPS1s() {
return (int) getTPS(1_000);
}
/**
*
* @param nbTicks number of ticks when the avg value is computed from history
* @return the avg number of TPS in the interval
*/
public synchronized float getAvgNano(List<Long> data, int nbTicks) {
if (data.size() <= 0) return 0;
if (nbTicks > data.size()) nbTicks = data.size();
long sum = 0;
for (int i = data.size() - nbTicks; i < data.size(); i++)
sum += data.get(i);
return sum / (float) nbTicks;
}
/**
*
* @param nbMillis number of milliseconds when the avg TPS is computed from history
* @return the avg number of TPS in the interval
*/
public synchronized float getTPS(long nbMillis) {
if (tpsTimes.size() == 0) return 0;
long currentMillis = System.currentTimeMillis();
if (currentMillis - nbMillis < firstRecord) nbMillis = currentMillis - firstRecord;
int count = 0;
for (Long v : tpsTimes) {
if (v > currentMillis - nbMillis) count++;
}
return count * (1000 / (float) nbMillis);
}
public synchronized int[] getTPSHistory() {
int[] history = new int[60];
long currentSec = System.currentTimeMillis() / 1000;
for (Long v : tpsTimes) {
int sec = (int) (currentSec - v/1000) - 1;
if (sec < 0 || sec >= 60)
continue;
history[sec]++;
}
return history;
}
public static void gc(CommandSender sender) {
long t1 = System.currentTimeMillis();
long alloc1 = Runtime.getRuntime().totalMemory();
System.gc();
long t2 = System.currentTimeMillis();
long alloc2 = Runtime.getRuntime().totalMemory();
long released = alloc1 - alloc2;
Chat releasedMemoryMessage = released > 0
? successText(MemoryUtil.humanReadableSize(released) + " of memory released for the OS.")
: released < 0
? failureText(MemoryUtil.humanReadableSize(-released) + " of memory taken from the OS.")
: chat();
Chat finalMessage = successText("GC completed in " + TimeUtil.durationToString(t2 - t1, true) + ". ")
.then(releasedMemoryMessage);
if (sender != null)
sender.sendMessage(finalMessage);
if (!(sender instanceof ConsoleCommandSender))
Log.info(finalMessage.getLegacyText());
}
public static String dispRound10(double val) {
long v = (long) Math.ceil(val * 10);
return "" + (v / 10f);
}
}

View File

@ -0,0 +1,101 @@
package fr.pandacube.lib.paper.modules.backup;
import java.io.File;
import java.time.LocalDateTime;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
import fr.pandacube.lib.util.Log;
public abstract class BackupCleaner implements UnaryOperator<TreeSet<LocalDateTime>> {
public static BackupCleaner KEEPING_N_LAST(int n) {
return new BackupCleaner() {
@Override
public TreeSet<LocalDateTime> apply(TreeSet<LocalDateTime> archives) {
return archives.descendingSet().stream()
.limit(n)
.collect(Collectors.toCollection(TreeSet::new));
}
};
}
public static BackupCleaner KEEPING_1_EVERY_N_MONTH(int n) {
return new BackupCleaner() {
@Override
public TreeSet<LocalDateTime> apply(TreeSet<LocalDateTime> localDateTimes) {
return localDateTimes.stream()
.collect(Collectors.groupingBy(
ldt -> {
return ldt.getYear() * 4 + ldt.getMonthValue() / 2;
},
TreeMap::new,
Collectors.minBy(LocalDateTime::compareTo))
)
.values()
.stream()
.filter(Optional::isPresent)
.map(Optional::get)
.collect(Collectors.toCollection(TreeSet::new));
}
};
}
public BackupCleaner merge(BackupCleaner other) {
BackupCleaner self = this;
return new BackupCleaner() {
@Override
public TreeSet<LocalDateTime> apply(TreeSet<LocalDateTime> archives) {
TreeSet<LocalDateTime> merged = new TreeSet<>();
merged.addAll(self.apply(archives));
merged.addAll(other.apply(archives));
return merged;
}
};
}
public void cleanupArchives(File archiveDir) {
String[] files = archiveDir.list();
Log.info("[Backup] Cleaning up backup directory " + archiveDir + "...");
TreeMap<LocalDateTime, File> datedFiles = new TreeMap<>();
for (String filename : files) {
File file = new File(archiveDir, filename);
if (!filename.matches("\\d{8}-\\d{6}\\.zip")) {
Log.warning("[Backup] Invalid file in backup directory: " + file);
continue;
}
String dateTimeStr = filename.substring(0, filename.length() - 4);
LocalDateTime ldt = LocalDateTime.parse(dateTimeStr, CompressProcess.dateFileNameFormatter);
datedFiles.put(ldt, file);
}
TreeSet<LocalDateTime> keptFiles = apply(new TreeSet<>(datedFiles.keySet()));
for (Entry<LocalDateTime, File> datedFile : datedFiles.entrySet()) {
if (keptFiles.contains(datedFile.getKey()))
continue;
// datedFile.getValue().delete(); // TODO check if the filtering is ok before actually removing files
Log.info("[Backup] Removed expired backup file " + datedFile.getValue());
}
Log.info("[Backup] Backup directory " + archiveDir + " cleaned.");
}
}

View File

@ -0,0 +1,15 @@
package fr.pandacube.lib.paper.modules.backup;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
public class BackupConfig {
public boolean worldBackupEnabled = true;
public boolean workdirBackupEnabled = true;
public String scheduling = "0 2 * * 1"; // cron format, here is everyday at 2am
public File backupDirectory = null;
public BackupCleaner worldBackupCleaner = BackupCleaner.KEEPING_1_EVERY_N_MONTH(3).merge(BackupCleaner.KEEPING_N_LAST(5));
public BackupCleaner workdirBackupCleaner = BackupCleaner.KEEPING_1_EVERY_N_MONTH(3).merge(BackupCleaner.KEEPING_N_LAST(5));
public List<String> workdirIgnoreList = new ArrayList<>();
}

View File

@ -0,0 +1,196 @@
package fr.pandacube.lib.paper.modules.backup;
import fc.cron.CronExpression;
import fr.pandacube.lib.paper.PandaLibPaper;
import fr.pandacube.lib.util.Log;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.world.WorldLoadEvent;
import org.bukkit.event.world.WorldSaveEvent;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
public class BackupManager implements Runnable, Listener {
Persist persist;
private final List<CompressProcess> compressQueue = new ArrayList<>();
private final Set<String> compressWorlds = new HashSet<>();
/* package */ AtomicReference<CompressProcess> compressRunning = new AtomicReference<>();
private final Set<String> dirtyForSave = new HashSet<>();
BackupConfig config;
public BackupManager(BackupConfig config) {
setConfig(config);
persist = new Persist(this);
for (final World world : Bukkit.getWorlds()) {
initCompressProcess(Type.WORLDS, world.getName());
}
initCompressProcess(Type.WORKDIR, null);
Bukkit.getServer().getScheduler().runTaskTimer(PandaLibPaper.getPlugin(), this, (60 - Calendar.getInstance().get(Calendar.SECOND)) * 20L, 60 * 20L);
Bukkit.getServer().getPluginManager().registerEvents(this, PandaLibPaper.getPlugin());
}
public void setConfig(BackupConfig config) {
this.config = config;
}
public void onDisable() {
if (compressRunning.get() != null) {
Log.warning("[Backup] Waiting after the end of a backup...");
CompressProcess tmp;
while ((tmp = compressRunning.get()) != null) {
try {
tmp.logProgress();
// wait 5 seconds between each progress log
// but check if the process has ended each .5 seconds
for (int i = 0; i < 10; i++) {
if (compressRunning.get() == null)
break;
Thread.sleep(500);
}
} catch (Throwable e) { // could occur because of synchronization errors/interruption/...
break;
}
}
}
// save dirty status of worlds
for (String wName : dirtyForSave) {
World w = Bukkit.getWorld(wName);
if (w != null)
persist.updateDirtyStatusAfterSave(w);
}
persist.save();
}
private void initCompressProcess(final Type type, final String worldName) {
if (!type.backupEnabled(config))
return;
if (type == Type.WORLDS) {
if (compressWorlds.contains(worldName))
return;
compressWorlds.add(worldName);
}
CompressProcess process = type == Type.WORLDS ? new CompressWorldProcess(this, worldName) : new CompressWorkdirProcess(this);
process.displayDirtynessStatus();
compressQueue.add(process);
}
@Override
public void run() {
CompressProcess tmp;
if ((tmp = compressRunning.get()) != null) {
tmp.logProgress();
}
else {
compressQueue.sort(null);
for (CompressProcess process : compressQueue) {
if (System.currentTimeMillis() >= process.getNext() && process.couldRunNow()) {
process.run();
return;
}
}
}
}
/**
* get the timestamp (in ms) of when the next compress will run, depending on since when the files to compress are dirty.
* @param dirtySince the timestamp in ms since the files are dirty
* @return the timestamp in ms when the next compress of the files should be run, or 0 if it is not yet scheduled
*/
/* package */ long getNextCompress(long dirtySince) {
if (dirtySince == -1)
return 0;
CronExpression parsedScheduling;
try {
parsedScheduling = new CronExpression(config.scheduling, false);
} catch (IllegalArgumentException e) {
Log.severe("Invalid backup scheduling configuration '" + config.scheduling + "'.", e);
return 0;
}
ZonedDateTime ldt = parsedScheduling.nextTimeAfter(ZonedDateTime.from(Instant.ofEpochMilli(dirtySince)));
// Log.info("Compress config: " + compressConfig + " - interval: " + interval);
return ldt.toInstant().toEpochMilli();
}
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldLoad(WorldLoadEvent event) {
initCompressProcess(Type.WORLDS, event.getWorld().getName());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldSave(WorldSaveEvent event) {
if (event.getWorld().getLoadedChunks().length > 0
|| dirtyForSave.contains(event.getWorld().getName())) {
persist.updateDirtyStatusAfterSave(event.getWorld());
dirtyForSave.remove(event.getWorld().getName());
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerChangeWorldEvent(PlayerChangedWorldEvent event) {
dirtyForSave.add(event.getFrom().getName());
dirtyForSave.add(event.getPlayer().getWorld().getName());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerJoin(PlayerJoinEvent event) {
dirtyForSave.add(event.getPlayer().getWorld().getName());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerQuit(PlayerQuitEvent event) {
dirtyForSave.add(event.getPlayer().getWorld().getName());
}
}

View File

@ -0,0 +1,200 @@
package fr.pandacube.lib.paper.modules.backup;
import fr.pandacube.lib.chat.Chat;
import fr.pandacube.lib.paper.PandaLibPaper;
import fr.pandacube.lib.paper.modules.PerformanceAnalysisManager;
import fr.pandacube.lib.paper.util.AutoUpdatedBossBar;
import fr.pandacube.lib.util.FileUtils;
import fr.pandacube.lib.util.Log;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.bossbar.BossBar.Color;
import net.kyori.adventure.bossbar.BossBar.Overlay;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import java.io.File;
import java.text.DateFormat;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.temporal.ChronoField;
import java.util.Calendar;
import java.util.Date;
import java.util.function.BiPredicate;
public abstract class CompressProcess implements Comparable<CompressProcess>, Runnable {
protected final BackupManager backupManager;
public final Type type;
public final String name;
private ZipCompressor compressor = null;
protected CompressProcess(BackupManager bm, final Type t, final String n) {
backupManager = bm;
type = t;
name = n;
}
@Override
public int compareTo(final CompressProcess process) {
return Long.compare(getNext(), process.getNext());
}
public abstract BiPredicate<File, String> getFilenameFilter();
public abstract File getSourceDir();
protected abstract void onCompressStart();
protected abstract void onCompressEnd(boolean success);
protected abstract File getTargetDir();
@Override
public void run() {
backupManager.compressRunning.set(this);
BiPredicate<File, String> filter = getFilenameFilter();
File sourceDir = getSourceDir();
if (!sourceDir.exists()) {
Log.warning(String.format("%% unable to compress %s (check path: %s)", name, sourceDir.getPath()));
backupManager.compressRunning.set(null);
return;
}
File targetDir = getTargetDir();
File target = new File(targetDir, getDateFileName() + ".zip");
BossBar bossBar = BossBar.bossBar(Chat.text("Archivage"), 0, Color.YELLOW, Overlay.NOTCHED_20);
AutoUpdatedBossBar auBossBar = new AutoUpdatedBossBar(bossBar, (bar) -> {
bar.setTitle(Chat.infoText("Archivage ")
.thenData(type + "\\" + name)
.thenText(" : ")
.then(compressor == null
? Chat.text("Démarrage...")
: compressor.getState()
)
);
bar.setProgress(compressor == null ? 0 : compressor.getProgress());
});
auBossBar.scheduleUpdateTimeSyncThreadAsync(100, 100);
onCompressStart();
Bukkit.getScheduler().runTaskAsynchronously(PandaLibPaper.getPlugin(), () -> {
Log.info("[Backup] starting for " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET + " ...");
compressor = new ZipCompressor(sourceDir, target, 9, filter);
PerformanceAnalysisManager.getInstance().addBossBar(bossBar);
boolean success = false;
try {
compressor.compress();
success = true;
Log.info("[Backup] finished for " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET);
backupManager.persist.updateDirtyStatusAfterCompress(type, name);
displayDirtynessStatus();
try {
type.backupCleaner(backupManager.config).cleanupArchives(targetDir);
} catch (Exception e) {
Log.severe(e);
}
}
catch (final Exception e) {
Log.severe("[Backup] Failed: " + sourceDir + " -> " + target, e);
FileUtils.delete(target);
if (target.exists())
Log.warning("unable to delete: " + target);
} finally {
backupManager.compressRunning.set(null);
boolean successF = success;
Bukkit.getScheduler().runTask(PandaLibPaper.getPlugin(), () -> onCompressEnd(successF));
try {
Thread.sleep(2000);
} catch(InterruptedException e) {
Thread.currentThread().interrupt();
}
PerformanceAnalysisManager.getInstance().removeBossBar(bossBar);
}
});
}
public void displayDirtynessStatus() {
if (hasNextScheduled() && type == Type.WORLDS) {
Log.info("[Backup] " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET + " is dirty. Next backup on "
+ DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG).format(new Date(getNext())));
}
else if (hasNextScheduled()) {
Log.info("[Backup] " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET + " next backup on "
+ DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG).format(new Date(getNext())));
}
else {
Log.info("[Backup] " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET + " is clean. Next backup not scheduled.");
}
}
static DateTimeFormatter dateFileNameFormatter = new DateTimeFormatterBuilder()
.append(DateTimeFormatter.BASIC_ISO_DATE)
.appendLiteral('-')
.appendValue(ChronoField.HOUR_OF_DAY, 2) // there is no DateTimeFormatter.BASIC_ISO_TIME
.appendValue(ChronoField.MINUTE_OF_HOUR, 2)
.appendValue(ChronoField.SECOND_OF_MINUTE, 2)
.toFormatter();
private String getDateFileName() {
Calendar calendar = Calendar.getInstance();
return dateFileNameFormatter.format(calendar.toInstant());
}
public void logProgress() {
if (compressor == null)
return;
Log.info("[Backup] " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET + ": " + compressor.getState().getLegacyText());
}
public boolean couldRunNow() {
if (!type.backupEnabled(backupManager.config))
return false;
if (!backupManager.persist.isDirty(type, name))
return false;
if (getNext() > System.currentTimeMillis())
return false;
return true;
}
public long getNext() {
if (!hasNextScheduled())
return Long.MAX_VALUE;
return backupManager.getNextCompress(backupManager.persist.isDirtySince(type, name));
}
public boolean hasNextScheduled() {
return backupManager.persist.isDirty(type, name);
}
}

View File

@ -0,0 +1,67 @@
package fr.pandacube.lib.paper.modules.backup;
import java.io.File;
import java.util.function.BiPredicate;
public class CompressWorkdirProcess extends CompressProcess {
protected CompressWorkdirProcess(BackupManager bm) {
super(bm, Type.WORKDIR, Type.WORKDIR.toString());
}
public BiPredicate<File, String> getFilenameFilter() {
return new SourceFileFilter();
}
private class SourceFileFilter implements BiPredicate<File, String> {
@Override
public boolean test(File file, String path) {
if (globalExcluded(file, path))
return false;
for (String exclude : backupManager.config.workdirIgnoreList) {
if (exclude.startsWith("/")) { // relative to source of workdir
if (path.matches(exclude.substring(1)))
return false;
}
else {
String name = path.substring(path.lastIndexOf("/") + 1);
if (name.matches(exclude))
return false;
}
}
return true;
}
public boolean globalExcluded(File file, String path) {
if (file.isDirectory() && new File(file, "level.dat").exists())
return true;
if (new File(getSourceDir(), "logs").equals(file))
return true;
return false;
}
}
@Override
public File getSourceDir() {
return new File(".");
}
@Override
protected void onCompressStart() { }
@Override
protected void onCompressEnd(boolean success) { }
@Override
protected File getTargetDir() {
return new File(backupManager.config.backupDirectory, "workdir");
}
}

View File

@ -0,0 +1,54 @@
package fr.pandacube.lib.paper.modules.backup;
import fr.pandacube.lib.paper.util.WorldUtil;
import org.bukkit.Bukkit;
import org.bukkit.World;
import java.io.File;
import java.util.function.BiPredicate;
public class CompressWorldProcess extends CompressProcess {
private boolean autoSave = true;
protected CompressWorldProcess(BackupManager bm, final String n) {
super(bm, Type.WORLDS, n);
}
private World getWorld() {
return Bukkit.getWorld(name);
}
public BiPredicate<File, String> getFilenameFilter() {
return (f, s) -> true;
}
@Override
public File getSourceDir() {
return WorldUtil.worldDir(name);
}
@Override
protected void onCompressStart() {
World w = getWorld();
if (w == null)
return;
autoSave = w.isAutoSave();
w.setAutoSave(false);
}
@Override
protected void onCompressEnd(boolean success) {
World w = getWorld();
if (w == null)
return;
w.setAutoSave(autoSave);
}
@Override
protected File getTargetDir() {
return new File(backupManager.config.backupDirectory, type.toString() + "/" + name);
}
}

View File

@ -0,0 +1,133 @@
package fr.pandacube.lib.paper.modules.backup;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.text.DateFormat;
import java.util.Date;
import org.bukkit.World;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
import org.yaml.snakeyaml.error.YAMLException;
import fr.pandacube.lib.paper.PandaLibPaper;
import fr.pandacube.lib.util.Log;
public class Persist extends YamlConfiguration {
protected final BackupManager backupManager;
private final File file;
// private final Set<String> dirtyWorldsSave = new HashSet<>();
public Persist(BackupManager bm) {
file = new File(PandaLibPaper.getPlugin().getDataFolder(), "backup_persist.yml");
backupManager = bm;
load();
}
public void reload() {
load();
}
protected void load() {
boolean loaded = false;
try {
load(file);
loaded = true;
}
catch (final FileNotFoundException ignored) { }
catch (final IOException e) {
Log.severe("cannot load " + file, e);
}
catch (final InvalidConfigurationException e) {
if (e.getCause() instanceof YAMLException) Log.severe("Config file " + file + " isn't valid!", e);
else if (e.getCause() == null || e.getCause() instanceof ClassCastException) Log.severe("Config file " + file + " isn't valid!");
else Log.severe("cannot load " + file, e);
}
if (!loaded) {
options().copyDefaults(true);
save();
}
}
public void save() {
try {
save(file);
}
catch (final IOException e) {
Log.severe("could not save " + file, e);
}
}
/**
* Make the specified world dirty for compress. Also makes the specified world clean for saving if nobody is connected there.
*/
public void updateDirtyStatusAfterSave(final World world) {
if (world == null)
return;
if (!isDirty(Type.WORLDS, world.getName())) { // don't set dirty if it is already
setDirtySinceNow(Type.WORLDS, world.getName());
Log.info("[Backup] " + Type.WORLDS + "\\" + world.getName() + " was saved and is now dirty. Next backup on "
+ DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG)
.format(new Date(backupManager.getNextCompress(System.currentTimeMillis())))
);
}
}
/**
* Update the dirty status after the specified compress process is done.
* @param t the type of process
* @param n the name of the process (for instance, the name of the world)
*/
public void updateDirtyStatusAfterCompress(Type t, String n) {
if (t == Type.WORLDS)
setNotDirty(t, n);
else
setDirtySinceNow(t, n);
}
private void setDirtySinceNow(Type t, String n) {
set(t + "." + n + ".dirty_since", System.currentTimeMillis());
save();
}
private void setNotDirty(Type t, String n) {
if (t == Type.WORKDIR)
return; // WORKDIR are always considered dirty
set(t + "." + n + ".dirty_since", -1);
save();
}
public boolean isDirty(Type t, String n) {
if (t == Type.WORKDIR)
return true;
return isDirtySince(t, n) != -1;
}
public long isDirtySince(Type t, String n) {
if (!contains(t + "." + n + ".dirty_since"))
setDirtySinceNow(t, n);
return getLong(t + "." + n + ".dirty_since");
}
/*
*
* type: // (worlds|others)
* name: // (root|plugin|<worldName>)
* dirty_since: (true|false)
*
*/
}

View File

@ -0,0 +1,26 @@
package fr.pandacube.lib.paper.modules.backup;
public enum Type {
WORLDS,
WORKDIR;
@Override
public String toString() {
return name().toLowerCase();
}
public boolean backupEnabled(BackupConfig cfg) {
return switch (this) {
case WORLDS -> cfg.worldBackupEnabled;
case WORKDIR -> cfg.workdirBackupEnabled;
};
}
public BackupCleaner backupCleaner(BackupConfig cfg) {
return switch (this) {
case WORLDS -> cfg.worldBackupCleaner;
case WORKDIR -> cfg.workdirBackupCleaner;
};
}
}

View File

@ -0,0 +1,194 @@
package fr.pandacube.lib.paper.modules.backup;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiPredicate;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import fr.pandacube.lib.chat.Chat;
import fr.pandacube.lib.util.MemoryUtil;
import fr.pandacube.lib.util.TimeUtil;
/**
* Handles the creation of a zip file that will have the content of a provided folder.
*/
public class ZipCompressor {
private static final int BUFFER_SIZE = 16 * 1024;
private final File srcDir, destFile;
private final int compressionLevel;
private final BiPredicate<File, String> filter;
private final List<Entry> entriesToCompress;
private ZipOutputStream zipOutStream;
private final Object stateLock = new Object();
private final long inputByteSize;
private long startTime;
private long elapsedByte = 0;
private Exception exception = null;
private boolean started = false;
private boolean finished = false;
/**
* Creates a new zip compressor.
* @param s the source directory.
* @param d the destination file.
* @param c the compression level, used in {@link ZipOutputStream#setLevel(int)} .
* @param f a filter that returns true for the files to include in the zip file, false to exclude.
*/
public ZipCompressor(File s, File d, int c, BiPredicate<File, String> f) {
srcDir = s;
destFile = d;
compressionLevel = c;
filter = f;
entriesToCompress = new ArrayList<>();
inputByteSize = addEntry("");
}
/**
* Returns a displayable representation of the running compression.
* @return a displayable representation of the running compression.
*/
public Chat getState() {
synchronized (stateLock) {
if (!started) {
return Chat.text("Démarrage...");
}
else if (!finished && exception == null) {
float progress = getProgress();
long elapsedTime = System.nanoTime() - startTime;
long remainingTime = (long)(elapsedTime / progress) - elapsedTime;
return Chat.chat()
.infoColor()
.thenData(Math.round(progress*100*10)/10 + "% ")
.thenText("(")
.thenData(MemoryUtil.humanReadableSize(elapsedByte) + "/" + MemoryUtil.humanReadableSize(inputByteSize))
.thenText(") - Temps restant estimé : ")
.thenData(TimeUtil.durationToString(remainingTime / 1000000));
}
else if (exception != null) {
return Chat.failureText("Erreur lors de l'archivage (voir console pour les détails)");
}
else { // finished
return Chat.successText("Terminé !");
}
}
}
/**
* Gets the progress of the running compression.
* @return the progress of the running compression 0 when it starts and 1 when it finishes.
*/
public float getProgress() {
if (!started)
return 0;
if (finished)
return 1;
return elapsedByte / (float) inputByteSize;
}
/**
* Run the compression on the current thread, and returns after the end of the compression.
* Should be run asynchronously (not on Server Thread).
* @throws Exception if an error occurs during compression.
*/
public void compress() throws Exception {
destFile.getParentFile().mkdirs();
try(ZipOutputStream zipStream = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(destFile), BUFFER_SIZE))) {
zipOutStream = zipStream;
zipOutStream.setLevel(compressionLevel);
synchronized (stateLock) {
startTime = System.nanoTime();
started = true;
}
for (Entry entry : entriesToCompress) {
entry.zip();
}
synchronized (stateLock) {
finished = true;
}
} catch (Exception e) {
synchronized (stateLock) {
exception = e;
}
throw e;
}
}
private long addEntry(String currentEntry) {
final File currentFile = new File(srcDir, currentEntry);
if (!currentFile.exists())
return 0;
if (currentFile.isDirectory()) {
if (!currentEntry.isEmpty()) { // it's not the zip root directory
currentEntry += "/";
entriesToCompress.add(new Entry(currentFile, currentEntry));
}
long sum = 0;
for (String child : currentFile.list()) {
String childEntry = currentEntry + child;
if (filter.test(new File(currentFile, child), childEntry))
sum += addEntry(childEntry);
}
return sum;
}
else { // is a file
entriesToCompress.add(new Entry(currentFile, currentEntry));
return currentFile.length();
}
}
private class Entry {
File file;
String entry;
Entry(File f, String e) {
file = f;
entry = e;
}
void zip() throws IOException {
ZipEntry zipEntry = new ZipEntry(entry);
BasicFileAttributes attributes = Files.readAttributes(file.toPath(), BasicFileAttributes.class);
if (attributes.isDirectory()) {
zipOutStream.putNextEntry(zipEntry);
zipOutStream.closeEntry();
}
else {
zipEntry.setTime(attributes.lastModifiedTime().toMillis());
zipOutStream.putNextEntry(zipEntry);
try {
Files.copy(file.toPath(), zipOutStream);
}
finally {
zipOutStream.closeEntry();
}
synchronized (stateLock) {
elapsedByte += attributes.size();
}
}
}
}
}

View File

@ -7,6 +7,8 @@ import org.bukkit.scoreboard.Team;
import fr.pandacube.lib.players.standalone.AbstractOffPlayer; import fr.pandacube.lib.players.standalone.AbstractOffPlayer;
import java.util.function.UnaryOperator;
/** /**
* Represents any player on a paper server, either offline or online. * Represents any player on a paper server, either offline or online.
*/ */
@ -94,4 +96,37 @@ public interface PaperOffPlayer extends AbstractOffPlayer {
return teamPrefix + teamColor + name + teamSuffix; return teamPrefix + teamColor + name + teamSuffix;
} }
/*
* Player config
*/
@Override
default String getConfig(String key) throws Exception {
return PaperPlayerConfigStorage.get(getUniqueId(), key);
}
@Override
default String getConfig(String key, String deflt) throws Exception {
return PaperPlayerConfigStorage.get(getUniqueId(), key, deflt);
}
@Override
default void setConfig(String key, String value) throws Exception {
PaperPlayerConfigStorage.set(getUniqueId(), key, value);
}
@Override
default void updateConfig(String key, String deflt, UnaryOperator<String> updater) throws Exception {
PaperPlayerConfigStorage.update(getUniqueId(), key, deflt, updater);
}
@Override
default void unsetConfig(String key) throws Exception {
PaperPlayerConfigStorage.unset(getUniqueId(), key);
}
} }

View File

@ -3,6 +3,7 @@ package fr.pandacube.lib.paper.players;
import com.destroystokyo.paper.ClientOption; import com.destroystokyo.paper.ClientOption;
import com.destroystokyo.paper.ClientOption.ChatVisibility; import com.destroystokyo.paper.ClientOption.ChatVisibility;
import com.destroystokyo.paper.SkinParts; import com.destroystokyo.paper.SkinParts;
import fr.pandacube.lib.paper.players.PlayerNonPersistentConfig.Expiration;
import fr.pandacube.lib.players.standalone.AbstractOnlinePlayer; import fr.pandacube.lib.players.standalone.AbstractOnlinePlayer;
import net.kyori.adventure.audience.MessageType; import net.kyori.adventure.audience.MessageType;
import net.kyori.adventure.identity.Identified; import net.kyori.adventure.identity.Identified;
@ -278,4 +279,30 @@ public interface PaperOnlinePlayer extends PaperOffPlayer, AbstractOnlinePlayer
} }
/*
* Player config
*/
default String getNonPersistentConfig(String key) {
return PlayerNonPersistentConfig.getData(getUniqueId(), key);
}
default String getNonPersistentConfig(String key, String deflt) {
return PlayerNonPersistentConfig.getData(getUniqueId(), key);
}
default void setNonPersistentConfig(String key, String value, Expiration expiration) {
PlayerNonPersistentConfig.setData(getUniqueId(), key, value, expiration);
}
default void unsetNonPersistentConfig(String key) {
PlayerNonPersistentConfig.unsetData(getUniqueId(), key);
}
} }

View File

@ -0,0 +1,213 @@
package fr.pandacube.lib.paper.players;
import fr.pandacube.lib.paper.PandaLibPaper;
import fr.pandacube.lib.util.Log;
import org.bukkit.Bukkit;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
import java.io.File;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.UUID;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
public class PaperPlayerConfigStorage {
static File storageFile = new File(PandaLibPaper.getPlugin().getDataFolder(), "playerdata.yml");
static boolean initialized = false;
static LinkedHashMap<ConfigKey, ConfigEntry> data = new LinkedHashMap<>();
static LinkedHashMap<UUID, LinkedHashSet<ConfigEntry>> playerSortedData = new LinkedHashMap<>();
static LinkedHashMap<String, LinkedHashSet<ConfigEntry>> keySortedData = new LinkedHashMap<>();
static boolean changed = false;
private static synchronized void initIfNeeded() {
if (initialized)
return;
try {
load();
} catch (InvalidConfigurationException|IOException e) {
throw new RuntimeException("Unable to load the player data file.", e);
}
// auto-save every 30 seconds
Bukkit.getScheduler().runTaskTimerAsynchronously(PandaLibPaper.getPlugin(), PaperPlayerConfigStorage::save, 600, 600);
initialized = true;
}
private static synchronized void load() throws IOException, InvalidConfigurationException {
YamlConfiguration config = new YamlConfiguration();
config.load(storageFile);
data.clear();
playerSortedData.clear();
keySortedData.clear();
for (String pIdStr : config.getKeys(false)) {
UUID pId;
try {
pId = UUID.fromString(pIdStr);
} catch (IllegalArgumentException e) {
Log.severe("Invalid player UUID: '" + pIdStr + "'", e);
continue;
}
ConfigurationSection sec = config.getConfigurationSection(pIdStr);
for (String key : sec.getKeys(false)) {
String value = sec.getString(key);
create(pId, key, value);
}
}
changed = false;
}
private static synchronized void save() {
YamlConfiguration config = new YamlConfiguration();
for (UUID pId : playerSortedData.keySet()) {
String pIdStr = pId.toString();
ConfigurationSection sec = new YamlConfiguration();
for (ConfigEntry e : playerSortedData.get(pId)) {
sec.set(e.key, e.value);
}
config.set(pIdStr, sec);
}
try {
config.save(storageFile);
} catch (IOException e) {
throw new RuntimeException("Unable to save the player data file.", e);
}
changed = false;
}
private static synchronized void create(UUID player, String key, String newValue) {
ConfigKey cKey = new ConfigKey(player, key);
ConfigEntry e = new ConfigEntry(player, key, newValue);
data.put(cKey, e);
playerSortedData.computeIfAbsent(player, p -> new LinkedHashSet<>()).add(e);
keySortedData.computeIfAbsent(key, p -> new LinkedHashSet<>()).add(e);
}
public static synchronized void set(UUID player, String key, String newValue) {
initIfNeeded();
ConfigKey cKey = new ConfigKey(player, key);
ConfigEntry e = data.get(cKey);
if (e != null && newValue == null) { // delete
data.remove(cKey);
if (playerSortedData.containsKey(player))
playerSortedData.get(player).remove(e);
if (keySortedData.containsKey(key))
keySortedData.get(key).remove(e);
changed = true;
}
else if (e == null && newValue != null) { // create
create(player, key, newValue);
changed = true;
}
else if (e != null && !newValue.equals(e.value)) { // update
e.value = newValue;
changed = true;
}
}
public static synchronized String get(UUID player, String key) {
initIfNeeded();
ConfigEntry e = data.get(new ConfigKey(player, key));
return e != null ? e.value : null;
}
public static String get(UUID p, String k, String deflt) {
String value = get(p, k);
return value == null ? deflt : value;
}
public static synchronized void update(UUID p, String k, String deflt, UnaryOperator<String> updater) {
String oldValue = get(p, k, deflt);
set(p, k, updater.apply(oldValue));
}
public static void unset(UUID p, String k) {
set(p, k, null);
}
public static LinkedHashSet<ConfigEntry> getAllFromPlayer(UUID p) {
initIfNeeded();
return new LinkedHashSet<>(playerSortedData.getOrDefault(p, new LinkedHashSet<>()));
}
public static LinkedHashSet<ConfigEntry> getAllWithKeys(String key) {
initIfNeeded();
return new LinkedHashSet<>(keySortedData.getOrDefault(key, new LinkedHashSet<>()));
}
public static LinkedHashSet<ConfigEntry> getAllWithKeyValue(String k, String v) {
initIfNeeded();
return getAllWithKeys(k).stream()
.filter(c -> c.value.equals(v))
.collect(Collectors.toCollection(LinkedHashSet::new));
}
private record ConfigKey(UUID playerId, String key) { }
public static class ConfigEntry {
private final UUID playerId;
private final String key;
private String value;
private ConfigEntry(UUID playerId, String key, String value) {
this.playerId = playerId;
this.key = key;
this.value = value;
}
public UUID getPlayerId() {
return playerId;
}
public String getKey() {
return key;
}
public String getValue() {
return value;
}
private void setValue(String value) {
this.value = value;
}
@Override
public int hashCode() {
return Objects.hash(playerId, key);
}
@Override
public boolean equals(Object obj) {
return obj instanceof ConfigEntry o
&& Objects.equals(playerId, o.playerId)
&& Objects.equals(key, o.key);
}
}
}

View File

@ -0,0 +1,122 @@
package fr.pandacube.lib.paper.players;
import com.destroystokyo.paper.event.server.ServerTickStartEvent;
import fr.pandacube.lib.paper.PandaLibPaper;
import org.bukkit.Bukkit;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerQuitEvent;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
public class PlayerNonPersistentConfig {
private static final Map<UUID, Map<String, ConfigEntry>> data = new HashMap<>();
private static long tick = 0;
static {
new ConfigListeners();
}
public static void setData(UUID playerId, String key, String value, Expiration expiration) {
data.computeIfAbsent(Objects.requireNonNull(playerId, "playerId"), pp -> new HashMap<>())
.put(Objects.requireNonNull(key, "key"),
new ConfigEntry(Objects.requireNonNull(value, "value"),
Objects.requireNonNull(expiration, "expiration")
)
);
}
public static void unsetData(UUID playerId, String key) {
data.getOrDefault(Objects.requireNonNull(playerId, "playerId"), new HashMap<>())
.remove(Objects.requireNonNull(key, "key"));
}
public static String getData(UUID playerId, String key) {
Map<String, ConfigEntry> playerData = data.getOrDefault(Objects.requireNonNull(playerId, "playerId"), new HashMap<>());
ConfigEntry ce = playerData.get(Objects.requireNonNull(key, "key"));
if (ce == null)
return null;
if (!ce.expiration.valid(playerId, key)) {
playerData.remove(key);
return null;
}
return ce.value;
}
public static boolean isDataSet(UUID playerId, String key) {
return getData(playerId, key) != null;
}
private record ConfigEntry(String value, Expiration expiration) { }
public static abstract class Expiration {
abstract boolean valid(UUID player, String key);
}
public static class ExpiresLogout extends Expiration {
protected boolean valid(UUID player, String key) {
return Bukkit.getPlayer(player) != null; // should not be call if player reconnects because it is removed on player quit
}
}
public static class ExpiresTick extends Expiration {
long expirationTick;
public ExpiresTick(long expirationDelayTick) {
expirationTick = tick + expirationDelayTick;
}
protected boolean valid(UUID player, String key) {
return tick < expirationTick;
}
}
public static class ExpiresServerStop extends Expiration {
protected boolean valid(UUID player, String key) {
return true;
}
}
private static class ConfigListeners implements Listener {
public ConfigListeners() {
Bukkit.getPluginManager().registerEvents(this, PandaLibPaper.getPlugin());
}
@EventHandler
public void onPlayerQuit(PlayerQuitEvent event) {
data.getOrDefault(event.getPlayer().getUniqueId(), new HashMap<>())
.entrySet()
.removeIf(e -> e.getValue().expiration instanceof ExpiresLogout);
}
@EventHandler
public void onTickStart(ServerTickStartEvent event) {
tick++;
}
}
}

View File

@ -0,0 +1,75 @@
package fr.pandacube.lib.paper.util;
import org.bukkit.Bukkit;
import org.bukkit.World.Environment;
import java.io.File;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Pattern;
public class WorldUtil {
public static Environment determineEnvironment(String world) {
if (Bukkit.getWorld(world) != null) {
return Bukkit.getWorld(world).getEnvironment();
}
File worldFolder = worldDir(world);
if (!worldFolder.isDirectory())
throw new IllegalStateException("The world " + world + " is not a valid world (directory not found).");
if (!new File(worldFolder, "level.dat").isFile())
throw new IllegalStateException("The world " + world + " is not a valid world (level.dat not found).");
if (new File(worldFolder, "region").isDirectory())
return Environment.NORMAL;
if (new File(worldFolder, "DIM-1" + File.pathSeparator + "region").isDirectory())
return Environment.NETHER;
if (new File(worldFolder, "DIM1" + File.pathSeparator + "region").isDirectory())
return Environment.THE_END;
throw new IllegalStateException("Unable to determine the type of the world " + world + ".");
}
private static final List<String> REGION_DATA_FILES = Arrays.asList("entities", "poi", "region", "DIM-1", "DIM1");
public static List<File> regionDataFiles(String world) {
return onlyExistents(worldDir(world), REGION_DATA_FILES);
}
public static List<File> mapFiles(String world) {
Pattern mapFilePattern = Pattern.compile("map_\\d+.dat");
return List.of(dataDir(world).listFiles((dir, name) -> mapFilePattern.matcher(name).find() || "idcounts.dat".equals(name)));
}
private static List<File> onlyExistents(File worldDir, List<String> searchList) {
return searchList.stream()
.map(f -> new File(worldDir, f))
.filter(File::exists)
.toList();
}
public static File worldDir(String world) {
return new File(Bukkit.getWorldContainer(), world);
}
public static File dataDir(String world) {
return new File(worldDir(world), "data");
}
public static boolean isValidWorld(String world) {
File d = worldDir(world);
return d.isDirectory() && new File(d, "level.dat").isFile();
}
}

View File

@ -1,6 +1,7 @@
package fr.pandacube.lib.players.standalone; package fr.pandacube.lib.players.standalone;
import java.util.UUID; import java.util.UUID;
import java.util.function.UnaryOperator;
/** /**
* Represents any player, either offline or online. * Represents any player, either offline or online.
@ -65,6 +66,24 @@ public interface AbstractOffPlayer {
*/ */
String getDisplayName(); String getDisplayName();
/*
* Player config
*/
String getConfig(String key) throws Exception;
String getConfig(String key, String deflt) throws Exception;
void setConfig(String key, String value) throws Exception;
void updateConfig(String key, String deflt, UnaryOperator<String> updater) throws Exception;
void unsetConfig(String key) throws Exception;

View File

@ -75,7 +75,6 @@
<module>pandalib-paper</module> <module>pandalib-paper</module>
<module>pandalib-paper-commands</module> <module>pandalib-paper-commands</module>
<module>pandalib-paper-permissions</module> <module>pandalib-paper-permissions</module>
<module>pandalib-paper-players</module>
<module>pandalib-paper-reflect</module> <module>pandalib-paper-reflect</module>
<module>pandalib-permissions</module> <module>pandalib-permissions</module>
<module>pandalib-players</module> <module>pandalib-players</module>