saveAll();
+
+ /**
+ * Flushes cached data in the tracker.
+ *
+ * This method provides the option to aggressively
+ * flush the cache, which will remove all cached objects
+ * that do not have a lock set on them.
+ *
+ * NOTE: Ensure you have run the {@link #saveAll()} method
+ * as this method will not clear unsaved entries.
+ *
+ * @param aggressive if the flush should be aggressive.
+ */
+ void flush(boolean aggressive);
+
+ /**
+ * Destroys the tracker.
+ */
+ void destroy();
+}
diff --git a/src/main/java/org/battleplugins/tracker/TrackerExecutor.java b/src/main/java/org/battleplugins/tracker/TrackerExecutor.java
new file mode 100644
index 0000000..0b3d5a2
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/TrackerExecutor.java
@@ -0,0 +1,466 @@
+package org.battleplugins.tracker;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import org.battleplugins.tracker.feature.recap.BattleRecap;
+import org.battleplugins.tracker.feature.recap.Recap;
+import org.battleplugins.tracker.feature.recap.RecapRoundup;
+import org.battleplugins.tracker.message.Messages;
+import org.battleplugins.tracker.stat.Record;
+import org.battleplugins.tracker.stat.StatType;
+import org.battleplugins.tracker.stat.TallyEntry;
+import org.battleplugins.tracker.stat.VersusTally;
+import org.battleplugins.tracker.util.Util;
+import org.bukkit.Bukkit;
+import org.bukkit.OfflinePlayer;
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandExecutor;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.text.DecimalFormat;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.BiConsumer;
+
+public class TrackerExecutor implements CommandExecutor {
+ private final Tracker tracker;
+ private final Map commands;
+
+ public TrackerExecutor(Tracker tracker) {
+ this.tracker = tracker;
+
+ this.commands = new HashMap<>(
+ Map.of(
+ "top", new SimpleExecutor("View the top players of this tracker.", Arguments.ofOptional("max"), this::top),
+ "rank", new SimpleExecutor("View the rank of a player.", Arguments.ofOptional("player"), this::rank),
+ "versus", new SimpleExecutor("Compare the stats of players in relation to each other.", Arguments.of("player").optional("target"), this::versus)
+ )
+ );
+
+ if (this.tracker.hasFeature(Recap.class)) {
+ this.commands.put("recap", new SimpleExecutor("View the recap of a player.", Arguments.ofOptional("player"), this::recap));
+ }
+ }
+
+ @Override
+ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
+ if (args.length == 0) {
+ this.sendHelp(sender, label);
+ return true;
+ }
+
+ SimpleExecutor simpleCommand = this.commands.get(args[0]);
+ if (simpleCommand != null) {
+ if (!hasPermission(sender, args[0])) {
+ Messages.send(sender, "command-no-permission");
+ return true;
+ }
+
+ simpleCommand.consumer().accept(sender, args.length == 1 ? "" : String.join(" ", args).replaceFirst(args[0], "").trim());
+ return true;
+ }
+
+ this.sendHelp(sender, label);
+ return true;
+ }
+
+ public void sendHelp(CommandSender sender, String label) {
+ if (!hasPermission(sender, "help")) {
+ Messages.send(sender, "command-no-permission");
+ return;
+ }
+
+ Messages.send(sender, "header", this.tracker.getName());
+ Map executors = new HashMap<>(this.commands);
+
+ // Sort alphabetical
+ executors.keySet().stream()
+ .sorted()
+ .forEach(command -> {
+ Executor executor = executors.get(command);
+ String args = executor.describeArgs();
+ sender.sendMessage(Component.text("/" + label + " " + command + (args.isEmpty() ? "" : " " + args), NamedTextColor.YELLOW)
+ .append(Component.text(" " + executor.description(), NamedTextColor.GOLD)));
+ });
+ }
+
+ public void sendHelp(CommandSender sender, String label, String cmd, @Nullable Executor executor) {
+ if (executor == null) {
+ this.sendHelp(sender, label);
+ return;
+ }
+
+ Messages.send(sender, "command-usage", "/" + label + " " + cmd + " " + executor.describeArgs());
+ }
+
+ private void top(CommandSender sender, String argument) {
+ int amount = Math.max(1, argument == null || argument.isBlank() ? 5 : Math.min(100, Integer.parseInt(argument)));
+ Util.getSortedRecords(this.tracker, amount, StatType.RATING).whenComplete((records, e) -> {
+ if (records.isEmpty()) {
+ Messages.send(sender, "leaderboard-no-entries");
+ return;
+ }
+
+ Messages.send(sender, "header", this.tracker.getName());
+ int ranking = 1;
+ for (Map.Entry entry : records.entrySet()) {
+ Record record = entry.getKey();
+
+ Util.sendTrackerMessage(sender, "leaderboard", ranking++, record);
+ }
+ });
+ }
+
+ private void rank(CommandSender sender, String playerName) {
+ OfflinePlayer target;
+ if (!(sender instanceof Player) && (playerName == null || playerName.isBlank())) {
+ Messages.send(sender, "command-player-not-found", "");
+ return;
+ } else if (sender instanceof Player player && (playerName == null || playerName.isBlank())) {
+ target = player;
+ } else {
+ target = Bukkit.getServer().getOfflinePlayerIfCached(playerName);
+ }
+
+ if (target == null) {
+ CompletableFuture.supplyAsync(() -> Bukkit.getOfflinePlayer(playerName)).thenCompose(this.tracker::getRecord).whenCompleteAsync((record, e) -> {
+ if (record == null) {
+ Messages.send(sender, "player-has-no-record", playerName);
+ return;
+ }
+
+ this.rank(sender, record);
+ }, Bukkit.getScheduler().getMainThreadExecutor(BattleTracker.getInstance()));
+ } else {
+ tracker.getRecord(target).whenCompleteAsync((record, e) -> {
+ if (record == null) {
+ Messages.send(sender, "player-has-no-record", playerName);
+ return;
+ }
+
+ this.rank(sender, record);
+ }, Bukkit.getScheduler().getMainThreadExecutor(BattleTracker.getInstance()));
+ }
+ }
+
+ private void rank(CommandSender sender, Record record) {
+ Util.sendTrackerMessage(sender, "rank", -1, record);
+ }
+
+ private void versus(CommandSender sender, String arg) {
+ String[] args = arg.split(" ");
+ if (args.length == 0) {
+ Messages.send(sender, "command-player-not-found", "");
+ return;
+ }
+
+ if (args.length == 1) {
+ if (sender instanceof Player player) {
+ String playerName = args[0];
+ CompletableFuture.supplyAsync(() -> Bukkit.getOfflinePlayer(playerName)).whenCompleteAsync((p, e) -> {
+ if (e != null) {
+ BattleTracker.getInstance().error("Failed to get versus tally for {} and {}", playerName, player.getName(), e);
+ return;
+ }
+
+ if (p == null) {
+ Messages.send(sender, "command-player-not-found", playerName);
+ return;
+ }
+
+ this.versus(sender, player, p);
+ }, Bukkit.getScheduler().getMainThreadExecutor(BattleTracker.getInstance()));
+ } else {
+ Messages.send(sender, "command-player-not-found", "");
+ }
+ } else if (args.length == 2) {
+ CompletableFuture player1Future = CompletableFuture.supplyAsync(() -> Bukkit.getOfflinePlayer(args[0]));
+ CompletableFuture player2Future = CompletableFuture.supplyAsync(() -> Bukkit.getOfflinePlayer(args[1]));
+
+ CompletableFuture.allOf(
+ player1Future,
+ player2Future
+ ).whenCompleteAsync((players, e) -> {
+ if (e != null) {
+ BattleTracker.getInstance().error("Failed to get versus tally for {} and {}", args[0], args[1], e);
+ return;
+ }
+
+ OfflinePlayer player1 = player1Future.join();
+ OfflinePlayer player2 = player2Future.join();
+ if (player1 == null) {
+ Messages.send(sender, "command-player-not-found", args[0]);
+ return;
+ }
+
+ if (player2 == null) {
+ Messages.send(sender, "command-player-not-found", args[1]);
+ return;
+ }
+
+ this.versus(sender, player1, player2);
+ }, Bukkit.getScheduler().getMainThreadExecutor(BattleTracker.getInstance()));
+ }
+ }
+
+ private void recap(CommandSender sender, String argument) {
+ String[] args = argument.split(" ");
+ String arg = args.length >= 1 ? args[0] : null;
+ Player player = arg == null || arg.isBlank() ? null : Bukkit.getPlayer(arg);
+ if (player == null && sender instanceof Player senderPlayer) {
+ player = senderPlayer;
+ }
+
+ if (player == null) {
+ Messages.send(sender, "command-player-not-found", (arg == null || arg.isBlank()) ? "" : arg);
+ return;
+ }
+
+ Recap recap = this.tracker.feature(Recap.class).orElseThrow(() -> new IllegalStateException("Recap feature is not enabled!"));
+ if (!recap.enabled()) {
+ Messages.send(sender, "recap-not-enabled");
+ return;
+ }
+
+ BattleRecap battleRecap = recap.getPreviousRecap(player);
+ if (battleRecap == null || battleRecap.getLastEntry() == null) {
+ Messages.send(sender, "recap-no-recap");
+ return;
+ }
+
+ if (args.length >= 2) {
+ boolean sent = false;
+ switch (args[1].toLowerCase(Locale.ROOT)) {
+ case "item":
+ RecapRoundup.recapItem(sender, battleRecap);
+ sent = true;
+ break;
+ case "entity":
+ RecapRoundup.recapEntity(sender, battleRecap);
+ sent = true;
+ break;
+ case "cause":
+ RecapRoundup.recapSource(sender, battleRecap);
+ sent = true;
+ break;
+ case "player":
+ RecapRoundup.recapPlayer(sender, battleRecap);
+ sent = true;
+ break;
+ default:
+ break;
+ }
+
+ if (sent) {
+ RecapRoundup.sendFooter(sender, this.tracker, battleRecap);
+ return;
+ }
+ }
+
+ recap.showRecap(sender, this.tracker, battleRecap);
+ }
+
+ private void versus(CommandSender sender, OfflinePlayer player1, OfflinePlayer player2) {
+ CompletableFuture future = this.tracker.getVersusTally(player1, player2);
+ future.whenComplete((tally, e) -> {
+ if (e != null) {
+ BattleTracker.getInstance().error("Failed to get versus tally for {} and {}", player1.getName(), player2.getName(), e);
+ return;
+ }
+
+ if (tally == null) {
+ Messages.send(sender, "player-has-no-tally", player1.getName(), player2.getName());
+ return;
+ }
+
+ CompletableFuture record1Future = this.tracker.getRecord(player1);
+ CompletableFuture record2Future = this.tracker.getRecord(player2);
+
+ CompletableFuture.allOf(
+ record1Future,
+ record2Future
+ ).whenCompleteAsync((records, ex) -> {
+ if (ex != null) {
+ BattleTracker.getInstance().error("Failed to get records for {} and {}", player1.getName(), player2.getName(), ex);
+ return;
+ }
+
+ Record record1 = record1Future.join();
+ Record record2 = record2Future.join();
+ if (record1 == null) {
+ Messages.send(sender, "player-has-no-record", player1.getName());
+ return;
+ }
+
+ if (record2 == null) {
+ Messages.send(sender, "player-has-no-record", player2.getName());
+ return;
+ }
+
+ this.versus0(sender, player1, record1, player2, record2, tally);
+ }, Bukkit.getScheduler().getMainThreadExecutor(BattleTracker.getInstance()));
+ });
+ }
+
+ private void versus0(CommandSender sender, OfflinePlayer player1, Record record1, OfflinePlayer player2, Record record2, VersusTally tally) {
+ DecimalFormat format = new DecimalFormat("0.##");
+
+ Messages.send(sender, "header", Messages.getPlain("versus-tally"));
+ Messages.send(sender, "versus", Map.of(
+ "player", record1.getName(),
+ "target", record2.getName(),
+ "player_rating", format.format(record1.getRating()),
+ "target_rating", format.format(record2.getRating())
+ ));
+
+ Map replacements = new HashMap<>();
+ replacements.put("kills", format.format(tally.getStat(StatType.KILLS)));
+ replacements.put("deaths", format.format(tally.getStat(StatType.DEATHS)));
+
+ // Since versus tallies are only stored one way, we need to flip the value
+ // in the scenario that the "1st" player instead the 2nd player
+ if (tally.id2().equals(player1.getUniqueId())) {
+ replacements.put("player", player2.getName());
+ replacements.put("target", player1.getName());
+ } else {
+ replacements.put("player", player1.getName());
+ replacements.put("target", player2.getName());
+ }
+
+ Messages.send(sender, "versus-compare", replacements);
+
+ CompletableFuture> future = this.tracker.getTallyEntries(player1.getUniqueId(), true);
+ future.whenComplete((entries, e) -> {
+ if (e != null) {
+ BattleTracker.getInstance().error("Failed to get tally entries for {}", player1.getName(), e);
+ return;
+ }
+
+ if (entries == null || entries.isEmpty()) {
+ return;
+ }
+
+ DateTimeFormatter formatter = DateTimeFormatter.ofPattern(Messages.getPlain("date-format"))
+ .withLocale(sender instanceof Player player ? player.locale() : Locale.ROOT)
+ .withZone(ZoneId.systemDefault());
+
+ Messages.send(sender, "versus-history");
+
+ // Sort entries by most recent
+ entries.stream()
+ .filter(entry -> {
+ // Ensure the entries are against the same two players
+ return (entry.id1().equals(player1.getUniqueId()) && entry.id2().equals(player2.getUniqueId())) ||
+ (entry.id1().equals(player2.getUniqueId()) && entry.id2().equals(player1.getUniqueId()));
+ })
+ .sorted((e1, e2) -> e2.timestamp().compareTo(e1.timestamp()))
+ .limit(5) // Limit to top 5
+ .forEach(entry -> {
+ // If the player is the first player, they won
+ if (entry.id1().equals(player1.getUniqueId())) {
+ Messages.send(sender, "versus-history-entry-win", Map.of(
+ "player", player1.getName(),
+ "target", player2.getName(),
+ "date", formatter.format(entry.timestamp())
+ ));
+ } else {
+ Messages.send(sender, "versus-history-entry-loss", Map.of(
+ "player", player1.getName(),
+ "target", player2.getName(),
+ "date", formatter.format(entry.timestamp())
+ ));
+ }
+ });
+ });
+ }
+
+ record SimpleExecutor(String description, Arguments args, BiConsumer consumer) implements Executor {
+
+ @Override
+ public String describeArgs() {
+ return this.args.describe();
+ }
+ }
+
+ private static boolean hasPermission(CommandSender sender, String node) {
+ return sender.hasPermission("battletracker.command." + node);
+ }
+
+ public interface Executor {
+ String description();
+
+ String describeArgs();
+ }
+
+ public static class Arguments {
+ private final List arguments = new ArrayList<>();
+
+ private Arguments() {
+ }
+
+ public String describe() {
+ if (this.arguments.isEmpty()) {
+ return "";
+ }
+
+ return this.arguments.stream()
+ .map(argument -> argument.required() ? "<" + argument.name() + ">" : "[" + argument.name() + "]")
+ .reduce((a, b) -> a + " " + b)
+ .orElse("");
+ }
+
+ private Arguments(boolean required, String... arguments) {
+ for (String argument : arguments) {
+ this.arguments.add(new Argument(argument, required));
+ }
+ }
+
+ public Arguments required(String... arguments) {
+ for (String argument : arguments) {
+ this.arguments.add(new Argument(argument, true));
+ }
+
+ return this;
+ }
+
+ public Arguments optional(String... arguments) {
+ for (String argument : arguments) {
+ this.arguments.add(new Argument(argument, false));
+ }
+
+ return this;
+ }
+
+ private Arguments(Argument... arguments) {
+ this.arguments.addAll(List.of(arguments));
+ }
+
+ public static Arguments of(String... arguments) {
+ return new Arguments(true, arguments);
+ }
+
+ public static Arguments of(Argument... arguments) {
+ return new Arguments(arguments);
+ }
+
+ public static Arguments ofOptional(String... arguments) {
+ return new Arguments(false, arguments);
+ }
+
+ public static Arguments empty() {
+ return new Arguments();
+ }
+
+ public record Argument(String name, boolean required) {
+ }
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/TrackerLoader.java b/src/main/java/org/battleplugins/tracker/TrackerLoader.java
new file mode 100644
index 0000000..21ef1b8
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/TrackerLoader.java
@@ -0,0 +1,86 @@
+package org.battleplugins.tracker;
+
+import org.battleplugins.tracker.feature.Killstreaks;
+import org.battleplugins.tracker.feature.Rampage;
+import org.battleplugins.tracker.feature.message.DeathMessages;
+import org.battleplugins.tracker.feature.recap.Recap;
+import org.battleplugins.tracker.listener.PvEListener;
+import org.battleplugins.tracker.listener.PvPListener;
+import org.battleplugins.tracker.stat.calculator.RatingCalculator;
+import org.bukkit.command.PluginCommand;
+import org.bukkit.configuration.Configuration;
+
+import java.nio.file.Path;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+record TrackerLoader(BattleTracker battleTracker, Configuration configuration, Path trackerPath) {
+
+ public void load() {
+ String name = this.configuration.getString("name");
+ String calculatorName = this.configuration.getString("calculator");
+ RatingCalculator calculator = this.battleTracker.getCalculator(calculatorName);
+ if (calculator == null) {
+ this.battleTracker.warn("Rating calculator {} not found!", calculatorName);
+ return;
+ }
+
+ List trackedData = this.configuration.getStringList("tracked-statistics");
+ if (trackedData.isEmpty()) {
+ this.battleTracker.warn("No tracked data found for tracker {}!", name);
+ return;
+ }
+
+ Set dataTypes = EnumSet.noneOf(TrackedDataType.class);
+ for (String data : trackedData) {
+ try {
+ TrackedDataType type = TrackedDataType.valueOf(data.toUpperCase(Locale.ROOT));
+ dataTypes.add(type);
+ } catch (IllegalArgumentException e) {
+ this.battleTracker.warn("Unknown tracked data type {} for tracker {}!", data, name);
+ }
+ }
+
+ List disabledWorlds = this.configuration.getStringList("disabled-worlds");
+
+ Tracker tracker = new SqlTracker(this.battleTracker, name, calculator, dataTypes, disabledWorlds);
+ if (this.configuration.isConfigurationSection("killstreaks")) {
+ tracker.registerFeature(Killstreaks.load(this.configuration.getConfigurationSection("killstreaks")));
+ }
+
+ if (this.configuration.isConfigurationSection("rampage")) {
+ tracker.registerFeature(Rampage.load(this.configuration.getConfigurationSection("rampage")));
+ }
+
+ if (this.configuration.isConfigurationSection("death-messages")) {
+ tracker.registerFeature(DeathMessages.load(this.configuration.getConfigurationSection("death-messages")));
+ }
+
+ if (this.configuration.isConfigurationSection("recap")) {
+ tracker.registerFeature(Recap.load(this.configuration.getConfigurationSection("recap")));
+ } else {
+ // Recaps are always enabled as they are used throughout the tracker for
+ // retrieving player damages. However, whether they are displayed is determined
+ // by the configuration.
+ throw new IllegalArgumentException("Recap configuration not found!");
+ }
+
+ // Register command
+ PluginCommand command = this.battleTracker.getCommand(tracker.getName().toLowerCase(Locale.ROOT));
+ TrackerExecutor executor = new TrackerExecutor(tracker);
+ command.setExecutor(executor);
+
+ if (tracker.tracksData(TrackedDataType.PVP)) {
+ this.battleTracker.registerListener(tracker, new PvPListener(tracker));
+ }
+
+ if (tracker.tracksData(TrackedDataType.PVE) || tracker.tracksData(TrackedDataType.WORLD)) {
+ this.battleTracker.registerListener(tracker, new PvEListener(tracker));
+ }
+
+ this.battleTracker.registerTracker(tracker);
+ this.battleTracker.info("Loaded tracker: {}.", name);
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/event/BattleTrackerPostInitializeEvent.java b/src/main/java/org/battleplugins/tracker/event/BattleTrackerPostInitializeEvent.java
new file mode 100644
index 0000000..2bc5c3b
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/event/BattleTrackerPostInitializeEvent.java
@@ -0,0 +1,38 @@
+package org.battleplugins.tracker.event;
+
+import org.battleplugins.tracker.BattleTracker;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Called when BattleTracker is fully initialized.
+ */
+public class BattleTrackerPostInitializeEvent extends Event {
+ private final static HandlerList HANDLERS = new HandlerList();
+
+ private final BattleTracker battleTracker;
+
+ public BattleTrackerPostInitializeEvent(BattleTracker battleTracker) {
+ this.battleTracker = battleTracker;
+ }
+
+ /**
+ * Returns the {@link BattleTracker} instance.
+ *
+ * @return the BattleTracker instance
+ */
+ public BattleTracker getBattleTracker() {
+ return battleTracker;
+ }
+
+ @NotNull
+ @Override
+ public HandlerList getHandlers() {
+ return HANDLERS;
+ }
+
+ public static HandlerList getHandlerList() {
+ return HANDLERS;
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/event/BattleTrackerPreInitializeEvent.java b/src/main/java/org/battleplugins/tracker/event/BattleTrackerPreInitializeEvent.java
new file mode 100644
index 0000000..8ffffa1
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/event/BattleTrackerPreInitializeEvent.java
@@ -0,0 +1,38 @@
+package org.battleplugins.tracker.event;
+
+import org.battleplugins.tracker.BattleTracker;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Called when BattleTracker is starting its initialization.
+ */
+public class BattleTrackerPreInitializeEvent extends Event {
+ private final static HandlerList HANDLERS = new HandlerList();
+
+ private final BattleTracker battleTracker;
+
+ public BattleTrackerPreInitializeEvent(BattleTracker battleTracker) {
+ this.battleTracker = battleTracker;
+ }
+
+ /**
+ * Returns the {@link BattleTracker} instance.
+ *
+ * @return the BattleTracker instance
+ */
+ public BattleTracker getBattleTracker() {
+ return battleTracker;
+ }
+
+ @NotNull
+ @Override
+ public HandlerList getHandlers() {
+ return HANDLERS;
+ }
+
+ public static HandlerList getHandlerList() {
+ return HANDLERS;
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/event/TallyRecordEvent.java b/src/main/java/org/battleplugins/tracker/event/TallyRecordEvent.java
new file mode 100644
index 0000000..20ec490
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/event/TallyRecordEvent.java
@@ -0,0 +1,74 @@
+package org.battleplugins.tracker.event;
+
+import org.battleplugins.tracker.Tracker;
+import org.battleplugins.tracker.stat.Record;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Called when a tally is recorded for two different
+ * players.
+ */
+public class TallyRecordEvent extends Event {
+ private final static HandlerList HANDLERS = new HandlerList();
+
+ private final Tracker tracker;
+ private final Record victor;
+ private final Record loser;
+ private final boolean tie;
+
+ public TallyRecordEvent(Tracker tracker, Record victor, Record loser, boolean tie) {
+ this.tracker = tracker;
+ this.victor = victor;
+ this.loser = loser;
+ this.tie = tie;
+ }
+
+ /**
+ * Returns the {@link Tracker} instance this
+ * tally was recorded in.
+ *
+ * @return the Tracker instance
+ */
+ public Tracker getTracker() {
+ return this.tracker;
+ }
+
+ /**
+ * Returns the victor of the tally.
+ *
+ * @return the victor of the tally
+ */
+ public Record getVictor() {
+ return this.victor;
+ }
+
+ /**
+ * Returns the loser of the tally.
+ *
+ * @return the loser of the tally
+ */
+ public Record getLoser() {
+ return this.loser;
+ }
+
+ /**
+ * Returns whether the tally was a tie.
+ *
+ * @return whether the tally was a tie
+ */
+ public boolean isTie() {
+ return this.tie;
+ }
+
+ @NotNull
+ @Override
+ public HandlerList getHandlers() {
+ return HANDLERS;
+ }
+
+ public static HandlerList getHandlerList() {
+ return HANDLERS;
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/event/TrackerDeathEvent.java b/src/main/java/org/battleplugins/tracker/event/TrackerDeathEvent.java
new file mode 100644
index 0000000..a59cf33
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/event/TrackerDeathEvent.java
@@ -0,0 +1,107 @@
+package org.battleplugins.tracker.event;
+
+import org.battleplugins.tracker.Tracker;
+import org.bukkit.entity.Entity;
+import org.bukkit.event.HandlerList;
+import org.bukkit.event.entity.PlayerDeathEvent;
+import org.bukkit.event.player.PlayerEvent;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Optional;
+
+/**
+ * Called when a player dies and has their data
+ * tracked by BattleTracker.
+ */
+public class TrackerDeathEvent extends PlayerEvent {
+ private final static HandlerList HANDLERS = new HandlerList();
+
+ private final Tracker tracker;
+ private final DeathType deathType;
+ private final Entity killer;
+ private final PlayerDeathEvent deathEvent;
+
+ public TrackerDeathEvent(Tracker tracker, DeathType deathType, @Nullable Entity killer, PlayerDeathEvent deathEvent) {
+ super(deathEvent.getEntity());
+
+ this.tracker = tracker;
+ this.deathType = deathType;
+ this.killer = killer;
+ this.deathEvent = deathEvent;
+ }
+
+ /**
+ * Returns the {@link Tracker} instance this
+ * tally was recorded in.
+ *
+ * @return the Tracker instance
+ */
+ public Tracker getTracker() {
+ return this.tracker;
+ }
+
+ /**
+ * Returns the type of death the player experienced.
+ *
+ * @return the type of death
+ */
+ public DeathType getDeathType() {
+ return this.deathType;
+ }
+
+ /**
+ * Returns the entity that killed the player.
+ *
+ * @return the entity that killed the player
+ */
+ public Optional killer() {
+ return Optional.ofNullable(this.killer);
+ }
+
+ /**
+ * Returns the entity that killed the player.
+ *
+ * @return the entity that killed the player
+ */
+ @Nullable
+ public Entity getKiller() {
+ return this.killer;
+ }
+
+ /**
+ * Returns the backing {@link PlayerDeathEvent}.
+ *
+ * @return the backing PlayerDeathEvent
+ */
+ public PlayerDeathEvent getDeathEvent() {
+ return this.deathEvent;
+ }
+
+ /**
+ * Returns the type of death the player experienced.
+ */
+ public enum DeathType {
+ /**
+ * The player died to another player.
+ */
+ PLAYER,
+ /**
+ * The player died to an entity.
+ */
+ ENTITY,
+ /**
+ * The player died to the world.
+ */
+ WORLD
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return HANDLERS;
+ }
+
+ public static HandlerList getHandlerList() {
+ return HANDLERS;
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/event/feature/DeathMessageEvent.java b/src/main/java/org/battleplugins/tracker/event/feature/DeathMessageEvent.java
new file mode 100644
index 0000000..66eeb1d
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/event/feature/DeathMessageEvent.java
@@ -0,0 +1,64 @@
+package org.battleplugins.tracker.event.feature;
+
+import net.kyori.adventure.text.Component;
+import org.battleplugins.tracker.Tracker;
+import org.bukkit.entity.Player;
+import org.bukkit.event.HandlerList;
+import org.bukkit.event.player.PlayerEvent;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Called when a player dies and has their death message
+ * displayed by BattleTracker.
+ */
+public class DeathMessageEvent extends PlayerEvent {
+ private final static HandlerList HANDLERS = new HandlerList();
+
+ private Component deathMessage;
+ private final Tracker tracker;
+
+ public DeathMessageEvent(Player player, Component deathMessage, Tracker tracker) {
+ super(player);
+
+ this.deathMessage = deathMessage;
+ this.tracker = tracker;
+ }
+
+ /**
+ * Returns the {@link Tracker} instance this
+ * death message was displayed for.
+ *
+ * @return the Tracker instance
+ */
+ public Tracker getTracker() {
+ return this.tracker;
+ }
+
+ /**
+ * Returns the death message that will be displayed.
+ *
+ * @return the death message
+ */
+ public Component getDeathMessage() {
+ return deathMessage;
+ }
+
+ /**
+ * Sets the death message that will be displayed.
+ *
+ * @param deathMessage the death message
+ */
+ public void setDeathMessage(Component deathMessage) {
+ this.deathMessage = deathMessage;
+ }
+
+ @NotNull
+ @Override
+ public HandlerList getHandlers() {
+ return HANDLERS;
+ }
+
+ public static HandlerList getHandlerList() {
+ return HANDLERS;
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/Feature.java b/src/main/java/org/battleplugins/tracker/feature/Feature.java
new file mode 100644
index 0000000..4e948c2
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/Feature.java
@@ -0,0 +1,12 @@
+package org.battleplugins.tracker.feature;
+
+import org.battleplugins.tracker.BattleTracker;
+
+public interface Feature {
+
+ boolean enabled();
+
+ void onEnable(BattleTracker battleTracker);
+
+ void onDisable(BattleTracker battleTracker);
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/Killstreaks.java b/src/main/java/org/battleplugins/tracker/feature/Killstreaks.java
new file mode 100644
index 0000000..a4c2d60
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/Killstreaks.java
@@ -0,0 +1,87 @@
+package org.battleplugins.tracker.feature;
+
+import net.kyori.adventure.text.Component;
+import org.battleplugins.tracker.BattleTracker;
+import org.battleplugins.tracker.Tracker;
+import org.battleplugins.tracker.event.TrackerDeathEvent;
+import org.battleplugins.tracker.feature.message.MessageAudience;
+import org.battleplugins.tracker.stat.Record;
+import org.battleplugins.tracker.stat.StatType;
+import org.battleplugins.tracker.util.MessageUtil;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public record Killstreaks(
+ boolean enabled,
+ int minimumKills,
+ int killstreakMessageInterval,
+ MessageAudience audience,
+ Map messages
+) implements TrackerFeature {
+
+ @Override
+ public void onEnable(BattleTracker battleTracker, Tracker tracker) {
+ battleTracker.registerListener(tracker, new KillstreakListener(tracker, this));
+ }
+
+ public static Killstreaks load(ConfigurationSection section) {
+ boolean enabled = section.getBoolean("enabled");
+ if (!enabled) {
+ return new Killstreaks(false, 0, 0, MessageAudience.GLOBAL, Map.of());
+ }
+
+ int minimumKills = section.getInt("minimum-kills");
+ int killstreakMessageInterval = section.getInt("killstreak-message-interval");
+ MessageAudience audience = MessageAudience.get(section.getString("audience"));
+
+ Map messages = new HashMap<>();
+ ConfigurationSection messagesSection = section.getConfigurationSection("messages");
+ messagesSection.getKeys(false).forEach(key -> {
+ if (!messagesSection.isString(key)) {
+ throw new IllegalArgumentException("Message " + key + " is not a string!");
+ }
+
+ messages.put(key, MessageUtil.deserialize(messagesSection.getString(key)));
+ });
+
+ return new Killstreaks(true, minimumKills, killstreakMessageInterval, audience, messages);
+ }
+
+ private record KillstreakListener(Tracker tracker, Killstreaks killstreaks) implements Listener {
+
+ @EventHandler
+ public void onTrackerDeath(TrackerDeathEvent event) {
+ if (!event.getTracker().equals(this.tracker)) {
+ return;
+ }
+
+ if (!(event.getKiller() instanceof Player killer)) {
+ return;
+ }
+
+ Record record = this.tracker.getRecord(killer);
+ float streak = record.getStat(StatType.STREAK);
+ if (streak >= this.killstreaks.minimumKills()) {
+ if ((int) streak % this.killstreaks.killstreakMessageInterval() != 0) {
+ return;
+ }
+
+ String killsStr = Integer.toString(Float.valueOf(streak).intValue());
+ Component message = this.killstreaks.messages().get(killsStr);
+ if (message == null) {
+ message = this.killstreaks.messages().get("default");
+ }
+
+ message = message.replaceText(builder -> builder.matchLiteral("%player%").once().replacement(killer.name()));
+ message = message.replaceText(builder -> builder.matchLiteral("%kills%").once().replacement(killsStr));
+
+ this.killstreaks.audience().broadcastMessage(message, killer, event.getPlayer());
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/Rampage.java b/src/main/java/org/battleplugins/tracker/feature/Rampage.java
new file mode 100644
index 0000000..77a6aef
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/Rampage.java
@@ -0,0 +1,96 @@
+package org.battleplugins.tracker.feature;
+
+import net.kyori.adventure.text.Component;
+import org.battleplugins.tracker.BattleTracker;
+import org.battleplugins.tracker.Tracker;
+import org.battleplugins.tracker.event.TrackerDeathEvent;
+import org.battleplugins.tracker.feature.message.MessageAudience;
+import org.battleplugins.tracker.util.MessageUtil;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+public record Rampage(
+ boolean enabled,
+ int rampageTime,
+ MessageAudience audience,
+ Map messages
+) implements TrackerFeature {
+
+ @Override
+ public void onEnable(BattleTracker battleTracker, Tracker tracker) {
+ battleTracker.registerListener(tracker, new RampageListener(tracker, this));
+ }
+
+ public static Rampage load(ConfigurationSection section) {
+ boolean enabled = section.getBoolean("enabled");
+ if (!enabled) {
+ return new Rampage(false, 0, MessageAudience.GLOBAL, Map.of());
+ }
+
+ int rampageTime = section.getInt("rampage-time");
+ MessageAudience audience = MessageAudience.get(section.getString("audience"));
+
+ Map messages = new HashMap<>();
+ ConfigurationSection messagesSection = section.getConfigurationSection("messages");
+ messagesSection.getKeys(false).forEach(key -> {
+ if (!messagesSection.isString(key)) {
+ throw new IllegalArgumentException("Message " + key + " is not a string!");
+ }
+
+ messages.put(key, MessageUtil.deserialize(messagesSection.getString(key)));
+ });
+
+ return new Rampage(true, rampageTime, audience, messages);
+ }
+
+ private static class RampageListener implements Listener {
+ private final Tracker tracker;
+ private final Rampage rampage;
+
+ private final Map lastKill = new HashMap<>();
+ private final Map killCount = new HashMap<>();
+
+ private RampageListener(Tracker tracker, Rampage rampage) {
+ this.tracker = tracker;
+ this.rampage = rampage;
+ }
+
+ @EventHandler
+ public void onTrackerDeath(TrackerDeathEvent event) {
+ if (!event.getTracker().equals(this.tracker)) {
+ return;
+ }
+
+ if (!(event.getKiller() instanceof Player killer)) {
+ return;
+ }
+
+ UUID killerUUID = killer.getUniqueId();
+ long lastKillTime = this.lastKill.getOrDefault(killerUUID, 0L);
+ if (System.currentTimeMillis() - lastKillTime > this.rampage.rampageTime() * 1000L) {
+ this.killCount.put(killerUUID, 0);
+ }
+
+ this.lastKill.put(killerUUID, System.currentTimeMillis());
+ int killCount = this.killCount.getOrDefault(killerUUID, 0) + 1;
+ this.killCount.put(killerUUID, killCount);
+
+ if (killCount > 1) {
+ Component message = this.rampage.messages().get(Integer.toString(killCount));
+ if (message == null) {
+ message = this.rampage.messages().get("default");
+ }
+
+ message = message.replaceText(builder -> builder.matchLiteral("%player%").once().replacement(killer.name()));
+
+ this.rampage.audience().broadcastMessage(message, killer, event.getPlayer());
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/TrackerFeature.java b/src/main/java/org/battleplugins/tracker/feature/TrackerFeature.java
new file mode 100644
index 0000000..ac6de65
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/TrackerFeature.java
@@ -0,0 +1,14 @@
+package org.battleplugins.tracker.feature;
+
+import org.battleplugins.tracker.BattleTracker;
+import org.battleplugins.tracker.Tracker;
+
+/**
+ * Represents a feature that can be loaded by BattleTracker.
+ */
+public interface TrackerFeature {
+
+ boolean enabled();
+
+ void onEnable(BattleTracker battleTracker, Tracker tracker);
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/battlearena/ArenaTracker.java b/src/main/java/org/battleplugins/tracker/feature/battlearena/ArenaTracker.java
new file mode 100644
index 0000000..7dccad7
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/battlearena/ArenaTracker.java
@@ -0,0 +1,35 @@
+package org.battleplugins.tracker.feature.battlearena;
+
+import org.battleplugins.tracker.BattleTracker;
+import org.battleplugins.tracker.SqlTracker;
+import org.battleplugins.tracker.TrackedDataType;
+import org.battleplugins.tracker.sql.TrackerSqlSerializer;
+import org.battleplugins.tracker.stat.StatType;
+import org.battleplugins.tracker.stat.calculator.RatingCalculator;
+
+import java.util.List;
+import java.util.Set;
+
+public class ArenaTracker extends SqlTracker {
+ public static StatType WINS = StatType.create("wins", "Wins", true);
+ public static StatType LOSSES = StatType.create("losses", "Losses", true);
+
+ public ArenaTracker(BattleTracker plugin, String name, RatingCalculator calculator, Set trackedData, List disabledWorlds) {
+ super(plugin, name, calculator, trackedData, disabledWorlds);
+ }
+
+ @Override
+ protected TrackerSqlSerializer createSerializer() {
+ List generalStats = List.of(
+ WINS, LOSSES,
+ StatType.KILLS, StatType.DEATHS, StatType.TIES,
+ StatType.MAX_STREAK, StatType.MAX_RANKING, StatType.RATING,
+ StatType.MAX_RATING, StatType.MAX_KD_RATIO
+ );
+ return new TrackerSqlSerializer(
+ this,
+ generalStats,
+ List.of(WINS, LOSSES, StatType.TIES)
+ );
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaFeature.java b/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaFeature.java
new file mode 100644
index 0000000..f49a5fa
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaFeature.java
@@ -0,0 +1,38 @@
+package org.battleplugins.tracker.feature.battlearena;
+
+import org.battleplugins.tracker.BattleTracker;
+import org.battleplugins.tracker.feature.Feature;
+import org.bukkit.Bukkit;
+
+public class BattleArenaFeature implements Feature {
+ private final boolean enabled;
+
+ private BattleArenaHandler handler;
+
+ public BattleArenaFeature() {
+ this.enabled = Bukkit.getServer().getPluginManager().getPlugin("BattleArena") != null;
+ }
+
+ @Override
+ public boolean enabled() {
+ return this.enabled;
+ }
+
+ @Override
+ public void onEnable(BattleTracker battleTracker) {
+ if (!this.enabled) {
+ battleTracker.info("BattleArena not found. Not tracking arena statistics.");
+ return;
+ }
+
+ this.handler = new BattleArenaHandler(battleTracker);
+ battleTracker.info("BattleArena found. Tracking arena statistics.");
+ }
+
+ @Override
+ public void onDisable(BattleTracker battleTracker) {
+ if (this.handler != null) {
+ this.handler.onDisable();
+ }
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaHandler.java b/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaHandler.java
new file mode 100644
index 0000000..81e082d
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaHandler.java
@@ -0,0 +1,32 @@
+package org.battleplugins.tracker.feature.battlearena;
+
+import org.battleplugins.arena.messages.Message;
+import org.battleplugins.arena.options.ArenaOptionType;
+import org.battleplugins.arena.options.types.BooleanArenaOption;
+import org.battleplugins.tracker.BattleTracker;
+import org.battleplugins.tracker.message.Messages;
+import org.battleplugins.tracker.util.MessageUtil;
+import org.bukkit.Bukkit;
+import org.bukkit.event.HandlerList;
+
+public class BattleArenaHandler {
+ public static final ArenaOptionType TRACK_STATISTICS = ArenaOptionType.create("track-statistics", BooleanArenaOption::new);
+
+ private final BattleTracker battleTracker;
+
+ public BattleArenaHandler(BattleTracker battleTracker) {
+ this.battleTracker = battleTracker;
+
+ Bukkit.getPluginManager().registerEvents(new BattleArenaListener(battleTracker), battleTracker);
+ }
+
+ public void onDisable() {
+ HandlerList.getRegisteredListeners(this.battleTracker).stream()
+ .filter(listener -> listener.getListener() instanceof BattleArenaListener)
+ .forEach(l -> HandlerList.unregisterAll(l.getListener()));
+ }
+
+ public static Message convertMessage(String message) {
+ return org.battleplugins.arena.messages.Messages.wrap(MessageUtil.serialize(Messages.get(message)));
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaListener.java b/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaListener.java
new file mode 100644
index 0000000..f2d8889
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaListener.java
@@ -0,0 +1,285 @@
+package org.battleplugins.tracker.feature.battlearena;
+
+import org.battleplugins.arena.Arena;
+import org.battleplugins.arena.ArenaPlayer;
+import org.battleplugins.arena.competition.JoinResult;
+import org.battleplugins.arena.competition.LiveCompetition;
+import org.battleplugins.arena.competition.phase.CompetitionPhaseType;
+import org.battleplugins.arena.event.arena.ArenaCreateExecutorEvent;
+import org.battleplugins.arena.event.arena.ArenaDrawEvent;
+import org.battleplugins.arena.event.arena.ArenaInitializeEvent;
+import org.battleplugins.arena.event.arena.ArenaVictoryEvent;
+import org.battleplugins.arena.event.player.ArenaKillEvent;
+import org.battleplugins.arena.event.player.ArenaLeaveEvent;
+import org.battleplugins.arena.event.player.ArenaPreJoinEvent;
+import org.battleplugins.arena.options.types.BooleanArenaOption;
+import org.battleplugins.tracker.BattleTracker;
+import org.battleplugins.tracker.TrackedDataType;
+import org.battleplugins.tracker.Tracker;
+import org.battleplugins.tracker.stat.Record;
+import org.battleplugins.tracker.stat.StatType;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+public class BattleArenaListener implements Listener {
+ private final BattleTracker battleTracker;
+ private final Map> pendingExecutors = new HashMap<>();
+
+ public BattleArenaListener(BattleTracker battleTracker) {
+ this.battleTracker = battleTracker;
+ }
+
+ @EventHandler
+ public void onArenaInitialize(ArenaInitializeEvent event) {
+ Arena arena = event.getArena();
+
+ // Statistic tracking is disabled
+ if (!arena.option(BattleArenaHandler.TRACK_STATISTICS).map(BooleanArenaOption::isEnabled).orElse(true)) {
+ return;
+ }
+
+ Consumer pendingExecutor = this.pendingExecutors.remove(arena);
+ this.battleTracker.registerTracker(
+ event.getArena().getName().toLowerCase(Locale.ROOT),
+ () -> {
+ Tracker tracker = new ArenaTracker(
+ this.battleTracker,
+ arena.getName(),
+ this.battleTracker.getCalculator("elo"),
+ Set.of(TrackedDataType.PVP),
+ List.of()
+ );
+
+ if (pendingExecutor != null) {
+ pendingExecutor.accept(tracker);
+ }
+
+ return tracker;
+ }
+ );
+
+ this.battleTracker.info("Enabled tracking for arena: {}.", arena.getName());
+ }
+
+ @EventHandler
+ public void onCreateExecutor(ArenaCreateExecutorEvent event) {
+ Arena arena = event.getArena();
+
+ // Statistic tracking is disabled
+ if (!arena.option(BattleArenaHandler.TRACK_STATISTICS).map(BooleanArenaOption::isEnabled).orElse(true)) {
+ return;
+ }
+
+ this.pendingExecutors.put(arena, tracker -> event.registerSubExecutor(new TrackerSubExecutor(arena, tracker)));
+ }
+
+ @EventHandler
+ public void onArenaJoin(ArenaPreJoinEvent event) {
+ Tracker tracker = this.battleTracker.getTracker(event.getArena().getName());
+ if (tracker == null) {
+ return;
+ }
+
+ if (this.battleTracker.getCombatLog().isInCombat(event.getPlayer())) {
+ event.setResult(new JoinResult(false, BattleArenaHandler.convertMessage("combat-log-cannot-join-arena")));
+ }
+ }
+
+ @EventHandler
+ public void onArenaLeave(ArenaLeaveEvent event) {
+ // If player leaves or quits, we want to decrement their elo
+ if (event.getCause() != ArenaLeaveEvent.Cause.COMMAND && event.getCause() != ArenaLeaveEvent.Cause.DISCONNECT) {
+ return;
+ }
+
+ // Player is not in an in-game phase, so we don't want to decrement their elo
+ if (!CompetitionPhaseType.INGAME.equals(event.getCompetition().getPhaseManager().getCurrentPhase().getType())) {
+ return;
+ }
+
+ Tracker tracker = this.battleTracker.getTracker(event.getArena().getName());
+ if (tracker == null) {
+ return;
+ }
+
+ Player player = event.getPlayer();
+ Record record = tracker.getRecord(player);
+ if (!record.isTracking()) {
+ return;
+ }
+
+ // Update rating
+ Record[] records = event.getCompetition().getPlayers()
+ .stream()
+ .filter(p -> !p.equals(event.getArenaPlayer()))
+ .map(p -> tracker.getRecord(p.getPlayer()))
+ .toArray(Record[]::new);
+
+ tracker.getRatingCalculator().updateRating(records, new Record[] { record }, false);
+ tracker.setValue(StatType.RATING, record.getRating(), player);
+ }
+
+ @EventHandler
+ public void onArenaKill(ArenaKillEvent event) {
+ Tracker tracker = this.battleTracker.getTracker(event.getArena().getName());
+ if (tracker == null) {
+ return;
+ }
+
+ Player killer = event.getKiller().getPlayer();
+ Player killed = event.getKilled().getPlayer();
+
+ Record killerRecord = tracker.getRecord(killer);
+ Record killedRecord = tracker.getRecord(killed);
+
+ if (killerRecord.isTracking()) {
+ killerRecord.incrementValue(StatType.KILLS);
+ }
+
+ if (killedRecord.isTracking()) {
+ killedRecord.incrementValue(StatType.DEATHS);
+ }
+
+ // Update ratios
+ tracker.setValue(StatType.KD_RATIO, killerRecord.getStat(StatType.KILLS) / Math.max(1, killerRecord.getStat(StatType.DEATHS)), killer);
+ tracker.setValue(StatType.KD_RATIO, killedRecord.getStat(StatType.KILLS) / Math.max(1, killedRecord.getStat(StatType.DEATHS)), killed);
+
+ float killerKdr = killerRecord.getStat(StatType.KD_RATIO);
+ float killerMaxKdr = killerRecord.getStat(StatType.MAX_KD_RATIO);
+
+ if (killerKdr > killerMaxKdr) {
+ tracker.setValue(StatType.MAX_KD_RATIO, killerKdr, killer);
+ }
+
+ tracker.setValue(StatType.STREAK, 0, killed);
+ tracker.incrementValue(StatType.STREAK, killer);
+
+ float killerStreak = killerRecord.getStat(StatType.STREAK);
+ float killerMaxStreak = killerRecord.getStat(StatType.MAX_STREAK);
+
+ if (killerStreak > killerMaxStreak) {
+ tracker.setValue(StatType.MAX_STREAK, killerStreak, killer);
+ }
+ }
+
+ @EventHandler
+ public void onArenaVictory(ArenaVictoryEvent event) {
+ // Development note: ArenaVictoryEvent will always be called in conjunction
+ // with the ArenaLoseEvent, so we can process all our logic here
+
+ if (!(event.getCompetition() instanceof LiveCompetition> liveCompetition)) {
+ return;
+ }
+
+ Tracker tracker = this.battleTracker.getTracker(event.getArena().getName());
+ if (tracker == null) {
+ return;
+ }
+
+ Record[] victorRecords = event.getVictors()
+ .stream()
+ .map(player -> tracker.getRecord(player.getPlayer()))
+ .toArray(Record[]::new);
+
+ Set losers = liveCompetition.getPlayers()
+ .stream()
+ .filter(p -> !event.getVictors().contains(p))
+ .collect(Collectors.toSet());
+
+ Record[] loserRecords = losers.stream()
+ .map(player -> tracker.getRecord(player.getPlayer()))
+ .toArray(Record[]::new);
+
+ // Update ratings
+ tracker.getRatingCalculator().updateRating(victorRecords, loserRecords, false);
+
+ for (ArenaPlayer victor : event.getVictors()) {
+ Player victorPlayer = victor.getPlayer();
+ Record victorRecord = tracker.getRecord(victorPlayer);
+
+ if (!victorRecord.isTracking()) {
+ continue;
+ }
+
+ victorRecord.incrementValue(ArenaTracker.WINS);
+
+ float victorRating = victorRecord.getRating();
+ float victorMaxRating = victorRecord.getStat(StatType.MAX_RATING);
+
+ tracker.setValue(StatType.RATING, victorRecord.getRating(), victorPlayer);
+
+ if (victorRating > victorMaxRating) {
+ tracker.setValue(StatType.MAX_RATING, victorRating, victorPlayer);
+ }
+ }
+
+ for (ArenaPlayer loser : losers) {
+ Player loserPlayer = loser.getPlayer();
+ Record loserRecord = tracker.getRecord(loserPlayer);
+
+ if (!loserRecord.isTracking()) {
+ continue;
+ }
+
+ loserRecord.incrementValue(ArenaTracker.LOSSES);
+
+ float loserRating = loserRecord.getRating();
+ float loserMaxRating = loserRecord.getStat(StatType.MAX_RATING);
+
+ tracker.setValue(StatType.RATING, loserRecord.getRating(), loserPlayer);
+
+ if (loserRating > loserMaxRating) {
+ tracker.setValue(StatType.MAX_RATING, loserRating, loserPlayer);
+ }
+ }
+ }
+
+ @EventHandler
+ public void onDraw(ArenaDrawEvent event) {
+ if (!(event.getCompetition() instanceof LiveCompetition> liveCompetition)) {
+ return;
+ }
+
+ Tracker tracker = this.battleTracker.getTracker(event.getArena().getName());
+ if (tracker == null) {
+ return;
+ }
+
+ Record[] records = liveCompetition.getPlayers()
+ .stream()
+ .map(player -> tracker.getRecord(player.getPlayer()))
+ .toArray(Record[]::new);
+
+ // Update ratings
+ tracker.getRatingCalculator().updateRating(records, true);
+
+ for (ArenaPlayer player : liveCompetition.getPlayers()) {
+ Player bukkitPlayer = player.getPlayer();
+ Record record = tracker.getRecord(bukkitPlayer);
+
+ if (!record.isTracking()) {
+ continue;
+ }
+
+ record.incrementValue(StatType.TIES);
+
+ float victorRating = record.getRating();
+ float victorMaxRating = record.getStat(StatType.MAX_RATING);
+
+ tracker.setValue(StatType.RATING, record.getRating(), bukkitPlayer);
+
+ if (victorRating > victorMaxRating) {
+ tracker.setValue(StatType.MAX_RATING, victorRating, bukkitPlayer);
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/battlearena/TrackerSubExecutor.java b/src/main/java/org/battleplugins/tracker/feature/battlearena/TrackerSubExecutor.java
new file mode 100644
index 0000000..25d3a2a
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/battlearena/TrackerSubExecutor.java
@@ -0,0 +1,96 @@
+package org.battleplugins.tracker.feature.battlearena;
+
+import net.kyori.adventure.text.Component;
+import org.battleplugins.arena.Arena;
+import org.battleplugins.arena.command.ArenaCommand;
+import org.battleplugins.arena.command.Argument;
+import org.battleplugins.arena.command.SubCommandExecutor;
+import org.battleplugins.arena.util.PaginationCalculator;
+import org.battleplugins.tracker.BattleTracker;
+import org.battleplugins.tracker.Tracker;
+import org.battleplugins.tracker.message.Messages;
+import org.battleplugins.tracker.stat.Record;
+import org.battleplugins.tracker.stat.StatType;
+import org.battleplugins.tracker.util.Util;
+import org.bukkit.Bukkit;
+import org.bukkit.OfflinePlayer;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+public class TrackerSubExecutor implements SubCommandExecutor {
+ private final Arena arena;
+ private final Tracker tracker;
+
+ public TrackerSubExecutor(Arena arena, Tracker tracker) {
+ this.arena = arena;
+ this.tracker = tracker;
+ }
+
+ @ArenaCommand(commands = "top", description = "View the top players in this arena.", permissionNode = "top")
+ public void top(Player player) {
+ this.top(player, 5);
+ }
+
+ @ArenaCommand(commands = "top", description = "View the top players in this arena.", permissionNode = "top")
+ public void top(Player player, @Argument(name = "max", description = "The maximum players to show.") int max) {
+ int amount = max <= 0 ? 5 : Math.min(100, max);
+ Util.getSortedRecords(this.tracker, amount, StatType.RATING).whenComplete((records, e) -> {
+ if (records.isEmpty()) {
+ Messages.send(player, "leaderboard-no-entries");
+ return;
+ }
+
+ player.sendMessage(PaginationCalculator.center(Messages.get("header", this.arena.getName()), Component.space()));
+
+ int ranking = 1;
+ for (Map.Entry entry : records.entrySet()) {
+ Record record = entry.getKey();
+
+ Util.sendTrackerMessage(player, "leaderboard-arena", ranking++, record);
+ }
+ });
+ }
+
+ @ArenaCommand(commands = "rank", description = "View the rank of a player.", permissionNode = "rank")
+ public void rank(Player player) {
+ this.rank(player, (String) null);
+ }
+
+ @ArenaCommand(commands = "rank", description = "View the rank of a player.", permissionNode = "rank")
+ public void rank(Player player, @Argument(name = "name", description = "The name of the player.") String playerName) {
+ OfflinePlayer target;
+ if ((playerName == null || playerName.isBlank())) {
+ Messages.send(player, "command-player-not-found", "");
+ return;
+ } else {
+ target = Bukkit.getServer().getOfflinePlayerIfCached(playerName);
+ }
+
+ if (target == null) {
+ CompletableFuture.supplyAsync(() -> Bukkit.getOfflinePlayer(playerName)).thenCompose(this.tracker::getRecord).whenCompleteAsync((record, e) -> {
+ if (record == null) {
+ Messages.send(player, "player-has-no-record", playerName);
+ return;
+ }
+
+ this.rank(player, record);
+ }, Bukkit.getScheduler().getMainThreadExecutor(BattleTracker.getInstance()));
+ } else {
+ this.tracker.getRecord(target).whenCompleteAsync((record, e) -> {
+ if (record == null) {
+ Messages.send(player, "player-has-no-record", playerName);
+ return;
+ }
+
+ this.rank(player, record);
+ }, Bukkit.getScheduler().getMainThreadExecutor(BattleTracker.getInstance()));
+ }
+ }
+
+ private void rank(CommandSender sender, Record record) {
+ Util.sendTrackerMessage(sender, "rank-arena", -1, record);
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/combatlog/CombatLog.java b/src/main/java/org/battleplugins/tracker/feature/combatlog/CombatLog.java
new file mode 100644
index 0000000..567a41c
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/combatlog/CombatLog.java
@@ -0,0 +1,352 @@
+package org.battleplugins.tracker.feature.combatlog;
+
+import net.kyori.adventure.bossbar.BossBar;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.title.TitlePart;
+import org.battleplugins.tracker.BattleTracker;
+import org.battleplugins.tracker.feature.Feature;
+import org.battleplugins.tracker.message.Messages;
+import org.battleplugins.tracker.util.MessageType;
+import org.battleplugins.tracker.util.Util;
+import org.bukkit.NamespacedKey;
+import org.bukkit.Registry;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.EntityType;
+import org.bukkit.entity.Player;
+import org.bukkit.entity.Projectile;
+import org.bukkit.entity.Tameable;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.HandlerList;
+import org.bukkit.event.Listener;
+import org.bukkit.event.entity.EntityDamageByEntityEvent;
+import org.bukkit.event.entity.PlayerDeathEvent;
+import org.bukkit.event.player.PlayerCommandPreprocessEvent;
+import org.bukkit.event.player.PlayerQuitEvent;
+import org.bukkit.metadata.FixedMetadataValue;
+import org.bukkit.metadata.MetadataValue;
+import org.bukkit.scheduler.BukkitTask;
+import org.jetbrains.annotations.Nullable;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public final class CombatLog implements Feature {
+ private final boolean enabled;
+ private final List disabledWorlds;
+ private final int combatTime;
+ private final boolean combatSelf;
+ private final boolean combatEntities;
+ private final boolean combatPlayers;
+ private final @Nullable MessageType displayMethod;
+ private final boolean allowPermissionBypass;
+ private final Set disabledEntities;
+ private final List disabledCommands;
+
+ private CombatLogListener listener;
+
+ public CombatLog(
+ boolean enabled,
+ List disabledWorlds,
+ int combatTime,
+ boolean combatSelf,
+ boolean combatEntities,
+ boolean combatPlayers,
+ @Nullable MessageType displayMethod,
+ boolean allowPermissionBypass,
+ Set disabledEntities,
+ List disabledCommands
+ ) {
+ this.enabled = enabled;
+ this.disabledWorlds = disabledWorlds;
+ this.combatTime = combatTime;
+ this.combatSelf = combatSelf;
+ this.combatEntities = combatEntities;
+ this.combatPlayers = combatPlayers;
+ this.displayMethod = displayMethod;
+ this.allowPermissionBypass = allowPermissionBypass;
+ this.disabledEntities = disabledEntities;
+ this.disabledCommands = disabledCommands;
+ }
+
+ @Override
+ public void onEnable(BattleTracker battleTracker) {
+ battleTracker.getServer().getPluginManager().registerEvents(this.listener = new CombatLogListener(battleTracker, this), battleTracker);
+ }
+
+ @Override
+ public void onDisable(BattleTracker battleTracker) {
+ if (this.listener != null) {
+ this.listener.onUnload();
+ }
+ }
+
+ public boolean isInCombat(Player player) {
+ if (this.listener == null) {
+ return false;
+ }
+
+ return this.listener.combatTasks.containsKey(player);
+ }
+
+ public static CombatLog load(ConfigurationSection section) {
+ boolean enabled = section.getBoolean("enabled");
+ if (!enabled) {
+ return new CombatLog(false, List.of(), 0, false, false, false, MessageType.ACTION_BAR, false, Set.of(), List.of());
+ }
+
+ List disabledWorlds = section.getStringList("disabled-worlds");
+ int combatTime = section.getInt("combat-time");
+ boolean combatSelf = section.getBoolean("combat-self");
+ boolean combatEntities = section.getBoolean("combat-entities");
+ boolean combatPlayers = section.getBoolean("combat-players");
+ MessageType displayMethod = Optional.ofNullable(section.getString("display-method"))
+ .map(method -> MessageType.valueOf(method.toUpperCase(Locale.ROOT)))
+ .orElse(null);
+ boolean allowPermissionBypass = section.getBoolean("allow-permission-bypass");
+
+ Set disabledEntities = section.getStringList("disabled-entities")
+ .stream()
+ .map(key -> Registry.ENTITY_TYPE.get(NamespacedKey.fromString(key)))
+ .collect(Collectors.toSet());
+
+ List disabledCommands = section.getStringList("disabled-commands");
+
+ return new CombatLog(true, disabledWorlds, combatTime, combatSelf, combatEntities, combatPlayers, displayMethod, allowPermissionBypass, disabledEntities, disabledCommands);
+ }
+
+ @Override
+ public boolean enabled() {
+ return this.enabled;
+ }
+
+ private static class CombatLogListener implements Listener {
+ private static final String BOSS_BAR_META_KEY = "combat-log-bar";
+
+ private final Map combatTasks = new HashMap<>();
+
+ private final BattleTracker battleTracker;
+ private final CombatLog combatLog;
+
+ private BukkitTask tickTask;
+
+ private CombatLogListener(BattleTracker battleTracker, CombatLog combatLog) {
+ this.battleTracker = battleTracker;
+ this.combatLog = combatLog;
+
+ if (combatLog.displayMethod == null) {
+ return;
+ }
+
+ this.tickTask = this.battleTracker.getServer().getScheduler().runTaskTimer(this.battleTracker, this::tick, 0L, 20L);
+ }
+
+ public void onUnload() {
+ HandlerList.unregisterAll(this);
+
+ if (this.tickTask != null) {
+ this.tickTask.cancel();
+ }
+ }
+
+ private void tick() {
+ for (Map.Entry entry : this.combatTasks.entrySet()) {
+ Duration remainingTime = Duration.ofSeconds(this.combatLog.combatTime)
+ .minus(Duration.ofMillis(System.currentTimeMillis() - entry.getValue().enteredCombat()))
+ .plusSeconds(1);
+
+ if (remainingTime.isZero() || remainingTime.isNegative()) {
+ continue;
+ }
+
+ Component message = Messages.get("combat-log-remaining-time", Util.toTimeString(remainingTime));
+
+ Player player = entry.getKey();
+ switch (this.combatLog.displayMethod) {
+ case ACTION_BAR -> player.sendActionBar(message);
+ case CHAT -> player.sendMessage(message);
+ case TITLE -> player.sendTitlePart(TitlePart.TITLE, message);
+ case SUBTITLE -> {
+ player.sendTitlePart(TitlePart.TITLE, Component.space());
+ player.sendTitlePart(TitlePart.SUBTITLE, message);
+ }
+ case BOSSBAR -> {
+ player.getMetadata(BOSS_BAR_META_KEY).stream()
+ .map(MetadataValue::value)
+ .filter(value -> value instanceof BossBar)
+ .map(value -> (BossBar) value)
+ .findFirst()
+ .ifPresentOrElse(bar -> {
+ bar.name(message);
+
+ float progress = (float) remainingTime.toSeconds() / (float) this.combatLog.combatTime;
+ bar.progress(progress);
+ }, () -> {
+ BossBar bar = BossBar.bossBar(message, 1.0f, BossBar.Color.BLUE, BossBar.Overlay.PROGRESS);
+ player.showBossBar(bar);
+
+ player.setMetadata(BOSS_BAR_META_KEY, new FixedMetadataValue(this.battleTracker, bar));
+ });
+ }
+ }
+ }
+ }
+
+ @EventHandler(ignoreCancelled = true)
+ public void onEntityDamageByEntity(EntityDamageByEntityEvent event) {
+ if (this.combatLog.disabledWorlds.contains(event.getEntity().getWorld().getName())) {
+ return;
+ }
+
+ if (!(event.getEntity() instanceof Player player)) {
+ return;
+ }
+
+ Entity damager = this.getTrueDamager(event, true);
+ if (damager == null) {
+ return;
+ }
+
+ if (this.combatLog.disabledEntities.contains(damager.getType())) {
+ return;
+ }
+
+ if (damager instanceof Player damagerPlayer) {
+ if (!this.combatLog.combatPlayers) {
+ return;
+ }
+
+ if (damagerPlayer.equals(player) && !this.combatLog.combatSelf) {
+ return;
+ }
+
+ } else if (!this.combatLog.combatEntities) {
+ return;
+ }
+
+ this.enterCombat(player);
+ if (damager instanceof Player damagerPlayer) {
+ this.enterCombat(damagerPlayer);
+ }
+ }
+
+ @EventHandler
+ public void onDeath(PlayerDeathEvent event) {
+ if (this.combatLog.disabledWorlds.contains(event.getEntity().getWorld().getName())) {
+ return;
+ }
+
+ if (event.getPlayer().getLastDamageCause() instanceof EntityDamageByEntityEvent damageEvent) {
+ Entity damager = this.getTrueDamager(damageEvent, false);
+ if (damager instanceof Player damagerPlayer && this.combatTasks.containsKey(damagerPlayer)) {
+ this.exitCombat(damagerPlayer);
+ }
+ }
+
+ if (this.combatTasks.containsKey(event.getEntity())) {
+ this.exitCombat(event.getEntity());
+ }
+ }
+
+ @EventHandler
+ public void onCommandPreProcess(PlayerCommandPreprocessEvent event) {
+ if (!this.combatTasks.containsKey(event.getPlayer())) {
+ return;
+ }
+
+ if (this.combatLog.disabledCommands.stream().anyMatch(cmd -> event.getMessage().startsWith("/" + cmd))) {
+ event.setCancelled(true);
+
+ Messages.send(event.getPlayer(), "combat-log-cannot-run-command");
+ }
+ }
+
+ @EventHandler
+ public void onQuit(PlayerQuitEvent event) {
+ Player player = event.getPlayer();
+ CombatEntry entry = this.combatTasks.get(player);
+ if (entry != null) {
+ entry.task().cancel();
+ this.combatTasks.remove(player);
+
+ // If player has a combat log bypass, exclude them from
+ // losing their items on logout
+ if (this.combatLog.allowPermissionBypass && player.hasPermission("battletracker.combatlog.bypass")) {
+ return;
+ }
+
+ // Kill the player if they log out in combat
+ player.setHealth(0);
+ }
+ }
+
+ private void enterCombat(Player player) {
+ CombatEntry entry = this.combatTasks.get(player);
+ if (entry != null) {
+ entry.task().cancel();
+ } else {
+ Messages.send(player, "combat-log-entered-combat");
+ }
+
+ BukkitTask task = this.battleTracker.getServer().getScheduler().runTaskLater(
+ this.battleTracker,
+ () -> this.exitCombat(player),
+ this.combatLog.combatTime * 20L
+ );
+
+ this.combatTasks.put(player, new CombatEntry(task, System.currentTimeMillis()));
+ }
+
+ private void exitCombat(Player player) {
+ CombatEntry entry = this.combatTasks.remove(player);
+ if (entry == null) {
+ return;
+ }
+
+ entry.task().cancel();
+
+ Messages.send(player, "combat-log-exited-combat");
+
+ // Remove bossbar if it exists
+ player.getMetadata(BOSS_BAR_META_KEY).stream()
+ .map(MetadataValue::value)
+ .filter(value -> value instanceof BossBar)
+ .map(value -> (BossBar) value)
+ .findFirst()
+ .ifPresent(bar -> {
+ player.hideBossBar(bar);
+
+ player.removeMetadata(BOSS_BAR_META_KEY, this.battleTracker);
+ });
+ }
+
+ private Entity getTrueDamager(EntityDamageByEntityEvent event, boolean checkIgnored) {
+ Entity damager = event.getDamager();
+ if (event.getDamager() instanceof Projectile projectile) {
+ if (checkIgnored && this.combatLog.disabledEntities.contains(projectile.getType())) {
+ return null;
+ }
+
+ if (projectile.getShooter() instanceof Entity entity) {
+ damager = entity;
+ }
+ }
+
+ if (damager instanceof Tameable tameable && tameable.isTamed() && tameable.getOwner() instanceof Entity owner) {
+ damager = owner;
+ }
+
+ return damager;
+ }
+
+ public record CombatEntry(BukkitTask task, long enteredCombat) {
+ }
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/damageindicators/DamageIndicators.java b/src/main/java/org/battleplugins/tracker/feature/damageindicators/DamageIndicators.java
new file mode 100644
index 0000000..a7eb46a
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/damageindicators/DamageIndicators.java
@@ -0,0 +1,102 @@
+package org.battleplugins.tracker.feature.damageindicators;
+
+import net.kyori.adventure.text.Component;
+import org.battleplugins.tracker.BattleTracker;
+import org.battleplugins.tracker.feature.Feature;
+import org.battleplugins.tracker.util.MessageUtil;
+import org.battleplugins.tracker.util.Util;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.entity.ArmorStand;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.event.HandlerList;
+import org.bukkit.event.Listener;
+import org.bukkit.event.entity.EntityDamageByEntityEvent;
+import org.bukkit.scheduler.BukkitRunnable;
+
+import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
+
+public record DamageIndicators(
+ boolean enabled,
+ List disabledWorlds,
+ Component format
+) implements Feature {
+
+ @Override
+ public void onEnable(BattleTracker battleTracker) {
+ battleTracker.getServer().getPluginManager().registerEvents(new DamageIndicatorListener(battleTracker, this), battleTracker);
+ }
+
+ @Override
+ public void onDisable(BattleTracker battleTracker) {
+ HandlerList.getRegisteredListeners(battleTracker).stream()
+ .filter(listener -> listener.getListener() instanceof DamageIndicatorListener)
+ .forEach(l -> HandlerList.unregisterAll(l.getListener()));
+ }
+
+ public static DamageIndicators load(ConfigurationSection section) {
+ boolean enabled = section.getBoolean("enabled");
+ if (!enabled) {
+ return new DamageIndicators(false, List.of(), Component.empty());
+ }
+
+ List disabledWorlds = section.getStringList("disabled-worlds");
+ String format = section.getString("format", "");
+ return new DamageIndicators(true, disabledWorlds, MessageUtil.MINI_MESSAGE.deserialize(format));
+ }
+
+ private record DamageIndicatorListener(BattleTracker battleTracker,
+ DamageIndicators damageIndicators) implements Listener {
+
+ @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
+ public void onEntityDamage(EntityDamageByEntityEvent event) {
+ if (!(event.getDamager() instanceof Player damager)) {
+ return;
+ }
+
+ Entity damaged = event.getEntity();
+
+ double xRand = ThreadLocalRandom.current().nextDouble(0.4, 0.7) / (ThreadLocalRandom.current().nextBoolean() ? 1 : -1);
+ double yRand = ThreadLocalRandom.current().nextDouble(0.5, 1.5);
+ double zRand = ThreadLocalRandom.current().nextDouble(0.4, 0.7) / (ThreadLocalRandom.current().nextBoolean() ? 1 : -1);
+
+ ArmorStand indicator = damaged.getWorld().spawn(damaged.getLocation().clone().add(xRand, yRand, zRand), ArmorStand.class, entity -> {
+ entity.customName(this.damageIndicators.format.replaceText(builder ->
+ builder.matchLiteral("{damage}").once().replacement(Component.text(Util.DAMAGE_FORMAT.format(event.getFinalDamage())))
+ ));
+
+ entity.setCustomNameVisible(true);
+ entity.setMarker(true);
+ entity.setPersistent(false);
+ entity.setInvisible(true);
+ entity.setGravity(false);
+ entity.setSmall(true);
+ entity.setVisibleByDefault(false);
+ });
+
+ damager.showEntity(this.battleTracker, indicator);
+
+ new BukkitRunnable() {
+ int counter = 0;
+
+ @Override
+ public void run() {
+ if (counter++ > 40) {
+ indicator.remove();
+ this.cancel();
+ return;
+ }
+
+ indicator.teleport(indicator.getLocation().clone().subtract(0, 0.06, 0));
+ if (indicator.isOnGround()) {
+ indicator.remove();
+ this.cancel();
+ }
+ }
+ }.runTaskTimer(this.battleTracker, 1, 1);
+ }
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/message/DeathMessages.java b/src/main/java/org/battleplugins/tracker/feature/message/DeathMessages.java
new file mode 100644
index 0000000..9fcbead
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/message/DeathMessages.java
@@ -0,0 +1,29 @@
+package org.battleplugins.tracker.feature.message;
+
+import org.battleplugins.tracker.BattleTracker;
+import org.battleplugins.tracker.Tracker;
+import org.battleplugins.tracker.feature.TrackerFeature;
+import org.bukkit.configuration.ConfigurationSection;
+
+public record DeathMessages(
+ boolean enabled,
+ MessageAudience audience,
+ PlayerMessages playerMessages,
+ EntityMessages entityMessages,
+ WorldMessages worldMessages
+) implements TrackerFeature {
+
+ @Override
+ public void onEnable(BattleTracker battleTracker, Tracker tracker) {
+ battleTracker.registerListener(tracker, new DeathMessagesListener(this, tracker));
+ }
+
+ public static DeathMessages load(ConfigurationSection section) {
+ boolean enabled = section.getBoolean("enabled");
+ MessageAudience audience = MessageAudience.get(section.getString("audience"));
+ PlayerMessages playerMessages = PlayerMessages.load(section.getConfigurationSection("player"));
+ EntityMessages entityMessages = EntityMessages.load(section.getConfigurationSection("entity"));
+ WorldMessages worldMessages = WorldMessages.load(section.getConfigurationSection("world"));
+ return new DeathMessages(enabled, audience, playerMessages, entityMessages, worldMessages);
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/message/DeathMessagesListener.java b/src/main/java/org/battleplugins/tracker/feature/message/DeathMessagesListener.java
new file mode 100644
index 0000000..8299205
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/message/DeathMessagesListener.java
@@ -0,0 +1,157 @@
+package org.battleplugins.tracker.feature.message;
+
+import net.kyori.adventure.text.Component;
+import org.battleplugins.tracker.Tracker;
+import org.battleplugins.tracker.event.TrackerDeathEvent;
+import org.battleplugins.tracker.event.feature.DeathMessageEvent;
+import org.battleplugins.tracker.util.ItemCollection;
+import org.battleplugins.tracker.util.Util;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.EntityType;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.event.Listener;
+import org.bukkit.event.entity.EntityDamageEvent;
+import org.bukkit.inventory.ItemStack;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.Map;
+
+public class DeathMessagesListener implements Listener {
+ private final DeathMessages deathMessages;
+ private final Tracker tracker;
+
+ public DeathMessagesListener(DeathMessages deathMessages, Tracker tracker) {
+ this.deathMessages = deathMessages;
+ this.tracker = tracker;
+ }
+
+ @EventHandler(priority = EventPriority.LOWEST)
+ public void onTrackerDeath(TrackerDeathEvent event) {
+ if (!event.getTracker().equals(this.tracker)) {
+ return;
+ }
+
+ if (event.getDeathType() == TrackerDeathEvent.DeathType.PLAYER && this.deathMessages.playerMessages().enabled()) {
+ this.onPlayerDeath(event);
+ } else if (event.getDeathType() == TrackerDeathEvent.DeathType.ENTITY && this.deathMessages.entityMessages().enabled()) {
+ this.onEntityDeath(event);
+ } else if (event.getDeathType() == TrackerDeathEvent.DeathType.WORLD && this.deathMessages.worldMessages().enabled()) {
+ this.onWorldDeath(event);
+ }
+ }
+
+ private void onPlayerDeath(TrackerDeathEvent event) {
+ PlayerMessages messages = this.deathMessages.playerMessages();
+ Player player = event.getDeathEvent().getPlayer();
+
+ // If we've recorded a PVP death, we can assume the killer is a player and make
+ // a few assumptions about the killer player.
+ Player killer = (Player) event.getKiller();
+
+ ItemStack item = killer.getInventory().getItem(killer.getActiveItemHand());
+ Component deathMessage = null;
+ for (Map.Entry> entry : messages.messages().entrySet()) {
+ if (!entry.getKey().contains(item.getType())) {
+ continue;
+ }
+
+ Component message = Util.getRandom(entry.getValue());
+ deathMessage = replace(message, killer, player, item);
+ }
+
+ if (deathMessage == null) {
+ Component defaultMessage = Util.getRandom(messages.defaultMessages());
+ deathMessage = replace(defaultMessage, killer, player, item);
+ }
+
+ event.getDeathEvent().deathMessage(null);
+ broadcastMessage(this.tracker, this.deathMessages.audience(), deathMessage, player, killer);
+ }
+
+ private void onEntityDeath(TrackerDeathEvent event) {
+ EntityMessages messages = this.deathMessages.entityMessages();
+ Player player = event.getDeathEvent().getPlayer();
+
+ // If we've recorded a PVE death, we can assume the killer is a player and make
+ // a few assumptions about the killer player.
+ Entity killer = event.getKiller();
+
+ Component deathMessage = null;
+ for (Map.Entry> entry : messages.messages().entrySet()) {
+ if (entry.getKey() != killer.getType()) {
+ continue;
+ }
+
+ Component message = Util.getRandom(entry.getValue());
+ deathMessage = replace(message, player, player, null);
+ }
+
+ if (deathMessage == null) {
+ Component defaultMessage = Util.getRandom(messages.defaultMessages());
+ deathMessage = replace(defaultMessage, player, player, null);
+ }
+
+ event.getDeathEvent().deathMessage(null);
+ broadcastMessage(this.tracker, this.deathMessages.audience(), deathMessage, player, player);
+ }
+
+ private void onWorldDeath(TrackerDeathEvent event) {
+ WorldMessages messages = this.deathMessages.worldMessages();
+ Player player = event.getDeathEvent().getPlayer();
+
+ EntityDamageEvent lastDamageCause = player.getLastDamageCause();
+ EntityDamageEvent.DamageCause cause = lastDamageCause == null ? null : lastDamageCause.getCause();
+
+ Component deathMessage = null;
+ for (Map.Entry> entry : messages.messages().entrySet()) {
+ if (entry.getKey() != cause) {
+ continue;
+ }
+
+ Component message = Util.getRandom(entry.getValue());
+ deathMessage = replace(message, player, player, null);
+ }
+
+ if (deathMessage == null) {
+ Component defaultMessage = Util.getRandom(messages.defaultMessages());
+ deathMessage = replace(defaultMessage, player, player, null);
+ }
+
+ event.getDeathEvent().deathMessage(null);
+ broadcastMessage(this.tracker, this.deathMessages.audience(), deathMessage, player, player);
+ }
+
+ private static void broadcastMessage(Tracker tracker, MessageAudience audience, Component message, Player player, Player target) {
+ DeathMessageEvent event = new DeathMessageEvent(player, message, tracker);
+ event.callEvent();
+
+ if (event.getDeathMessage().equals(Component.empty())) {
+ return;
+ }
+
+ audience.broadcastMessage(event.getDeathMessage(), player, target);
+ }
+
+ private static Component replace(Component component, Entity player, Entity target, @Nullable ItemStack item) {
+ component = component.replaceText(builder -> builder.matchLiteral("%player%").once().replacement(player.name()));
+ component = component.replaceText(builder -> builder.matchLiteral("%target%").once().replacement(target.name()));
+ if (item != null) {
+ Component itemName;
+ if (item.hasItemMeta() && item.getItemMeta().hasDisplayName()) {
+ itemName = item.getItemMeta().displayName();
+ } else {
+ itemName = Component.translatable(item.getType());
+ }
+
+ itemName = itemName.hoverEvent(item.asHoverEvent());
+
+ Component finalItemName = itemName;
+ component = component.replaceText(builder -> builder.matchLiteral("%item%").once().replacement(finalItemName));
+ }
+
+ return component;
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/message/EntityMessages.java b/src/main/java/org/battleplugins/tracker/feature/message/EntityMessages.java
new file mode 100644
index 0000000..cb20abe
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/message/EntityMessages.java
@@ -0,0 +1,51 @@
+package org.battleplugins.tracker.feature.message;
+
+import net.kyori.adventure.text.Component;
+import org.battleplugins.tracker.util.MessageUtil;
+import org.bukkit.NamespacedKey;
+import org.bukkit.Registry;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.entity.EntityType;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public record EntityMessages(
+ boolean enabled,
+ Map> messages,
+ List defaultMessages
+) {
+
+ public static EntityMessages load(ConfigurationSection section) {
+ boolean enabled = section.getBoolean("enabled");
+ if (!enabled) {
+ return new EntityMessages(false, Map.of(), List.of());
+ }
+
+ Map> messages = new HashMap<>();
+ List defaultMessages = section.getStringList("messages.default")
+ .stream()
+ .map(MessageUtil::deserialize)
+ .toList();
+
+ ConfigurationSection messagesSection = section.getConfigurationSection("messages");
+ messagesSection.getKeys(false).forEach(key -> {
+ if (!messagesSection.isList(key)) {
+ throw new IllegalArgumentException("Section " + key + " is not a list of messages!");
+ }
+
+ if (key.equalsIgnoreCase("default")) {
+ return;
+ }
+
+ List messageList = messagesSection.getStringList(key);
+ messages.put(Registry.ENTITY_TYPE.get(NamespacedKey.fromString(key)), messageList.stream()
+ .map(MessageUtil::deserialize)
+ .collect(Collectors.toList()));
+ });
+
+ return new EntityMessages(true, messages, defaultMessages);
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/message/MessageAudience.java b/src/main/java/org/battleplugins/tracker/feature/message/MessageAudience.java
new file mode 100644
index 0000000..f99522e
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/message/MessageAudience.java
@@ -0,0 +1,69 @@
+package org.battleplugins.tracker.feature.message;
+
+import net.kyori.adventure.text.Component;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+/**
+ * Represents a message audience.
+ */
+public final class MessageAudience {
+ private static final Map AUDIENCES = new HashMap<>();
+
+ public static final MessageAudience GLOBAL = new MessageAudience("global", player -> List.copyOf(Bukkit.getOnlinePlayers()));
+ public static final MessageAudience WORLD = new MessageAudience("world", player -> List.copyOf(player.getWorld().getPlayers()));
+ public static final MessageAudience LOCAL = new MessageAudience("local", List::of);
+
+ private final String name;
+ private final Function> audienceProvider;
+
+ public MessageAudience(String name, Function> audienceProvider) {
+ this.name = name;
+ this.audienceProvider = audienceProvider;
+
+ AUDIENCES.put(name, this);
+ }
+
+ public String getName() {
+ return this.name;
+ }
+
+ public List getAudience(Player player) {
+ return this.audienceProvider.apply(player);
+ }
+
+ public void broadcastMessage(Component message, Player player, @Nullable Player target) {
+ List viewers;
+
+ // Special logic for target, since we need the context from
+ // the death event logic earlier
+ if (this == MessageAudience.LOCAL) {
+ if (target == null) {
+ viewers = List.of(player);
+ } else {
+ viewers = List.of(player, target);
+ }
+ } else {
+ viewers = this.getAudience(player);
+ }
+
+ for (Player viewer : viewers) {
+ viewer.sendMessage(message);
+ }
+ }
+
+ public static MessageAudience create(String name, Function> audienceProvider) {
+ return new MessageAudience(name, audienceProvider);
+ }
+
+ @Nullable
+ public static MessageAudience get(String name) {
+ return AUDIENCES.get(name);
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/message/PlayerMessages.java b/src/main/java/org/battleplugins/tracker/feature/message/PlayerMessages.java
new file mode 100644
index 0000000..a052a21
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/message/PlayerMessages.java
@@ -0,0 +1,49 @@
+package org.battleplugins.tracker.feature.message;
+
+import net.kyori.adventure.text.Component;
+import org.battleplugins.tracker.util.ItemCollection;
+import org.battleplugins.tracker.util.MessageUtil;
+import org.bukkit.configuration.ConfigurationSection;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public record PlayerMessages(
+ boolean enabled,
+ Map> messages,
+ List defaultMessages
+) {
+
+ public static PlayerMessages load(ConfigurationSection section) {
+ boolean enabled = section.getBoolean("enabled");
+ if (!enabled) {
+ return new PlayerMessages(false, Map.of(), List.of());
+ }
+
+ Map> messages = new HashMap<>();
+ List defaultMessages = section.getStringList("messages.default")
+ .stream()
+ .map(MessageUtil::deserialize)
+ .toList();
+
+ ConfigurationSection messagesSection = section.getConfigurationSection("messages");
+ messagesSection.getKeys(false).forEach(key -> {
+ if (!messagesSection.isList(key)) {
+ throw new IllegalArgumentException("Section " + key + " is not a list of messages!");
+ }
+
+ if (key.equalsIgnoreCase("default")) {
+ return;
+ }
+
+ List messageList = messagesSection.getStringList(key);
+ messages.put(ItemCollection.fromString(key), messageList.stream()
+ .map(MessageUtil::deserialize)
+ .collect(Collectors.toList()));
+ });
+
+ return new PlayerMessages(true, messages, defaultMessages);
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/message/WorldMessages.java b/src/main/java/org/battleplugins/tracker/feature/message/WorldMessages.java
new file mode 100644
index 0000000..4e841d3
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/message/WorldMessages.java
@@ -0,0 +1,50 @@
+package org.battleplugins.tracker.feature.message;
+
+import net.kyori.adventure.text.Component;
+import org.battleplugins.tracker.util.MessageUtil;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.event.entity.EntityDamageEvent;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public record WorldMessages(
+ boolean enabled,
+ Map> messages,
+ List defaultMessages
+) {
+
+ public static WorldMessages load(ConfigurationSection section) {
+ boolean enabled = section.getBoolean("enabled");
+ if (!enabled) {
+ return new WorldMessages(false, Map.of(), List.of());
+ }
+
+ Map> messages = new HashMap<>();
+ List defaultMessages = section.getStringList("messages.default")
+ .stream()
+ .map(MessageUtil::deserialize)
+ .toList();
+
+ ConfigurationSection messagesSection = section.getConfigurationSection("messages");
+ messagesSection.getKeys(false).forEach(key -> {
+ if (!messagesSection.isList(key)) {
+ throw new IllegalArgumentException("Section " + key + " is not a list of messages!");
+ }
+
+ if (key.equalsIgnoreCase("default")) {
+ return;
+ }
+
+ List messageList = messagesSection.getStringList(key);
+ messages.put(EntityDamageEvent.DamageCause.valueOf(key.toUpperCase(Locale.ROOT)), messageList.stream()
+ .map(MessageUtil::deserialize)
+ .collect(Collectors.toList()));
+ });
+
+ return new WorldMessages(true, messages, defaultMessages);
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/recap/BattleRecap.java b/src/main/java/org/battleplugins/tracker/feature/recap/BattleRecap.java
new file mode 100644
index 0000000..653e114
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/recap/BattleRecap.java
@@ -0,0 +1,145 @@
+package org.battleplugins.tracker.feature.recap;
+
+import org.bukkit.Material;
+import org.bukkit.entity.Player;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.PlayerInventory;
+import org.jetbrains.annotations.Nullable;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+public class BattleRecap {
+ private final List entries = new ArrayList<>();
+ private final double startingHealth;
+ private final Instant createTime;
+ private final String recapOwner;
+
+ private PlayerInventory inventory;
+ private ItemStack[] inventorySnapshot;
+
+ public BattleRecap(Player player) {
+ this(player.getInventory(), player.getHealth(), player.getName());
+ }
+
+ public BattleRecap(PlayerInventory inventory, double startingHealth, String recapOwner) {
+ this.inventory = inventory;
+ this.startingHealth = startingHealth;
+ this.recapOwner = recapOwner;
+ this.createTime = Instant.now();
+ }
+
+ public void record(RecapEntry entry) {
+ this.entries.add(entry);
+ }
+
+ public Optional lastEntry() {
+ return Optional.ofNullable(this.getLastEntry());
+ }
+
+ @Nullable
+ public RecapEntry getLastEntry() {
+ if (this.entries.isEmpty()) {
+ return null;
+ }
+
+ return this.entries.get(this.entries.size() - 1);
+ }
+
+ public List getEntries() {
+ return List.copyOf(this.entries);
+ }
+
+ public List getCombinedEntries() {
+ List entries = new ArrayList<>();
+ RecapEntry lastEntry = null;
+ for (RecapEntry entry : this.entries) {
+ // We only want to combine the health gain entries, since they can get excessive
+ if (entry.kind() == RecapEntry.Kind.LOSS) {
+ if (lastEntry != null) {
+ entries.add(lastEntry);
+ lastEntry = null;
+ }
+
+ entries.add(entry);
+ continue;
+ }
+
+ if (lastEntry != null) {
+ if (lastEntry.kind() == RecapEntry.Kind.GAIN) {
+ lastEntry = lastEntry.toBuilder()
+ .amount(lastEntry.amount() + entry.amount())
+ .logTime(entry.logTime())
+ .build();
+ } else {
+ lastEntry = entry;
+ }
+ } else {
+ lastEntry = entry;
+ }
+ }
+
+ if (lastEntry != null) {
+ entries.add(lastEntry);
+ }
+
+ return entries;
+ }
+
+ public double getStartingHealth() {
+ return this.startingHealth;
+ }
+
+ public String getRecapOwner() {
+ return this.recapOwner;
+ }
+
+ public Instant getCreateTime() {
+ return this.createTime;
+ }
+
+ public void markDeath() {
+ this.inventorySnapshot = this.getInventorySnapshot();
+ this.inventory = null;
+ }
+
+ public ItemStack[] getInventorySnapshot() {
+ if (this.inventorySnapshot != null) {
+ return this.inventorySnapshot;
+ }
+
+ ItemStack[] contents = this.inventory.getStorageContents();
+
+ // Size is contents + armor + main & offhand
+ ItemStack[] snapshot = new ItemStack[contents.length + 6];
+
+ // Main inventory contents
+ for (int i = 0; i < contents.length; i++) {
+ ItemStack item = contents[i];
+ if (item != null) {
+ snapshot[i] = item.clone();
+ }
+ }
+
+ // Armor contents
+ snapshot[contents.length] = nullify(this.inventory.getHelmet());
+ snapshot[contents.length + 1] = nullify(this.inventory.getChestplate());
+ snapshot[contents.length + 2] = nullify(this.inventory.getLeggings());
+ snapshot[contents.length + 3] = nullify(this.inventory.getBoots());
+
+ // Hand + offhand contents
+ snapshot[contents.length + 4] = nullify(this.inventory.getItemInMainHand());
+ snapshot[contents.length + 5] = nullify(this.inventory.getItemInOffHand());
+ return snapshot;
+ }
+
+ private static ItemStack nullify(ItemStack item) {
+ if (item != null && item.getType() == Material.AIR) {
+ return null;
+ }
+
+ return item;
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/recap/EntitySnapshot.java b/src/main/java/org/battleplugins/tracker/feature/recap/EntitySnapshot.java
new file mode 100644
index 0000000..3c42c01
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/recap/EntitySnapshot.java
@@ -0,0 +1,13 @@
+package org.battleplugins.tracker.feature.recap;
+
+import net.kyori.adventure.text.Component;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.EntityType;
+import org.bukkit.entity.Player;
+
+public record EntitySnapshot(EntityType type, Component displayedName) {
+
+ public EntitySnapshot(Entity entity) {
+ this(entity.getType(), entity instanceof Player player ? player.name() : Component.translatable(entity.getType()));
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/recap/Recap.java b/src/main/java/org/battleplugins/tracker/feature/recap/Recap.java
new file mode 100644
index 0000000..362c5bc
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/recap/Recap.java
@@ -0,0 +1,287 @@
+package org.battleplugins.tracker.feature.recap;
+
+import net.kyori.adventure.audience.Audience;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.JoinConfiguration;
+import net.kyori.adventure.text.event.ClickCallback;
+import net.kyori.adventure.text.event.ClickEvent;
+import net.kyori.adventure.text.event.HoverEvent;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.TextDecoration;
+import org.battleplugins.tracker.BattleTracker;
+import org.battleplugins.tracker.TrackedDataType;
+import org.battleplugins.tracker.Tracker;
+import org.battleplugins.tracker.event.feature.DeathMessageEvent;
+import org.battleplugins.tracker.feature.TrackerFeature;
+import org.battleplugins.tracker.message.Messages;
+import org.battleplugins.tracker.util.TrackerInventoryHolder;
+import org.battleplugins.tracker.util.Util;
+import org.bukkit.Material;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.entity.EntityType;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.entity.EntityRegainHealthEvent;
+import org.bukkit.event.entity.PlayerDeathEvent;
+import org.bukkit.event.player.PlayerQuitEvent;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.persistence.PersistentDataType;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+public record Recap(
+ boolean enabled,
+ DisplayContent displayContent,
+ boolean hoverRecap,
+ Map previousRecaps,
+ Map recaps
+) implements TrackerFeature {
+
+ public Recap(boolean enabled, DisplayContent displayContent, boolean hoverRecap) {
+ this(enabled, displayContent, hoverRecap, new HashMap<>(), new HashMap<>());
+ }
+
+ public static Recap load(ConfigurationSection section) {
+ return new Recap(
+ section.getBoolean("enabled", true),
+ DisplayContent.valueOf(section.getString("display-content", "ALL").toUpperCase(Locale.ROOT)),
+ section.getBoolean("hover-recap", true)
+ );
+ }
+
+ @Override
+ public void onEnable(BattleTracker battleTracker, Tracker tracker) {
+ battleTracker.registerListener(tracker, new RecapListener(tracker, this));
+ }
+
+ public BattleRecap getRecap(Player player) {
+ return this.recaps.computeIfAbsent(player.getUniqueId(), uuid -> new BattleRecap(player));
+ }
+
+ public Optional previousRecap(Player player) {
+ return Optional.ofNullable(this.getPreviousRecap(player));
+ }
+
+ @Nullable
+ public BattleRecap getPreviousRecap(Player player) {
+ return this.previousRecaps.get(player.getUniqueId());
+ }
+
+ @NotNull
+ public static ItemStack getRecapItem(Duration deathDuration, BattleRecap battleRecap) {
+ ItemStack recapItem = new ItemStack(Material.BOOK);
+ recapItem.editMeta(meta -> {
+ meta.displayName(Messages.get("recap-info").decoration(TextDecoration.ITALIC, false));
+
+ List lore = new ArrayList<>();
+ lore.add(Messages.get("recap-death-time", Util.toTimeStringShort(deathDuration)));
+ lore.add(Messages.get("recap-starting-health", Util.formatHealth(battleRecap.getStartingHealth(), false)));
+ lore.add(Component.empty());
+ lore.add(Messages.get("recap-damage-log"));
+
+ processRecapEntry(battleRecap.getCombinedEntries(), 10, true, lore::add);
+
+ lore.add(Component.empty());
+ lore.add(Messages.get("recap-click-for-more"));
+
+ // Make sure the lore lines are not italic
+ ListIterator itr = lore.listIterator();
+ while (itr.hasNext()) {
+ itr.set(itr.next().decoration(TextDecoration.ITALIC, false));
+ }
+
+ meta.lore(lore);
+ meta.getPersistentDataContainer().set(TrackerInventoryHolder.RECAP_KEY, PersistentDataType.BOOLEAN, true);
+ });
+
+ return recapItem;
+ }
+
+ public static void processRecapEntry(List entries, int max, boolean showItem, Consumer componentConsumer) {
+ for (int i = entries.size() - 1; i >= Math.max(0, entries.size() - max); i--) {
+ RecapEntry entry = entries.get(i);
+ ItemStack item = entry.itemUsed();
+
+ // TODO: Translation support
+ Component cause = null;
+ if (entry.causingEntity() != null) {
+ cause = entry.causingEntity().displayedName();
+ if (entry.sourceEntity() != null && !entry.sourceEntity().equals(entry.causingEntity())) {
+ cause = entry.sourceEntity().displayedName()
+ .append(Component.text(" ("))
+ .append(entry.causingEntity().displayedName())
+ .append(Component.text(")"));
+ } else if (showItem && item != null && entry.causingEntity().type() == EntityType.PLAYER) {
+ Component itemName;
+ if (item.hasItemMeta() && item.getItemMeta().hasDisplayName()) {
+ itemName = item.getItemMeta().displayName();
+ } else {
+ itemName = Component.translatable(item.getType());
+ }
+
+ cause = cause.append(Component.text(" (").append(itemName).append(Component.text(")")));
+ }
+ }
+
+ if (cause == null && entry.damageCause() != null) {
+ cause = Component.text(Util.capitalize(entry.damageCause().name().toLowerCase(Locale.ROOT).replace("_", " ")));
+ }
+
+ boolean loss = entry.kind() == RecapEntry.Kind.LOSS;
+ Component recapLog = Messages.get("recap-log", Map.of(
+ "health", Component.text(Util.formatHealth(entry.amount(), loss), loss ? NamedTextColor.RED : NamedTextColor.GREEN),
+ "time", Util.toTimeStringShort(Duration.between(entry.logTime(), Instant.now().plusSeconds(1))),
+ "damage_cause", cause == null ? Component.empty() : cause
+ ));
+
+ componentConsumer.accept(recapLog);
+ }
+ }
+
+ public void showRecap(Audience audience, Tracker tracker, BattleRecap battleRecap) {
+ Duration deathDuration = Duration.between(battleRecap.getLastEntry().logTime(), Instant.now());
+
+ ItemStack recapItem = Recap.getRecapItem(deathDuration, battleRecap);
+
+ Inventory inventory = TrackerInventoryHolder.create(TrackerInventoryHolder.RECAP_KEY, tracker, 54, Messages.get("recap"), handler -> {
+ handler.onClick(TrackerInventoryHolder.RECAP_KEY, () -> {
+ if (tracker.tracksData(TrackedDataType.PVP)) {
+ RecapRoundup.recapPlayer(audience, battleRecap);
+ } else if (tracker.tracksData(TrackedDataType.PVE)) {
+ RecapRoundup.recapEntity(audience, battleRecap);
+ } else {
+ RecapRoundup.recapSource(audience, battleRecap);
+ }
+
+ RecapRoundup.sendFooter(audience, tracker, battleRecap);
+ });
+ });
+ Recap.DisplayContent displayContent = this.displayContent;
+ if (displayContent == Recap.DisplayContent.ALL) {
+ if (!(audience instanceof Player senderPlayer)) {
+ Messages.send(audience, "command-must-be-player");
+ return;
+ }
+
+ ItemStack[] snapshot = battleRecap.getInventorySnapshot();
+ for (int i = 0; i < snapshot.length; i++) {
+ inventory.setItem(i, snapshot[i]);
+ }
+
+ inventory.setItem(52, recapItem);
+
+ senderPlayer.openInventory(inventory);
+ } else if (displayContent == Recap.DisplayContent.ARMOR) {
+ if (!(audience instanceof Player senderPlayer)) {
+ Messages.send(audience, "command-must-be-player");
+ return;
+ }
+
+ ItemStack empty = new ItemStack(Material.BONE);
+ empty.editMeta(meta -> meta.displayName(Component.empty()));
+
+ ItemStack[] snapshot = battleRecap.getInventorySnapshot();
+ inventory.setItem(13, Optional.ofNullable(snapshot[36]).orElse(empty));
+ inventory.setItem(22, Optional.ofNullable(snapshot[37]).orElse(empty));
+ inventory.setItem(31, Optional.ofNullable(snapshot[38]).orElse(empty));
+ inventory.setItem(40, Optional.ofNullable(snapshot[39]).orElse(empty));
+
+ inventory.setItem(21, Optional.ofNullable(snapshot[40]).orElse(empty));
+ inventory.setItem(23, Optional.ofNullable(snapshot[41]).orElse(empty));
+
+ inventory.setItem(25, recapItem);
+
+ senderPlayer.openInventory(inventory);
+ } else if (displayContent == Recap.DisplayContent.RECAP) {
+ // TODO: Send paginated chat message
+ }
+ }
+
+ public enum DisplayContent {
+ ALL,
+ ARMOR,
+ RECAP
+ }
+
+ private record RecapListener(Tracker tracker, Recap recap) implements Listener {
+
+ @EventHandler
+ public void onQuit(PlayerQuitEvent event) {
+ this.recap.recaps.remove(event.getPlayer().getUniqueId());
+ }
+
+ @EventHandler
+ public void onDeath(PlayerDeathEvent event) {
+ Player player = event.getEntity();
+ BattleRecap recap = this.recap.getRecap(player);
+ recap.markDeath();
+
+ this.recap.previousRecaps.put(player.getUniqueId(), recap);
+ this.recap.recaps.remove(player.getUniqueId());
+ }
+
+ @EventHandler
+ public void onHealthRegain(EntityRegainHealthEvent event) {
+ if (!(event.getEntity() instanceof Player player)) {
+ return;
+ }
+
+ BattleRecap recap = this.recap.getRecap(player);
+ recap.record(RecapEntry.builder()
+ .amount(event.getAmount())
+ .logTime(Instant.now())
+ .kind(RecapEntry.Kind.GAIN)
+ .build());
+ }
+
+ @EventHandler
+ public void onDeathMessage(DeathMessageEvent event) {
+ if (!this.tracker.equals(event.getTracker())) {
+ return;
+ }
+
+ if (!this.recap.hoverRecap) {
+ return;
+ }
+
+ Component deathMessage = event.getDeathMessage();
+ if (deathMessage == null) {
+ return;
+ }
+
+ BattleRecap recap = this.recap.previousRecaps.get(event.getPlayer().getUniqueId());
+ if (recap == null) {
+ return;
+ }
+
+ List lines = new ArrayList<>();
+ processRecapEntry(recap.getCombinedEntries(), 5, false, lines::add);
+
+ deathMessage = deathMessage.hoverEvent(
+ HoverEvent.showText(Messages.get("recap-damage-log")
+ .append(Component.newline())
+ .append(Component.join(JoinConfiguration.newlines(), lines))
+ .append(Component.newline())
+ .append(Component.newline())
+ .append(Messages.get("recap-click-for-more"))
+ ));
+
+ deathMessage = deathMessage.clickEvent(ClickEvent.callback(audience -> this.recap.showRecap(audience, this.tracker, recap), builder -> builder.lifetime(Duration.ofMinutes(5)).uses(ClickCallback.UNLIMITED_USES)));
+ event.setDeathMessage(deathMessage);
+ }
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/recap/RecapEntry.java b/src/main/java/org/battleplugins/tracker/feature/recap/RecapEntry.java
new file mode 100644
index 0000000..2bbaf78
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/recap/RecapEntry.java
@@ -0,0 +1,102 @@
+package org.battleplugins.tracker.feature.recap;
+
+import org.bukkit.entity.Entity;
+import org.bukkit.event.entity.EntityDamageEvent;
+import org.bukkit.inventory.ItemStack;
+import org.jetbrains.annotations.Nullable;
+
+import java.time.Instant;
+
+public record RecapEntry(
+ @Nullable EntityDamageEvent.DamageCause damageCause,
+ double amount,
+ @Nullable EntitySnapshot causingEntity,
+ @Nullable EntitySnapshot sourceEntity,
+ @Nullable ItemStack itemUsed,
+ Instant logTime,
+ Kind kind
+) {
+
+ public RecapEntry(EntityDamageEvent.DamageCause damageCause, double amount, Entity causingEntity, Entity sourceEntity, ItemStack itemUsed, Instant logTime, Kind kind) {
+ this(damageCause, amount, new EntitySnapshot(causingEntity), new EntitySnapshot(sourceEntity), itemUsed, logTime, kind);
+ }
+
+ public Builder toBuilder() {
+ return new Builder()
+ .damageCause(this.damageCause)
+ .amount(this.amount)
+ .causingEntity(this.causingEntity)
+ .sourceEntity(this.sourceEntity)
+ .itemUsed(this.itemUsed)
+ .logTime(this.logTime)
+ .kind(this.kind);
+ }
+
+ public enum Kind {
+ GAIN,
+ LOSS
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private EntityDamageEvent.DamageCause damageCause;
+ private double amount;
+ private EntitySnapshot causingEntity;
+ private EntitySnapshot sourceEntity;
+ private ItemStack itemUsed;
+ private Instant logTime;
+ private Kind kind = Kind.LOSS;
+
+ public Builder damageCause(EntityDamageEvent.DamageCause cause) {
+ this.damageCause = cause;
+ return this;
+ }
+
+ public Builder amount(double amount) {
+ this.amount = amount;
+ return this;
+ }
+
+ public Builder causingEntity(Entity causingEntity) {
+ this.causingEntity = new EntitySnapshot(causingEntity);
+ return this;
+ }
+
+ public Builder causingEntity(EntitySnapshot causingEntity) {
+ this.causingEntity = causingEntity;
+ return this;
+ }
+
+ public Builder sourceEntity(Entity sourceEntity) {
+ this.sourceEntity = new EntitySnapshot(sourceEntity);
+ return this;
+ }
+
+ public Builder sourceEntity(EntitySnapshot sourceEntity) {
+ this.sourceEntity = sourceEntity;
+ return this;
+ }
+
+ public Builder itemUsed(ItemStack itemUsed) {
+ this.itemUsed = itemUsed;
+ return this;
+ }
+
+ public Builder logTime(Instant logTime) {
+ this.logTime = logTime;
+ return this;
+ }
+
+ public Builder kind(Kind kind) {
+ this.kind = kind;
+ return this;
+ }
+
+ public RecapEntry build() {
+ return new RecapEntry(this.damageCause, this.amount, this.causingEntity, this.sourceEntity, this.itemUsed, this.logTime, this.kind);
+ }
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/feature/recap/RecapRoundup.java b/src/main/java/org/battleplugins/tracker/feature/recap/RecapRoundup.java
new file mode 100644
index 0000000..21887b3
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/feature/recap/RecapRoundup.java
@@ -0,0 +1,232 @@
+package org.battleplugins.tracker.feature.recap;
+
+import it.unimi.dsi.fastutil.Hash;
+import it.unimi.dsi.fastutil.objects.Object2ObjectOpenCustomHashMap;
+import net.kyori.adventure.audience.Audience;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.TranslatableComponent;
+import org.battleplugins.tracker.TrackedDataType;
+import org.battleplugins.tracker.Tracker;
+import org.battleplugins.tracker.message.Messages;
+import org.battleplugins.tracker.util.Util;
+import org.bukkit.Material;
+import org.bukkit.entity.EntityType;
+import org.bukkit.event.entity.EntityDamageEvent;
+import org.bukkit.inventory.ItemStack;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+public class RecapRoundup {
+
+ public static void recapItem(Audience sender, BattleRecap recap) {
+ Messages.send(sender, "header", Messages.getPlain("recap-damage-item"));
+ Map> entriesByStack = new Object2ObjectOpenCustomHashMap<>(new Hash.Strategy<>() {
+
+ @Override
+ public int hashCode(ItemStack o) {
+ if (o.getType() == Material.AIR) {
+ return o.getType().hashCode();
+ }
+
+ int hash = 1;
+
+ hash = hash * 31 + o.getType().hashCode();
+ hash = hash * 31 + (o.hasItemMeta() ? (o.getItemMeta().hashCode()) : 0);
+ return hash;
+ }
+
+ @Override
+ public boolean equals(ItemStack a, ItemStack b) {
+ if (a == null && b == null) {
+ return true;
+ }
+
+ if (a == null && b.getType() == Material.AIR) {
+ return true;
+ }
+
+ if (a != null && a.getType() == Material.AIR && b == null) {
+ return true;
+ }
+
+ if (a != null && b != null && a.getType() == Material.AIR && b.getType() == Material.AIR) {
+ return true;
+ }
+
+ return a != null && a.isSimilar(b);
+ }
+ });
+
+ for (RecapEntry entry : recap.getEntries()) {
+ if (entry.itemUsed() == null || entry.kind() != RecapEntry.Kind.LOSS) {
+ continue;
+ }
+
+ entriesByStack.computeIfAbsent(entry.itemUsed(), k -> new ArrayList<>()).add(entry);
+ }
+
+ for (Map.Entry> entry : entriesByStack.entrySet()) {
+ double damage = entry.getValue().stream().mapToDouble(RecapEntry::amount).sum();
+ if (damage == 0) {
+ continue;
+ }
+
+ TranslatableComponent itemComponent = Component.translatable(entry.getKey());
+ if (entry.getKey().getType() != Material.AIR) {
+ itemComponent = itemComponent.hoverEvent(entry.getKey());
+ }
+
+ Messages.send(sender, "recap-log-item", Map.of(
+ "item", itemComponent,
+ "hits", Integer.toString(entry.getValue().size()),
+ "damage", Util.HEALTH_FORMAT.format(damage)
+ ));
+ }
+
+ Messages.send(sender, "recap-log-general", Map.of(
+ "time", Util.toTimeStringShort(Duration.between(recap.getCreateTime(), recap.getLastEntry().logTime())),
+ "health", Util.formatHealth(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.GAIN).mapToDouble(RecapEntry::amount).sum(), false),
+ "damage", Util.HEALTH_FORMAT.format(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.LOSS).mapToDouble(RecapEntry::amount).sum())
+ ));
+ }
+
+ public static void recapPlayer(Audience sender, BattleRecap recap) {
+ Messages.send(sender, "header", Messages.getPlain("recap-damage-player"));
+ Map> entriesByStack = new HashMap<>();
+
+ for (RecapEntry entry : recap.getEntries()) {
+ if (entry.kind() != RecapEntry.Kind.LOSS) {
+ continue;
+ }
+
+ if (entry.causingEntity() == null) {
+ continue;
+ }
+
+ entriesByStack.computeIfAbsent(entry.causingEntity(), k -> new ArrayList<>()).add(entry);
+ }
+
+ for (Map.Entry> entry : entriesByStack.entrySet()) {
+ if (entry.getKey().type() != EntityType.PLAYER) {
+ continue;
+ }
+
+ double damage = entry.getValue().stream().mapToDouble(RecapEntry::amount).sum();
+ if (damage == 0) {
+ continue;
+ }
+
+ Messages.send(sender, "recap-log-player", Map.of(
+ "player", entry.getKey().displayedName(),
+ "hits", Integer.toString(entry.getValue().size()),
+ "damage", Util.HEALTH_FORMAT.format(damage)
+ ));
+ }
+
+ Messages.send(sender, "recap-log-general", Map.of(
+ "time", Util.toTimeStringShort(Duration.between(recap.getCreateTime(), recap.getLastEntry().logTime())),
+ "health", Util.formatHealth(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.GAIN).mapToDouble(RecapEntry::amount).sum(), false),
+ "damage", Util.HEALTH_FORMAT.format(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.LOSS).mapToDouble(RecapEntry::amount).sum())
+ ));
+ }
+
+ public static void recapEntity(Audience sender, BattleRecap recap) {
+ Messages.send(sender, "header", Messages.getPlain("recap-damage-entity"));
+ Map> entriesByStack = new HashMap<>();
+
+ for (RecapEntry entry : recap.getEntries()) {
+ if (entry.kind() != RecapEntry.Kind.LOSS) {
+ continue;
+ }
+
+ if (entry.causingEntity() == null) {
+ continue;
+ }
+
+ entriesByStack.computeIfAbsent(entry.causingEntity(), k -> new ArrayList<>()).add(entry);
+ }
+
+ for (Map.Entry> entry : entriesByStack.entrySet()) {
+ double damage = entry.getValue().stream().mapToDouble(RecapEntry::amount).sum();
+ if (damage == 0) {
+ continue;
+ }
+
+ Component entityComponent = entry.getKey().displayedName();
+ if (entry.getKey().type() == EntityType.PLAYER) {
+ entityComponent = entityComponent.append(Component.text(" ("))
+ .append(Component.translatable(entry.getKey().type()))
+ .append(Component.text(")"));
+ }
+
+ Messages.send(sender, "recap-log-entity", Map.of(
+ "entity", entityComponent,
+ "hits", Integer.toString(entry.getValue().size()),
+ "damage", Util.HEALTH_FORMAT.format(damage)
+ ));
+ }
+
+ Messages.send(sender, "recap-log-general", Map.of(
+ "time", Util.toTimeStringShort(Duration.between(recap.getCreateTime(), recap.getLastEntry().logTime())),
+ "health", Util.formatHealth(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.GAIN).mapToDouble(RecapEntry::amount).sum(), false),
+ "damage", Util.HEALTH_FORMAT.format(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.LOSS).mapToDouble(RecapEntry::amount).sum())
+ ));
+ }
+
+ public static void recapSource(Audience sender, BattleRecap recap) {
+ Messages.send(sender, "header", Messages.getPlain("recap-damage-cause"));
+ Map> entriesByStack = new HashMap<>();
+
+ for (RecapEntry entry : recap.getEntries()) {
+ if (entry.kind() != RecapEntry.Kind.LOSS) {
+ continue;
+ }
+
+ if (entry.damageCause() == null) {
+ continue;
+ }
+
+ entriesByStack.computeIfAbsent(entry.damageCause(), k -> new ArrayList<>()).add(entry);
+ }
+
+ for (Map.Entry> entry : entriesByStack.entrySet()) {
+ double damage = entry.getValue().stream().mapToDouble(RecapEntry::amount).sum();
+ if (damage == 0) {
+ continue;
+ }
+
+ // TODO: Translation support
+ Component causeComponent = Component.text(Util.capitalize(entry.getKey().name().toLowerCase(Locale.ROOT).replace("_", " ")));
+ Messages.send(sender, "recap-log-cause", Map.of(
+ "cause", causeComponent,
+ "hits", Integer.toString(entry.getValue().size()),
+ "damage", Util.HEALTH_FORMAT.format(damage)
+ ));
+ }
+
+ Messages.send(sender, "recap-log-general", Map.of(
+ "time", Util.toTimeStringShort(Duration.between(recap.getCreateTime(), recap.getLastEntry().logTime())),
+ "health", Util.formatHealth(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.GAIN).mapToDouble(RecapEntry::amount).sum(), false),
+ "damage", Util.HEALTH_FORMAT.format(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.LOSS).mapToDouble(RecapEntry::amount).sum())
+ ));
+ }
+
+ public static void sendFooter(Audience audience, Tracker tracker, BattleRecap recap) {
+ if (tracker.tracksData(TrackedDataType.PVP)) {
+ Messages.send(audience, "recap-footer-pvp", Map.of(
+ "tracker", tracker.getName().toLowerCase(Locale.ROOT),
+ "player", recap.getRecapOwner()
+ ));
+ } else {
+ Messages.send(audience, "recap-footer-pve", Map.of(
+ "tracker", tracker.getName().toLowerCase(Locale.ROOT),
+ "player", recap.getRecapOwner()
+ ));
+ }
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/listener/PvEListener.java b/src/main/java/org/battleplugins/tracker/listener/PvEListener.java
new file mode 100644
index 0000000..f0bb1ff
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/listener/PvEListener.java
@@ -0,0 +1,207 @@
+package org.battleplugins.tracker.listener;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
+import org.battleplugins.tracker.TrackedDataType;
+import org.battleplugins.tracker.Tracker;
+import org.battleplugins.tracker.event.TrackerDeathEvent;
+import org.battleplugins.tracker.feature.recap.Recap;
+import org.battleplugins.tracker.feature.recap.RecapEntry;
+import org.battleplugins.tracker.stat.Record;
+import org.battleplugins.tracker.stat.StatType;
+import org.battleplugins.tracker.util.Util;
+import org.bukkit.entity.AnimalTamer;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.EntityType;
+import org.bukkit.entity.LivingEntity;
+import org.bukkit.entity.Player;
+import org.bukkit.entity.Projectile;
+import org.bukkit.entity.Tameable;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.event.Listener;
+import org.bukkit.event.entity.EntityDamageByEntityEvent;
+import org.bukkit.event.entity.EntityDamageEvent;
+import org.bukkit.event.entity.EntityDeathEvent;
+import org.bukkit.event.entity.PlayerDeathEvent;
+import org.bukkit.inventory.ItemStack;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.UUID;
+
+public class PvEListener implements Listener {
+ private final Tracker tracker;
+
+ public PvEListener(Tracker tracker) {
+ this.tracker = tracker;
+ }
+
+ @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
+ public void onDeath(PlayerDeathEvent event) {
+ Player killed = event.getEntity();
+ if (this.tracker.getDisabledWorlds().contains(killed.getWorld().getName())) {
+ return;
+ }
+
+ TrackedDataType dataType = TrackedDataType.PVE;
+
+ EntityDamageEvent lastDamageCause = killed.getLastDamageCause();
+ Entity killer = null;
+ String killerName;
+ if (lastDamageCause instanceof EntityDamageByEntityEvent damageEvent) {
+ EntityType killerType;
+
+ killer = damageEvent.getDamager();
+ if (killer instanceof Player) {
+ return;
+ }
+
+ killerType = killer.getType();
+ if (killer instanceof Projectile projectile && projectile.getShooter() instanceof Entity entity) {
+ if (projectile.getShooter() instanceof Player) {
+ return;
+ }
+
+ killer = entity;
+ killerType = projectile.getType();
+ }
+
+ if (killer instanceof Tameable tameable && tameable.isTamed()) {
+ return; // only players can tame animals
+ }
+
+ killerName = PlainTextComponentSerializer.plainText().serialize(Component.translatable(killerType));
+ } else {
+ dataType = TrackedDataType.WORLD;
+
+ // TODO: Translation support, and use new damage API when it is
+ // ready for widespread adoption
+ if (lastDamageCause == null) {
+ killerName = "Unknown";
+ } else {
+ killerName = Util.capitalize(lastDamageCause.getCause().name().toLowerCase(Locale.ROOT).replace("_", " "));
+ }
+ }
+
+ if (!this.tracker.tracksData(dataType)) {
+ return;
+ }
+
+ Record killerRecord = this.tracker.getOrCreateRecord(killed);
+ if (killerRecord.isTracking()) {
+ this.tracker.incrementValue(StatType.DEATHS, killed);
+ }
+
+ Record record = new Record(this.tracker, UUID.randomUUID(), killerName, new HashMap<>());
+ record.setRating(this.tracker.getRatingCalculator().getDefaultRating());
+ this.tracker.getRatingCalculator().updateRating(record, killerRecord, false);
+
+ new TrackerDeathEvent(this.tracker, dataType == TrackedDataType.PVE ? TrackerDeathEvent.DeathType.ENTITY : TrackerDeathEvent.DeathType.WORLD, killer, event).callEvent();
+ }
+
+ @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
+ public void onEntityDeath(EntityDeathEvent event) {
+ Entity killed = event.getEntity();
+ if (this.tracker.getDisabledWorlds().contains(killed.getWorld().getName())) {
+ return;
+ }
+
+ if (killed instanceof Player) {
+ return;
+ }
+
+ if (!this.tracker.tracksData(TrackedDataType.PVE)) {
+ return;
+ }
+
+ if (!(killed.getLastDamageCause() instanceof EntityDamageByEntityEvent lastDamageCause)) {
+ return;
+ }
+
+ if (!(lastDamageCause.getDamager() instanceof Player killer)) {
+ return;
+ }
+
+ Record killerRecord = this.tracker.getOrCreateRecord(killer);
+ if (killerRecord.isTracking()) {
+ this.tracker.incrementValue(StatType.KILLS, killer);
+ }
+
+ String killerName = PlainTextComponentSerializer.plainText().serialize(Component.translatable(killer.getType()));
+
+ Record record = new Record(this.tracker, UUID.randomUUID(), killerName, new HashMap<>());
+ record.setRating(this.tracker.getRatingCalculator().getDefaultRating());
+ this.tracker.getRatingCalculator().updateRating(killerRecord, record, false);
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onEntityDamage(EntityDamageEvent event) {
+ if (!(event.getEntity() instanceof Player player)) {
+ return;
+ }
+
+ if (this.tracker.getDisabledWorlds().contains(event.getEntity().getWorld().getName())) {
+ return;
+ }
+
+ Recap recap = this.tracker.getFeature(Recap.class);
+ if (recap == null) {
+ return;
+ }
+
+ if (!(event instanceof EntityDamageByEntityEvent entityEvent)) {
+ if (!this.tracker.tracksData(TrackedDataType.WORLD)) {
+ return;
+ }
+
+ recap.getRecap(player).record(RecapEntry.builder()
+ .damageCause(event.getCause())
+ .amount(event.getFinalDamage())
+ .logTime(Instant.now())
+ .build()
+ );
+
+ return;
+ }
+
+ if (!this.tracker.tracksData(TrackedDataType.PVE)) {
+ return;
+ }
+
+ Entity causingEntity = entityEvent.getDamager();
+ Entity sourceEntity = causingEntity;
+ if (causingEntity instanceof Projectile proj) {
+ if (proj.getShooter() instanceof Entity shooter) {
+ sourceEntity = shooter;
+ }
+ }
+
+ if (causingEntity instanceof Tameable tameable && tameable.isTamed()) {
+ AnimalTamer owner = tameable.getOwner();
+ if (owner instanceof Entity entity) {
+ sourceEntity = entity;
+ }
+ }
+
+ if (sourceEntity instanceof Player) {
+ return;
+ }
+
+ ItemStack itemUsed = null;
+ if (causingEntity instanceof LivingEntity livingEntity && livingEntity.getEquipment() != null) {
+ itemUsed = livingEntity.getEquipment().getItemInMainHand();
+ }
+
+ recap.getRecap(player).record(RecapEntry.builder()
+ .damageCause(event.getCause())
+ .causingEntity(causingEntity)
+ .sourceEntity(sourceEntity)
+ .amount(event.getFinalDamage())
+ .itemUsed(itemUsed)
+ .logTime(Instant.now())
+ .build()
+ );
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/listener/PvPListener.java b/src/main/java/org/battleplugins/tracker/listener/PvPListener.java
new file mode 100644
index 0000000..b9049db
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/listener/PvPListener.java
@@ -0,0 +1,150 @@
+package org.battleplugins.tracker.listener;
+
+import org.battleplugins.tracker.TrackedDataType;
+import org.battleplugins.tracker.Tracker;
+import org.battleplugins.tracker.event.TrackerDeathEvent;
+import org.battleplugins.tracker.feature.recap.Recap;
+import org.battleplugins.tracker.feature.recap.RecapEntry;
+import org.battleplugins.tracker.stat.Record;
+import org.battleplugins.tracker.stat.StatType;
+import org.battleplugins.tracker.stat.TallyEntry;
+import org.bukkit.entity.AnimalTamer;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.entity.Projectile;
+import org.bukkit.entity.Tameable;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.event.Listener;
+import org.bukkit.event.entity.EntityDamageByEntityEvent;
+import org.bukkit.event.entity.EntityDamageEvent;
+import org.bukkit.event.entity.PlayerDeathEvent;
+import org.bukkit.inventory.ItemStack;
+
+import java.time.Instant;
+
+public class PvPListener implements Listener {
+ private final Tracker tracker;
+
+ public PvPListener(Tracker tracker) {
+ this.tracker = tracker;
+ }
+
+ @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
+ public void onDeath(PlayerDeathEvent event) {
+ if (!this.tracker.tracksData(TrackedDataType.PVP)) {
+ return;
+ }
+
+ Player killed = event.getEntity();
+ if (this.tracker.getDisabledWorlds().contains(killed.getWorld().getName())) {
+ return;
+ }
+
+ EntityDamageEvent lastDamageCause = killed.getLastDamageCause();
+ Player killer = null;
+ if (lastDamageCause instanceof EntityDamageByEntityEvent damageByEntityEvent) {
+ Entity damager = damageByEntityEvent.getDamager();
+ if (damager instanceof Player) {
+ killer = (Player) damager;
+ }
+
+ if (damager instanceof Projectile proj) {
+ if (proj.getShooter() instanceof Player player) {
+ killer = player;
+ }
+ }
+
+ if (damager instanceof Tameable tameable && tameable.isTamed()) {
+ AnimalTamer owner = tameable.getOwner();
+ if (owner instanceof Player player) {
+ killer = player;
+ }
+ }
+ }
+
+ if (killer == null) {
+ return;
+ }
+
+ Record killerRecord = this.tracker.getOrCreateRecord(killer);
+ Record killedRecord = this.tracker.getOrCreateRecord(killed);
+
+ if (killerRecord.isTracking()) {
+ this.tracker.incrementValue(StatType.KILLS, killer);
+ }
+
+ if (killedRecord.isTracking()) {
+ this.tracker.incrementValue(StatType.DEATHS, killed);
+ }
+
+ this.tracker.updateRating(killer, killed, false);
+
+ new TrackerDeathEvent(this.tracker, TrackerDeathEvent.DeathType.PLAYER, killer, event).callEvent();
+
+ Player finalKiller = killer;
+ this.tracker.getOrCreateVersusTally(killer, killed).whenComplete((versusTally, e) -> {
+ // The format is killer : killed : stat1 : stat2 ....
+ // If the killer is in place of the killed, we need to swap the values
+ boolean addToKills = !versusTally.id2().equals(finalKiller.getUniqueId());
+ if (addToKills) {
+ this.tracker.modifyTally(versusTally, ctx -> ctx.recordStat(StatType.KILLS, versusTally.getStat(StatType.KILLS) + 1));
+ } else {
+ this.tracker.modifyTally(versusTally, ctx -> ctx.recordStat(StatType.DEATHS, versusTally.getStat(StatType.DEATHS) + 1));
+ }
+
+ // Record a tally entry at the current timestamp
+ TallyEntry entry = new TallyEntry(finalKiller, killed, false, Instant.now());
+ this.tracker.recordTallyEntry(entry);
+ });
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onEntityDamage(EntityDamageByEntityEvent event) {
+ if (!this.tracker.tracksData(TrackedDataType.PVP)) {
+ return;
+ }
+
+ if (!(event.getEntity() instanceof Player damaged)) {
+ return;
+ }
+
+ if (this.tracker.getDisabledWorlds().contains(damaged.getWorld().getName())) {
+ return;
+ }
+
+ Recap recap = this.tracker.getFeature(Recap.class);
+ if (recap == null) {
+ return;
+ }
+
+ Entity sourceEntity = event.getDamager();
+ if (event.getDamager() instanceof Projectile proj) {
+ if (proj.getShooter() instanceof Player shooter) {
+ sourceEntity = shooter;
+ }
+ }
+
+ if (event.getDamager() instanceof Tameable tameable && tameable.isTamed()) {
+ AnimalTamer owner = tameable.getOwner();
+ if (owner instanceof Player player) {
+ sourceEntity = player;
+ }
+ }
+
+ if (!(sourceEntity instanceof Player player)) {
+ return;
+ }
+
+ ItemStack itemUsed = player.getInventory().getItemInMainHand();
+ recap.getRecap(damaged).record(RecapEntry.builder()
+ .damageCause(event.getCause())
+ .causingEntity(event.getDamager())
+ .sourceEntity(sourceEntity)
+ .amount(event.getFinalDamage())
+ .itemUsed(itemUsed)
+ .logTime(Instant.now())
+ .build()
+ );
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/message/Messages.java b/src/main/java/org/battleplugins/tracker/message/Messages.java
new file mode 100644
index 0000000..930babf
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/message/Messages.java
@@ -0,0 +1,157 @@
+package org.battleplugins.tracker.message;
+
+import net.kyori.adventure.audience.Audience;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.ComponentLike;
+import net.kyori.adventure.text.event.ClickEvent;
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
+import org.battleplugins.tracker.BattleTracker;
+import org.battleplugins.tracker.util.MessageUtil;
+import org.bukkit.configuration.file.FileConfiguration;
+import org.bukkit.configuration.file.YamlConfiguration;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class Messages {
+ private static final Map MESSAGES = new HashMap<>();
+
+ public static void load(Path messagesPath) {
+ MESSAGES.clear();
+
+ File messagesFile = messagesPath.toFile();
+ FileConfiguration messagesConfig = YamlConfiguration.loadConfiguration(messagesFile);
+
+ for (String key : messagesConfig.getKeys(false)) {
+ String messageText = messagesConfig.getString(key);
+ if (messageText == null) {
+ BattleTracker.getInstance().warn("Message key {} has no value in messages file! Skipping", key);
+ continue;
+ }
+
+ Component message = MessageUtil.MINI_MESSAGE.deserialize(messageText);
+ MESSAGES.put(key, message);
+ }
+ }
+
+ public static Component get(String key, String... replacements) {
+ Component message = MESSAGES.get(key);
+ if (message == null) {
+ BattleTracker.getInstance().warn("Unknown message key {} in messages file! Skipping", key);
+ return Component.empty();
+ }
+
+ for (String replacement : replacements) {
+ message = message.replaceText(builder -> builder.matchLiteral("{}").once().replacement(Component.text(replacement)));
+ }
+
+ return message;
+ }
+
+ public static Component get(String key, Map replacements) {
+ Component message = MESSAGES.get(key);
+ if (message == null) {
+ BattleTracker.getInstance().warn("Unknown message key {} in messages file! Skipping", key);
+ return Component.empty();
+ }
+
+ for (Map.Entry entry : replacements.entrySet()) {
+ if (entry.getValue() instanceof ComponentLike componentLike) {
+ message = message.replaceText(builder -> builder.matchLiteral("%" + entry.getKey() + "%").once().replacement(componentLike));
+ } else {
+ message = message.replaceText(builder -> builder.matchLiteral("%" + entry.getKey() + "%").once().replacement(Component.text(entry.getValue().toString())));
+ }
+ }
+
+ return message;
+ }
+
+ public static String getPlain(String key, String... replacements) {
+ Component message = MESSAGES.get(key);
+ if (message == null) {
+ BattleTracker.getInstance().warn("Unknown message key {} in messages file! Skipping", key);
+ return "";
+ }
+
+ for (String replacement : replacements) {
+ message = message.replaceText(builder -> builder.matchLiteral("{}").once().replacement(replacement));
+ }
+
+ return PlainTextComponentSerializer.plainText().serialize(MESSAGES.get(key));
+ }
+
+ public static void send(Audience audience, String key, Map replacements) {
+ Component message = MESSAGES.get(key);
+ if (message == null) {
+ BattleTracker.getInstance().warn("Unknown message key {} in messages file! Skipping", key);
+ return;
+ }
+
+ for (Map.Entry entry : replacements.entrySet()) {
+ if (entry.getValue() instanceof ComponentLike componentLike) {
+ message = message.replaceText(builder -> builder.matchLiteral("%" + entry.getKey() + "%").once().replacement(componentLike));
+ } else {
+ message = message.replaceText(builder -> builder.matchLiteral("%" + entry.getKey() + "%").once().replacement(Component.text(entry.getValue().toString())));
+ }
+ }
+
+ message = processClickEvent(message, replacements);
+ audience.sendMessage(message);
+ }
+
+ public static void send(Audience audience, String key) {
+ send(audience, key, Map.of());
+ }
+
+ public static void send(Audience audience, String key, String... replacements) {
+ send(audience, key, Arrays.stream(replacements).map(Component::text).toArray(Component[]::new));
+ }
+
+ public static void send(Audience audience, String key, Component... replacements) {
+ Component message = MESSAGES.get(key);
+ if (message == null) {
+ BattleTracker.getInstance().warn("Unknown message key {} in messages file! Skipping", key);
+ return;
+ }
+
+ for (Component replacement : replacements) {
+ message = message.replaceText(builder -> builder.matchLiteral("{}").once().replacement(replacement));
+ }
+
+ audience.sendMessage(message);
+ }
+
+ private static Component processClickEvent(Component component, Map replacements) {
+ ClickEvent clickEvent = component.clickEvent();
+ if (clickEvent != null) {
+ for (Map.Entry entry : replacements.entrySet()) {
+ clickEvent = ClickEvent.clickEvent(clickEvent.action(), clickEvent.value().replace("%" + entry.getKey() + "%", entry.getValue().toString()));
+ }
+
+ component = component.clickEvent(clickEvent);
+ }
+
+ List children = new ArrayList<>();
+ for (Component child : component.children()) {
+ ClickEvent childClickEvent = child.clickEvent();
+ if (childClickEvent != null) {
+ for (Map.Entry entry : replacements.entrySet()) {
+ childClickEvent = ClickEvent.clickEvent(childClickEvent.action(), childClickEvent.value().replace("%" + entry.getKey() + "%", entry.getValue().toString()));
+ }
+
+ child = child.clickEvent(childClickEvent);
+ }
+
+ child = processClickEvent(child, replacements);
+ children.add(child);
+ }
+
+ component = component.children(children);
+ return component;
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/sql/DbCache.java b/src/main/java/org/battleplugins/tracker/sql/DbCache.java
new file mode 100644
index 0000000..ab93f3a
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/sql/DbCache.java
@@ -0,0 +1,280 @@
+package org.battleplugins.tracker.sql;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+/**
+ * A cache holding data from a database.
+ *
+ * This is an async, thread-safe cache that can be used to store data
+ * from a database. This cache is designed for used of data that is
+ * frequently accessed and should be stored in memory for faster access.
+ *
+ * This cache has flushing capabilities, meaning that the cache can be
+ * saved to the database at any time. This is useful for saving data
+ * that has been modified in the cache. Additionally, this can be scaled
+ * or modified in cases where lots of data is loaded.
+ */
+public interface DbCache {
+
+ /**
+ * Creates a new Set cache.
+ *
+ * @param the value of the cache
+ * @return a new Set cache
+ */
+ static SetCache createSet() {
+ return new DbCacheSet<>();
+ }
+
+ /**
+ * Creates a new Map cache.
+ *
+ * @param the key of the cache
+ * @param the value of the cache
+ * @return a new Map cache
+ */
+ static MapCache createMap() {
+ return new DbCacheMap<>();
+ }
+
+ /**
+ * Creates a new Multimap cache.
+ *
+ * @param the key of the cache
+ * @param the value of the cache
+ * @return a new Multimap cache
+ */
+ static MultimapCache createMultimap() {
+ return new DbCacheMultimap<>();
+ }
+
+ interface SetCache extends DbCache {
+
+ /**
+ * Adds a value to the cache.
+ *
+ * @param value the value to add
+ */
+ void add(V value);
+
+ /**
+ * Modifies a value in the cache.
+ *
+ * @param value the value to modify
+ */
+ void modify(V value);
+
+ /**
+ * Locks a value in the cache.
+ *
+ * This will ensure that an entry is not removed
+ * from the cache until it is unlocked.
+ *
+ * @param value the value to lock
+ */
+ void lock(V value);
+
+ /**
+ * Unlocks a value in the cache.
+ *
+ * This will allow an entry to be removed from
+ * the cache.
+ *
+ * @param value the value to unlock
+ */
+ void unlock(V value);
+
+ /**
+ * Returns a cached value from the cache immediately.
+ *
+ * This method should be used when the value is expected to be
+ * in the cache. If the value is not in the cache, this method
+ * will return null.
+ *
+ * @param predicate the predicate to get the value from
+ * @return the value from the cache, or null if the value is not in the cache
+ */
+ @Nullable
+ V getCached(Predicate predicate);
+
+ /**
+ * Returns a value from the cache or loads it if it is not in the cache.
+ *
+ * This method should be used when the value is not guaranteed to be
+ * in the cache. If the value is in the cache, this method will
+ * return the value immediately. If the value is not in the cache,
+ * this method will load the value from the database and return it.
+ *
+ * @param predicate the predicate to get the value from. If the predicate returns
+ * false, the value will be loaded from the database
+ * @param loader the loader to load the value from the database
+ * @return the value from the cache or the value loaded from the database
+ */
+ CompletableFuture getOrLoad(Predicate predicate, CompletableFuture loader);
+
+ /**
+ * Saves the cache to the database.
+ *
+ * The consumer will be called for each value in the cache that
+ * has been modified. This is useful for saving data that has been
+ * modified in the cache.
+ *
+ * @param value the value to save
+ */
+ void save(Consumer value);
+
+ /**
+ * Flushes the cache.
+ *
+ * This method will remove the key from the cache and save the
+ * values to the database. This is useful for saving data that
+ * has been modified in the cache.
+ *
+ * NOTE: This should be used in conjunction with the
+ * {@link #save} method as flushing with
+ * unsaved entries will not damageCause the objects to be flushed
+ * from memory.
+ *
+ * @param all whether to flush all entries
+ */
+ void flush(boolean all);
+ }
+
+ interface MapBase extends DbCache {
+
+ /**
+ * Returns a {@link Set} of all the keys in
+ * the cache.
+ *
+ * @return a set of all the keys in the cache
+ */
+ Set keySet();
+
+ /**
+ * Puts a value into the cache.
+ *
+ * @param key the key to store the value under
+ * @param value the value to store
+ */
+ void put(K key, V value);
+
+ /**
+ * Locks a key in the cache.
+ *
+ * This will ensure that an entry is not removed
+ * from the cache until it is unlocked.
+ *
+ * @param key the key to lock
+ */
+ void lock(K key);
+
+ /**
+ * Unlocks a key in the cache.
+ *
+ * This will allow an entry to be removed from
+ * the cache.
+ *
+ * @param key the key to unlock
+ */
+ void unlock(K key);
+
+ /**
+ * Removes an entry from the cache.
+ *
+ * @param key the key of the entry
+ */
+ void remove(K key);
+
+ /**
+ * Returns a cached value from the cache immediately.
+ *
+ * This method should be used when the value is expected to be
+ * in the cache. If the value is not in the cache, this method
+ * will return null.
+ *
+ * @param key the key to get the value from
+ * @return the value from the cache, or null if the value is not in the cache
+ */
+ @Nullable
+ C getCached(K key);
+
+ /**
+ * Returns a value from the cache or loads it if it is not in the cache.
+ *
+ * This method should be used when the value is not guaranteed to be
+ * in the cache. If the value is in the cache, this method will
+ * return the value immediately. If the value is not in the cache,
+ * this method will load the value from the database and return it.
+ *
+ * @param key the key to get the value from
+ * @param loader the loader to load the value from the database
+ * @return the value from the cache or the value loaded from the database
+ */
+ CompletableFuture getOrLoad(K key, CompletableFuture loader);
+
+ /**
+ * Bulk loads data into this cache.
+ *
+ * @param loader the loader to load the data from the database
+ * @param keyFunction the function to get the key from the value
+ * @return the loaded data
+ */
+ CompletableFuture extends Collection> loadBulk(CompletableFuture extends Collection> loader, Function keyFunction);
+
+ /**
+ * Saves the cache to the database.
+ *
+ * The consumer will be called for each value in the cache that
+ * has been modified. This is useful for saving data that has been
+ * modified in the cache.
+ *
+ * @param key the key to save the value under
+ * @param value the value to save
+ */
+ void save(K key, Consumer value);
+
+ /**
+ * Flushes the cache.
+ *
+ * This method will remove the key from the cache and save the
+ * values to the database. This is useful for saving data that
+ * has been modified in the cache.
+ *
+ * NOTE: This should be used in conjunction with the
+ * {@link #save(Object, Consumer)} method as flushing with
+ * unsaved entries will not damageCause the objects to be flushed
+ * from memory.
+ *
+ * @param key the key to flush
+ * @param all whether to flush all entries
+ */
+ void flush(K key, boolean all);
+ }
+
+ interface MapCache extends MapBase {
+ }
+
+ interface MultimapCache extends MapBase> {
+
+ @NotNull
+ @Override
+ List getCached(K key);
+
+ /**
+ * Puts a collection of values into the cache.
+ *
+ * @param key the key to store the values under
+ * @param values the values to store
+ */
+ void putAll(K key, Collection values);
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/sql/DbCacheMap.java b/src/main/java/org/battleplugins/tracker/sql/DbCacheMap.java
new file mode 100644
index 0000000..ef55fa5
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/sql/DbCacheMap.java
@@ -0,0 +1,131 @@
+package org.battleplugins.tracker.sql;
+
+import org.battleplugins.tracker.BattleTracker;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+class DbCacheMap implements DbCache.MapCache {
+ private final Map> entries = new ConcurrentHashMap<>();
+
+ @Override
+ public Set keySet() {
+ return this.entries.keySet();
+ }
+
+ @Override
+ public void put(K key, V value) {
+ this.entries.put(key, new DbValue<>(value, true));
+ }
+
+ @Override
+ public void remove(K key) {
+ this.entries.remove(key);
+ }
+
+ @Override
+ public V getCached(K key) {
+ DbValue dbValue = this.entries.get(key);
+ if (dbValue == null) {
+ return null;
+ }
+
+ dbValue.resetLastAccess();
+ return dbValue.value;
+ }
+
+ @Override
+ public CompletableFuture getOrLoad(K key, CompletableFuture loader) {
+ if (this.entries.containsKey(key)) {
+ return CompletableFuture.completedFuture(this.getCached(key));
+ }
+
+ return loader.thenApply(value -> {
+ if (value == null) {
+ return null;
+ }
+
+ this.entries.put(key, new DbValue<>(value, false));
+ return value;
+ });
+ }
+
+ @Override
+ public CompletableFuture extends Collection> loadBulk(CompletableFuture extends Collection> loader, Function keyFunction) {
+ return loader.thenApply(values -> {
+ for (V value : values) {
+ K key = keyFunction.apply(value);
+
+ // If we have this data in our cache already, let's use
+ // the cache as the source of truth, since the data may
+ // have been updated
+ if (this.entries.containsKey(key)) {
+ continue;
+ }
+
+ this.entries.put(key, new DbValue<>(value, false));
+ }
+
+ return values;
+ });
+ }
+
+ @Override
+ public void save(K key, Consumer valueConsumer) {
+ DbValue value = this.entries.get(key);
+ if (value == null) {
+ BattleTracker.getInstance().warn("No value found in cache for key {}", key);
+ return;
+ }
+
+ if (value.dirty) {
+ valueConsumer.accept(value.value);
+ value.dirty = false;
+ }
+ }
+
+ @Override
+ public void flush(K key, boolean all) {
+ DbValue dbValue = this.entries.get(key);
+ if (dbValue == null) {
+ this.entries.remove(key);
+ return;
+ }
+
+ if (!all && !dbValue.shouldFlush()) {
+ return;
+ }
+
+ // If the db value is locked, do not flush
+ if (dbValue.locked) {
+ return;
+ }
+
+ if (!dbValue.dirty) {
+ this.entries.remove(key);
+ } else {
+ BattleTracker.getInstance().warn("Unsaved DB value found in cache: {} for key {}", dbValue.value, key);
+ }
+ }
+
+ @Override
+ public void lock(K key) {
+ DbValue dbValue = this.entries.get(key);
+ if (dbValue != null) {
+ dbValue.lock();
+ }
+ }
+
+ @Override
+ public void unlock(K key) {
+ DbValue dbValue = this.entries.get(key);
+ if (dbValue != null) {
+ dbValue.unlock();
+ }
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/sql/DbCacheMultimap.java b/src/main/java/org/battleplugins/tracker/sql/DbCacheMultimap.java
new file mode 100644
index 0000000..d4c7323
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/sql/DbCacheMultimap.java
@@ -0,0 +1,187 @@
+package org.battleplugins.tracker.sql;
+
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Multimaps;
+import org.battleplugins.tracker.BattleTracker;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+class DbCacheMultimap implements DbCache.MultimapCache {
+ private final ListMultimap> entries = Multimaps.synchronizedListMultimap(
+ MultimapBuilder.hashKeys()
+ .arrayListValues()
+ .build()
+ );
+
+ private final Set lockedKeys = new HashSet<>();
+ private final Set loadedKeys = new HashSet<>();
+
+ @Override
+ public Set keySet() {
+ return this.entries.keySet();
+ }
+
+ @Override
+ public void put(K key, V value) {
+ this.entries.put(key, new DbValue<>(value, true));
+ }
+
+ @Override
+ public void lock(K key) {
+ this.lockedKeys.add(key);
+ }
+
+ @Override
+ public void unlock(K key) {
+ this.lockedKeys.remove(key);
+ }
+
+ @Override
+ public void remove(K key) {
+ this.entries.removeAll(key);
+ }
+
+ @Override
+ public void putAll(K key, Collection values) {
+ values.forEach(value -> this.put(key, value));
+ }
+
+ @NotNull
+ @Override
+ public List getCached(K key) {
+ if (!this.entries.containsKey(key)) {
+ return List.of();
+ }
+
+ List> entries = this.entries.get(key);
+ if (entries.isEmpty()) {
+ return List.of();
+ }
+
+ List cached = new ArrayList<>(entries.size());
+ for (DbValue entry : entries) {
+ cached.add(entry.value);
+ entry.resetLastAccess();
+ }
+
+ return cached;
+ }
+
+ @Override
+ public CompletableFuture> getOrLoad(K key, CompletableFuture> loader) {
+ if (this.loadedKeys.contains(key)) {
+ return CompletableFuture.completedFuture(this.getCached(key));
+ }
+
+ List cachedAndLoaded = new ArrayList<>();
+ if (this.entries.containsKey(key)) {
+ List> cachedData = this.entries.get(key);
+ cachedAndLoaded.addAll(cachedData.stream()
+ .peek(DbValue::resetLastAccess)
+ .map(dbValue -> dbValue.value)
+ .toList());
+ }
+
+ return loader.thenApply(value -> {
+ if (value == null) {
+ return List.of();
+ }
+
+ for (V v : value) {
+ this.entries.put(key, new DbValue<>(v, false));
+ }
+
+ this.loadedKeys.add(key);
+
+ // If there is cached data, we need to merge the cached data
+ // with the loaded data. This will only be called once, so if
+ // we take a slight performance hit on load, that's fine, as the
+ // data will be cached for future use
+ if (!cachedAndLoaded.isEmpty()) {
+ cachedAndLoaded.addAll(value);
+ return cachedAndLoaded;
+ }
+
+ return value;
+ });
+ }
+
+ @Override
+ public CompletableFuture extends Collection>> loadBulk(CompletableFuture extends Collection>> loader, Function, K> keyFunction) {
+ return loader.thenApply(values -> {
+ for (List value : values) {
+ K key = keyFunction.apply(value);
+ for (V v : value) {
+ // If we have this data in our cache already, let's use
+ // the cache as the source of truth, since the data may
+ // have been updated
+ if (this.entries.containsEntry(key, v)) {
+ continue;
+ }
+
+ this.entries.put(key, new DbValue<>(v, false));
+ }
+
+ this.loadedKeys.add(key);
+ }
+
+ return values;
+ });
+ }
+
+ @Override
+ public void save(K key, Consumer valueConsumer) {
+ this.entries.get(key).forEach(dbValue -> {
+ if (dbValue.dirty) {
+ valueConsumer.accept(dbValue.value);
+
+ dbValue.dirty = false;
+ }
+ });
+
+ // If this key has never been loaded from the database
+ // before, we need to flush the data from the cache as
+ // keeping it here will mean that if a db load is called,
+ // the data could exist twice in our cached instance
+ if (!this.loadedKeys.contains(key)) {
+ this.flush(key, true);
+ }
+ }
+
+ @Override
+ public void flush(K key, boolean all) {
+ this.loadedKeys.remove(key);
+ if (!this.entries.containsKey(key)) {
+ return;
+ }
+
+ Iterator> iterator = this.entries.get(key).iterator();
+ while (iterator.hasNext()) {
+ DbValue dbValue = iterator.next();
+ if (!all && !dbValue.shouldFlush()) {
+ continue;
+ }
+
+ // If the db value is locked, do not flush
+ if (this.lockedKeys.contains(key)) {
+ continue;
+ }
+
+ if (!dbValue.dirty) {
+ iterator.remove();
+ } else {
+ BattleTracker.getInstance().warn("Unsaved DB value found in cache: {} for key {}", dbValue.value, key);
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/battleplugins/tracker/sql/DbCacheSet.java b/src/main/java/org/battleplugins/tracker/sql/DbCacheSet.java
new file mode 100644
index 0000000..0d718d0
--- /dev/null
+++ b/src/main/java/org/battleplugins/tracker/sql/DbCacheSet.java
@@ -0,0 +1,108 @@
+package org.battleplugins.tracker.sql;
+
+import org.battleplugins.tracker.BattleTracker;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Iterator;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+class DbCacheSet implements DbCache.SetCache {
+ private final Set> entries = ConcurrentHashMap.newKeySet();
+
+ @Override
+ public void add(V value) {
+ this.entries.add(new DbValue<>(value, true));
+ }
+
+ @Override
+ public void modify(V value) {
+ for (DbValue entry : this.entries) {
+ if (entry.value.equals(value)) {
+ entry.dirty = true;
+ entry.resetLastAccess();
+ return;
+ }
+ }
+ }
+
+ @Override
+ public void lock(V value) {
+ for (DbValue entry : this.entries) {
+ if (entry.value.equals(value)) {
+ entry.lock();
+ return;
+ }
+ }
+ }
+
+ @Override
+ public void unlock(V value) {
+ for (DbValue