feat: add comprehensive player statistics logging
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package net.therosegarden.playerstats;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
|
||||
final class FlatFileStatsStorage implements StatsStorage {
|
||||
private final Path playersDirectory;
|
||||
private final Path eventsDirectory;
|
||||
private final boolean journalEnabled;
|
||||
|
||||
FlatFileStatsStorage(Path dataDirectory, boolean journalEnabled) throws IOException {
|
||||
this.playersDirectory = dataDirectory.resolve("players");
|
||||
this.eventsDirectory = dataDirectory.resolve("events");
|
||||
this.journalEnabled = journalEnabled;
|
||||
Files.createDirectories(playersDirectory);
|
||||
if (journalEnabled) {
|
||||
Files.createDirectories(eventsDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlayerRecord load(UUID uuid, String currentName) throws Exception {
|
||||
Path file = playersDirectory.resolve(uuid + ".properties");
|
||||
long now = System.currentTimeMillis();
|
||||
if (!Files.exists(file)) {
|
||||
return new PlayerRecord(uuid, currentName, now);
|
||||
}
|
||||
|
||||
Properties properties = new Properties();
|
||||
try (var reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
|
||||
properties.load(reader);
|
||||
}
|
||||
PlayerRecord record = PlayerRecord.fromProperties(properties);
|
||||
record.touch(currentName, now);
|
||||
return record;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(PlayerRecord record) throws Exception {
|
||||
Path target = playersDirectory.resolve(record.uuid() + ".properties");
|
||||
Path temporary = playersDirectory.resolve(record.uuid() + ".properties.tmp");
|
||||
try (var writer = Files.newBufferedWriter(temporary, StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||
record.toProperties().store(writer, "RosePlayerStats");
|
||||
}
|
||||
try {
|
||||
Files.move(temporary, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING,
|
||||
java.nio.file.StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (java.nio.file.AtomicMoveNotSupportedException ignored) {
|
||||
Files.move(temporary, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void journal(UUID uuid, String playerName, String eventType, String detail) throws Exception {
|
||||
if (!journalEnabled) {
|
||||
return;
|
||||
}
|
||||
LocalDate date = Instant.now().atZone(ZoneOffset.UTC).toLocalDate();
|
||||
Path file = eventsDirectory.resolve(date + ".jsonl");
|
||||
String line = "{\"timestamp\":" + System.currentTimeMillis()
|
||||
+ ",\"uuid\":\"" + escape(uuid.toString()) + "\""
|
||||
+ ",\"player\":\"" + escape(playerName) + "\""
|
||||
+ ",\"event\":\"" + escape(eventType) + "\""
|
||||
+ ",\"detail\":\"" + escape(detail) + "\"}\n";
|
||||
try (BufferedWriter writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND)) {
|
||||
writer.write(line);
|
||||
}
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder result = new StringBuilder(value.length() + 16);
|
||||
for (char c : value.toCharArray()) {
|
||||
switch (c) {
|
||||
case '\\' -> result.append("\\\\");
|
||||
case '"' -> result.append("\\\"");
|
||||
case '\n' -> result.append("\\n");
|
||||
case '\r' -> result.append("\\r");
|
||||
case '\t' -> result.append("\\t");
|
||||
default -> {
|
||||
if (c < 0x20) {
|
||||
result.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
result.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package net.therosegarden.playerstats;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
final class PlayerRecord {
|
||||
private final UUID uuid;
|
||||
private final ConcurrentHashMap<String, Long> counters = new ConcurrentHashMap<>();
|
||||
private volatile String lastName;
|
||||
private volatile long firstSeen;
|
||||
private volatile long lastSeen;
|
||||
|
||||
PlayerRecord(UUID uuid, String lastName, long now) {
|
||||
this.uuid = uuid;
|
||||
this.lastName = lastName == null ? "unknown" : lastName;
|
||||
this.firstSeen = now;
|
||||
this.lastSeen = now;
|
||||
}
|
||||
|
||||
UUID uuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
String lastName() {
|
||||
return lastName;
|
||||
}
|
||||
|
||||
long firstSeen() {
|
||||
return firstSeen;
|
||||
}
|
||||
|
||||
long lastSeen() {
|
||||
return lastSeen;
|
||||
}
|
||||
|
||||
void touch(String name, long now) {
|
||||
if (name != null && !name.isBlank()) {
|
||||
lastName = name;
|
||||
}
|
||||
if (firstSeen <= 0L) {
|
||||
firstSeen = now;
|
||||
}
|
||||
lastSeen = now;
|
||||
}
|
||||
|
||||
long increment(String key, long amount) {
|
||||
if (amount == 0L) {
|
||||
return get(key);
|
||||
}
|
||||
return counters.merge(key, amount, Long::sum);
|
||||
}
|
||||
|
||||
void set(String key, long value) {
|
||||
counters.put(key, value);
|
||||
}
|
||||
|
||||
long get(String key) {
|
||||
return counters.getOrDefault(key, 0L);
|
||||
}
|
||||
|
||||
Map<String, Long> countersSnapshot() {
|
||||
return new HashMap<>(counters);
|
||||
}
|
||||
|
||||
Properties toProperties() {
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("meta.uuid", uuid.toString());
|
||||
properties.setProperty("meta.last_name", lastName);
|
||||
properties.setProperty("meta.first_seen", Long.toString(firstSeen));
|
||||
properties.setProperty("meta.last_seen", Long.toString(lastSeen));
|
||||
for (Map.Entry<String, Long> entry : counters.entrySet()) {
|
||||
properties.setProperty("counter." + entry.getKey(), Long.toString(entry.getValue()));
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
static PlayerRecord fromProperties(Properties properties) {
|
||||
UUID uuid = UUID.fromString(properties.getProperty("meta.uuid"));
|
||||
long now = System.currentTimeMillis();
|
||||
PlayerRecord record = new PlayerRecord(uuid, properties.getProperty("meta.last_name", "unknown"), now);
|
||||
record.firstSeen = parseLong(properties.getProperty("meta.first_seen"), now);
|
||||
record.lastSeen = parseLong(properties.getProperty("meta.last_seen"), record.firstSeen);
|
||||
|
||||
for (String name : properties.stringPropertyNames()) {
|
||||
if (!name.startsWith("counter.")) {
|
||||
continue;
|
||||
}
|
||||
record.counters.put(name.substring("counter.".length()), parseLong(properties.getProperty(name), 0L));
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private static long parseLong(String value, long fallback) {
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(value);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package net.therosegarden.playerstats;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
final class PlayerStatsCommand implements CommandExecutor, TabCompleter {
|
||||
private final RosePlayerStatsPlugin plugin;
|
||||
private final StatsService stats;
|
||||
|
||||
PlayerStatsCommand(RosePlayerStatsPlugin plugin, StatsService stats) {
|
||||
this.plugin = plugin;
|
||||
this.stats = stats;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
|
||||
if (args.length == 0) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage(plugin.prefix() + plugin.color("&eUsage: /playerstats <player|uuid>"));
|
||||
return true;
|
||||
}
|
||||
if (!sender.hasPermission("roseplayerstats.view")) {
|
||||
sender.sendMessage(plugin.prefix() + plugin.color("&cYou do not have permission to view player statistics."));
|
||||
return true;
|
||||
}
|
||||
show(sender, stats.record(player));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args[0].equalsIgnoreCase("save")) {
|
||||
if (!sender.hasPermission("roseplayerstats.admin")) {
|
||||
sender.sendMessage(plugin.prefix() + plugin.color("&cYou do not have permission to save statistics."));
|
||||
return true;
|
||||
}
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
stats.saveAll();
|
||||
stats.flushJournal();
|
||||
sender.sendMessage(plugin.prefix() + plugin.color("&aPlayer statistics and event journal flushed to storage."));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args[0].equalsIgnoreCase("reload")) {
|
||||
if (!sender.hasPermission("roseplayerstats.admin")) {
|
||||
sender.sendMessage(plugin.prefix() + plugin.color("&cYou do not have permission to reload RosePlayerStats."));
|
||||
return true;
|
||||
}
|
||||
plugin.reloadConfig();
|
||||
sender.sendMessage(plugin.prefix() + plugin.color("&aConfiguration reloaded. &7Storage backend changes require a plugin/server restart."));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!sender.hasPermission("roseplayerstats.view.others")) {
|
||||
sender.sendMessage(plugin.prefix() + plugin.color("&cYou do not have permission to view another player's statistics."));
|
||||
return true;
|
||||
}
|
||||
|
||||
String lookup = String.join(" ", args);
|
||||
Player online = Bukkit.getPlayerExact(lookup);
|
||||
PlayerRecord record = online != null ? stats.record(online) : stats.findLoaded(lookup);
|
||||
if (record == null) {
|
||||
sender.sendMessage(plugin.prefix() + plugin.color("&cNo loaded statistics found for &f" + lookup + "&c. Use the UUID while the player is online at least once after plugin startup."));
|
||||
return true;
|
||||
}
|
||||
show(sender, record);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void show(CommandSender sender, PlayerRecord record) {
|
||||
long estimatedSteps = record.get("movement.estimated_steps");
|
||||
long distanceCm = record.get("movement.total_cm");
|
||||
long playTimeMs = record.get("sessions.play_time_ms");
|
||||
|
||||
sender.sendMessage(plugin.prefix() + plugin.color("&d&lPlayer Statistics"));
|
||||
sender.sendMessage(plugin.color("&7Player: &f" + record.lastName()));
|
||||
sender.sendMessage(plugin.color("&7UUID: &f" + record.uuid()));
|
||||
sender.sendMessage(plugin.color("&7Estimated steps: &f" + format(estimatedSteps)));
|
||||
sender.sendMessage(plugin.color("&7Distance tracked: &f" + formatDistance(distanceCm)));
|
||||
sender.sendMessage(plugin.color("&7Blocks broken: &f" + format(record.get("blocks.broken.total"))));
|
||||
sender.sendMessage(plugin.color("&7Blocks placed: &f" + format(record.get("blocks.placed.total"))));
|
||||
sender.sendMessage(plugin.color("&7Deaths: &f" + format(record.get("combat.deaths"))));
|
||||
sender.sendMessage(plugin.color("&7Joins: &f" + format(record.get("sessions.joins"))));
|
||||
sender.sendMessage(plugin.color("&7Tracked play time: &f" + formatDuration(playTimeMs)));
|
||||
sender.sendMessage(plugin.color("&8Counters stored: &7" + record.countersSnapshot().size()));
|
||||
}
|
||||
|
||||
private static String format(long value) {
|
||||
return String.format(Locale.US, "%,d", value);
|
||||
}
|
||||
|
||||
private static String formatDistance(long centimeters) {
|
||||
double meters = centimeters / 100.0;
|
||||
if (meters >= 1000.0) {
|
||||
return String.format(Locale.US, "%,.2f km", meters / 1000.0);
|
||||
}
|
||||
return String.format(Locale.US, "%,.1f m", meters);
|
||||
}
|
||||
|
||||
private static String formatDuration(long milliseconds) {
|
||||
Duration duration = Duration.ofMillis(Math.max(0L, milliseconds));
|
||||
long hours = duration.toHours();
|
||||
long minutes = duration.minusHours(hours).toMinutes();
|
||||
return hours + "h " + minutes + "m";
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command,
|
||||
@NotNull String alias, @NotNull String[] args) {
|
||||
if (args.length != 1) {
|
||||
return List.of();
|
||||
}
|
||||
String token = args[0].toLowerCase(Locale.ROOT);
|
||||
List<String> options = new ArrayList<>();
|
||||
if (sender.hasPermission("roseplayerstats.admin")) {
|
||||
options.add("save");
|
||||
options.add("reload");
|
||||
}
|
||||
if (sender.hasPermission("roseplayerstats.view.others")) {
|
||||
for (Player player : Bukkit.getOnlinePlayers()) {
|
||||
options.add(player.getName());
|
||||
}
|
||||
}
|
||||
options.removeIf(value -> !value.toLowerCase(Locale.ROOT).startsWith(token));
|
||||
options.sort(String.CASE_INSENSITIVE_ORDER);
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
package net.therosegarden.playerstats;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Statistic;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
import org.bukkit.event.enchantment.EnchantItemEvent;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.EntityPickupItemEvent;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryCloseEvent;
|
||||
import org.bukkit.event.inventory.InventoryOpenEvent;
|
||||
import io.papermc.paper.event.player.AsyncChatEvent;
|
||||
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
|
||||
import org.bukkit.event.player.PlayerAdvancementDoneEvent;
|
||||
import org.bukkit.event.player.PlayerBedEnterEvent;
|
||||
import org.bukkit.event.player.PlayerBedLeaveEvent;
|
||||
import org.bukkit.event.player.PlayerBucketEmptyEvent;
|
||||
import org.bukkit.event.player.PlayerBucketFillEvent;
|
||||
import org.bukkit.event.player.PlayerChangedWorldEvent;
|
||||
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
|
||||
import org.bukkit.event.player.PlayerDropItemEvent;
|
||||
import org.bukkit.event.player.PlayerExpChangeEvent;
|
||||
import org.bukkit.event.player.PlayerFishEvent;
|
||||
import org.bukkit.event.player.PlayerGameModeChangeEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerItemBreakEvent;
|
||||
import org.bukkit.event.player.PlayerItemConsumeEvent;
|
||||
import org.bukkit.event.player.PlayerItemDamageEvent;
|
||||
import org.bukkit.event.player.PlayerItemMendEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerKickEvent;
|
||||
import org.bukkit.event.player.PlayerLevelChangeEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.event.player.PlayerRespawnEvent;
|
||||
import org.bukkit.event.player.PlayerStatisticIncrementEvent;
|
||||
import org.bukkit.event.player.PlayerSwapHandItemsEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.event.player.PlayerToggleFlightEvent;
|
||||
import org.bukkit.event.player.PlayerToggleSneakEvent;
|
||||
import org.bukkit.event.player.PlayerToggleSprintEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
final class PlayerStatsListener implements Listener {
|
||||
private static final PlainTextComponentSerializer PLAIN = PlainTextComponentSerializer.plainText();
|
||||
|
||||
private final RosePlayerStatsPlugin plugin;
|
||||
private final StatsService stats;
|
||||
private final Map<UUID, Long> sessionStarts = new ConcurrentHashMap<>();
|
||||
|
||||
PlayerStatsListener(RosePlayerStatsPlugin plugin, StatsService stats) {
|
||||
this.plugin = plugin;
|
||||
this.stats = stats;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPreLogin(AsyncPlayerPreLoginEvent event) {
|
||||
try {
|
||||
stats.preload(event.getUniqueId(), event.getName());
|
||||
} catch (Exception ex) {
|
||||
plugin.getLogger().warning("Could not preload statistics for " + event.getName() + " (" + event.getUniqueId() + "): " + ex.getMessage());
|
||||
stats.record(event.getUniqueId(), event.getName());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
stats.record(player);
|
||||
stats.increment(player, "sessions.joins");
|
||||
sessionStarts.put(player.getUniqueId(), System.currentTimeMillis());
|
||||
stats.journal(player, "join", "world=" + player.getWorld().getName() + ";location=" + location(player.getLocation()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onQuit(PlayerQuitEvent event) {
|
||||
finishSession(event.getPlayer(), "quit");
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onKick(PlayerKickEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
stats.increment(player, "sessions.kicks");
|
||||
stats.journal(player, "kick", "reason=" + safe(PLAIN.serialize(event.reason())));
|
||||
}
|
||||
|
||||
private void finishSession(Player player, String eventType) {
|
||||
Long started = sessionStarts.remove(player.getUniqueId());
|
||||
if (started != null) {
|
||||
stats.increment(player, "sessions.play_time_ms", Math.max(0L, System.currentTimeMillis() - started));
|
||||
}
|
||||
stats.journal(player, eventType, "world=" + player.getWorld().getName() + ";location=" + location(player.getLocation()));
|
||||
stats.savePlayerAsync(player);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onMove(PlayerMoveEvent event) {
|
||||
if (!plugin.getConfig().getBoolean("movement.enabled", true) || event instanceof PlayerTeleportEvent) {
|
||||
return;
|
||||
}
|
||||
Location from = event.getFrom();
|
||||
Location to = event.getTo();
|
||||
if (to == null || from.getWorld() != to.getWorld()) {
|
||||
return;
|
||||
}
|
||||
double dx = to.getX() - from.getX();
|
||||
double dy = to.getY() - from.getY();
|
||||
double dz = to.getZ() - from.getZ();
|
||||
double distance = Math.sqrt((dx * dx) + (dy * dy) + (dz * dz));
|
||||
double max = Math.max(1.0, plugin.getConfig().getDouble("movement.max-single-move-blocks", 12.0));
|
||||
if (distance <= 0.0 || distance > max) {
|
||||
return;
|
||||
}
|
||||
|
||||
Player player = event.getPlayer();
|
||||
long centimeters = Math.max(1L, Math.round(distance * 100.0));
|
||||
PlayerRecord record = stats.record(player);
|
||||
record.increment("movement.total_cm", centimeters);
|
||||
|
||||
String mode;
|
||||
boolean groundStep = false;
|
||||
if (player.isInsideVehicle()) {
|
||||
mode = "vehicle";
|
||||
} else if (player.isGliding()) {
|
||||
mode = "gliding";
|
||||
} else if (player.isFlying()) {
|
||||
mode = "flying";
|
||||
} else if (player.isSwimming()) {
|
||||
mode = "swimming";
|
||||
} else if (player.isSprinting()) {
|
||||
mode = "sprinting";
|
||||
groundStep = true;
|
||||
} else if (player.isSneaking()) {
|
||||
mode = "sneaking";
|
||||
groundStep = true;
|
||||
} else {
|
||||
mode = "walking";
|
||||
groundStep = true;
|
||||
}
|
||||
record.increment("movement." + mode + "_cm", centimeters);
|
||||
|
||||
if (groundStep) {
|
||||
long groundCm = record.increment("movement.ground_cm", centimeters);
|
||||
double stepLength = Math.max(1.0, plugin.getConfig().getDouble("movement.estimated-step-length-centimeters", 76.2));
|
||||
record.set("movement.estimated_steps", Math.round(groundCm / stepLength));
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onStatistic(PlayerStatisticIncrementEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
int change = event.getNewValue() - event.getPreviousValue();
|
||||
if (change <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
StringBuilder key = new StringBuilder("minecraft.").append(event.getStatistic().name().toLowerCase(Locale.ROOT));
|
||||
if (event.getMaterial() != null) {
|
||||
key.append('.').append(event.getMaterial().name().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
if (event.getEntityType() != null) {
|
||||
key.append('.').append(event.getEntityType().name().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
stats.increment(player, key.toString(), change);
|
||||
|
||||
if (plugin.getConfig().getBoolean("journal.log-statistic-increments", true)) {
|
||||
stats.journal(player, "statistic", "stat=" + key + ";change=" + change + ";new_value=" + event.getNewValue());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onBlockBreak(BlockBreakEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
Material material = event.getBlock().getType();
|
||||
stats.increment(player, "blocks.broken.total");
|
||||
stats.increment(player, "blocks.broken." + material.name());
|
||||
stats.journal(player, "block_break", "material=" + material + ";location=" + location(event.getBlock().getLocation()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onBlockPlace(BlockPlaceEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
Material material = event.getBlockPlaced().getType();
|
||||
stats.increment(player, "blocks.placed.total");
|
||||
stats.increment(player, "blocks.placed." + material.name());
|
||||
stats.journal(player, "block_place", "material=" + material + ";location=" + location(event.getBlockPlaced().getLocation()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onBucketEmpty(PlayerBucketEmptyEvent event) {
|
||||
stats.increment(event.getPlayer(), "buckets.emptied.total");
|
||||
stats.increment(event.getPlayer(), "buckets.emptied." + event.getBucket().name());
|
||||
stats.journal(event.getPlayer(), "bucket_empty", "bucket=" + event.getBucket() + ";location=" + location(event.getBlock().getLocation()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onBucketFill(PlayerBucketFillEvent event) {
|
||||
stats.increment(event.getPlayer(), "buckets.filled.total");
|
||||
stats.increment(event.getPlayer(), "buckets.filled." + event.getBucket().name());
|
||||
stats.journal(event.getPlayer(), "bucket_fill", "bucket=" + event.getBucket() + ";location=" + location(event.getBlock().getLocation()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onDeath(PlayerDeathEvent event) {
|
||||
Player player = event.getEntity();
|
||||
stats.increment(player, "combat.deaths");
|
||||
String cause = player.getLastDamageCause() == null ? "unknown" : player.getLastDamageCause().getCause().name();
|
||||
stats.increment(player, "combat.deaths_by." + cause);
|
||||
stats.journal(player, "death", "cause=" + cause + ";location=" + location(player.getLocation()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onDamage(EntityDamageEvent event) {
|
||||
if (!(event.getEntity() instanceof Player player)) {
|
||||
return;
|
||||
}
|
||||
long milliHearts = Math.max(0L, Math.round(event.getFinalDamage() * 500.0));
|
||||
stats.increment(player, "combat.damage_taken_millihearts", milliHearts);
|
||||
stats.increment(player, "combat.damage_taken_events");
|
||||
stats.increment(player, "combat.damage_taken_by." + event.getCause().name(), milliHearts);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onDamageByEntity(EntityDamageByEntityEvent event) {
|
||||
Player attacker = attackingPlayer(event.getDamager());
|
||||
if (attacker == null || attacker.equals(event.getEntity())) {
|
||||
return;
|
||||
}
|
||||
long milliHearts = Math.max(0L, Math.round(event.getFinalDamage() * 500.0));
|
||||
stats.increment(attacker, "combat.damage_dealt_millihearts", milliHearts);
|
||||
stats.increment(attacker, "combat.damage_dealt_events");
|
||||
stats.increment(attacker, "combat.damage_dealt_to." + event.getEntityType().name(), milliHearts);
|
||||
}
|
||||
|
||||
private Player attackingPlayer(Entity damager) {
|
||||
if (damager instanceof Player player) {
|
||||
return player;
|
||||
}
|
||||
if (damager instanceof org.bukkit.entity.Projectile projectile && projectile.getShooter() instanceof Player player) {
|
||||
return player;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onPickup(EntityPickupItemEvent event) {
|
||||
if (!(event.getEntity() instanceof Player player)) {
|
||||
return;
|
||||
}
|
||||
ItemStack item = event.getItem().getItemStack();
|
||||
stats.increment(player, "items.picked_up.total", item.getAmount());
|
||||
stats.increment(player, "items.picked_up." + item.getType().name(), item.getAmount());
|
||||
stats.journal(player, "item_pickup", "material=" + item.getType() + ";amount=" + item.getAmount());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onDrop(PlayerDropItemEvent event) {
|
||||
ItemStack item = event.getItemDrop().getItemStack();
|
||||
stats.increment(event.getPlayer(), "items.dropped.total", item.getAmount());
|
||||
stats.increment(event.getPlayer(), "items.dropped." + item.getType().name(), item.getAmount());
|
||||
stats.journal(event.getPlayer(), "item_drop", "material=" + item.getType() + ";amount=" + item.getAmount());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onConsume(PlayerItemConsumeEvent event) {
|
||||
stats.increment(event.getPlayer(), "items.consumed.total");
|
||||
stats.increment(event.getPlayer(), "items.consumed." + event.getItem().getType().name());
|
||||
stats.journal(event.getPlayer(), "item_consume", "material=" + event.getItem().getType());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onCraft(CraftItemEvent event) {
|
||||
if (!(event.getWhoClicked() instanceof Player player) || event.getCurrentItem() == null) {
|
||||
return;
|
||||
}
|
||||
Material type = event.getCurrentItem().getType();
|
||||
stats.increment(player, "crafts.total");
|
||||
stats.increment(player, "crafts." + type.name());
|
||||
stats.journal(player, "craft", "material=" + type + ";amount=" + event.getCurrentItem().getAmount());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onEnchant(EnchantItemEvent event) {
|
||||
Player player = event.getEnchanter();
|
||||
stats.increment(player, "enchantments.total");
|
||||
stats.increment(player, "enchantments.levels_spent", event.getExpLevelCost());
|
||||
stats.journal(player, "enchant", "material=" + event.getItem().getType() + ";level_cost=" + event.getExpLevelCost());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onInventoryOpen(InventoryOpenEvent event) {
|
||||
if (event.getPlayer() instanceof Player player) {
|
||||
stats.increment(player, "inventory.opens.total");
|
||||
stats.increment(player, "inventory.opens." + event.getInventory().getType().name());
|
||||
stats.journal(player, "inventory_open", "type=" + event.getInventory().getType());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onInventoryClose(InventoryCloseEvent event) {
|
||||
if (event.getPlayer() instanceof Player player) {
|
||||
stats.increment(player, "inventory.closes.total");
|
||||
stats.journal(player, "inventory_close", "type=" + event.getInventory().getType());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onInventoryClick(InventoryClickEvent event) {
|
||||
if (event.getWhoClicked() instanceof Player player) {
|
||||
stats.increment(player, "inventory.clicks.total");
|
||||
stats.increment(player, "inventory.clicks." + event.getClick().name());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onInteract(PlayerInteractEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
stats.increment(player, "interactions.total");
|
||||
stats.increment(player, "interactions." + event.getAction().name());
|
||||
if (event.getClickedBlock() != null) {
|
||||
stats.increment(player, "interactions.block." + event.getClickedBlock().getType().name());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onInteractEntity(PlayerInteractEntityEvent event) {
|
||||
stats.increment(event.getPlayer(), "interactions.entity.total");
|
||||
stats.increment(event.getPlayer(), "interactions.entity." + event.getRightClicked().getType().name());
|
||||
stats.journal(event.getPlayer(), "entity_interact", "entity=" + event.getRightClicked().getType());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onCommand(PlayerCommandPreprocessEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
String raw = event.getMessage().startsWith("/") ? event.getMessage().substring(1) : event.getMessage();
|
||||
String command = raw.isBlank() ? "unknown" : raw.split("\\s+", 2)[0].toLowerCase(Locale.ROOT);
|
||||
stats.increment(player, "commands.total");
|
||||
stats.increment(player, "commands." + command);
|
||||
|
||||
if (plugin.getConfig().getBoolean("journal.log-command-content", true)) {
|
||||
Set<String> redacted = Set.copyOf(plugin.getConfig().getStringList("journal.redact-command-arguments").stream()
|
||||
.map(value -> value.toLowerCase(Locale.ROOT)).toList());
|
||||
String detail = redacted.contains(command) && raw.contains(" ") ? command + " <redacted>" : raw;
|
||||
stats.journal(player, "command", detail);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onChat(AsyncChatEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
stats.increment(player, "chat.messages");
|
||||
String message = PLAIN.serialize(event.message());
|
||||
stats.increment(player, "chat.characters", message.length());
|
||||
if (plugin.getConfig().getBoolean("journal.log-chat-content", true)) {
|
||||
stats.journal(player, "chat", message);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onAdvancement(PlayerAdvancementDoneEvent event) {
|
||||
stats.increment(event.getPlayer(), "advancements.total");
|
||||
stats.increment(event.getPlayer(), "advancements." + event.getAdvancement().getKey());
|
||||
stats.journal(event.getPlayer(), "advancement", event.getAdvancement().getKey().toString());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onTeleport(PlayerTeleportEvent event) {
|
||||
stats.increment(event.getPlayer(), "teleports.total");
|
||||
stats.increment(event.getPlayer(), "teleports." + event.getCause().name());
|
||||
stats.journal(event.getPlayer(), "teleport", "cause=" + event.getCause() + ";from=" + location(event.getFrom()) + ";to=" + location(event.getTo()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onChangedWorld(PlayerChangedWorldEvent event) {
|
||||
stats.increment(event.getPlayer(), "world_changes.total");
|
||||
stats.journal(event.getPlayer(), "world_change", "from=" + event.getFrom().getName() + ";to=" + event.getPlayer().getWorld().getName());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onRespawn(PlayerRespawnEvent event) {
|
||||
stats.increment(event.getPlayer(), "respawns.total");
|
||||
stats.journal(event.getPlayer(), "respawn", "location=" + location(event.getRespawnLocation()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onGameMode(PlayerGameModeChangeEvent event) {
|
||||
if (event.isCancelled()) {
|
||||
return;
|
||||
}
|
||||
stats.increment(event.getPlayer(), "gamemode_changes.total");
|
||||
stats.increment(event.getPlayer(), "gamemode_changes.to." + event.getNewGameMode().name());
|
||||
stats.journal(event.getPlayer(), "gamemode", "to=" + event.getNewGameMode());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onLevel(PlayerLevelChangeEvent event) {
|
||||
stats.increment(event.getPlayer(), "experience.level_change_events");
|
||||
if (event.getNewLevel() > event.getOldLevel()) {
|
||||
stats.increment(event.getPlayer(), "experience.levels_gained", event.getNewLevel() - event.getOldLevel());
|
||||
}
|
||||
stats.set(event.getPlayer(), "experience.current_level", event.getNewLevel());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onExperience(PlayerExpChangeEvent event) {
|
||||
if (event.getAmount() > 0) {
|
||||
stats.increment(event.getPlayer(), "experience.points_gained", event.getAmount());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onFish(PlayerFishEvent event) {
|
||||
stats.increment(event.getPlayer(), "fishing.events.total");
|
||||
stats.increment(event.getPlayer(), "fishing." + event.getState().name());
|
||||
if (event.getCaught() != null) {
|
||||
stats.journal(event.getPlayer(), "fish", "state=" + event.getState() + ";caught=" + event.getCaught().getType());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onBedEnter(PlayerBedEnterEvent event) {
|
||||
stats.increment(event.getPlayer(), "beds.enter_attempts");
|
||||
stats.increment(event.getPlayer(), "beds.enter_action." + event.enterAction().toString());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onBedLeave(PlayerBedLeaveEvent event) {
|
||||
stats.increment(event.getPlayer(), "beds.leaves");
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onItemDamage(PlayerItemDamageEvent event) {
|
||||
stats.increment(event.getPlayer(), "items.durability_damage", event.getDamage());
|
||||
stats.increment(event.getPlayer(), "items.durability_damage." + event.getItem().getType().name(), event.getDamage());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onItemBreak(PlayerItemBreakEvent event) {
|
||||
stats.increment(event.getPlayer(), "items.broken.total");
|
||||
stats.increment(event.getPlayer(), "items.broken." + event.getBrokenItem().getType().name());
|
||||
stats.journal(event.getPlayer(), "item_break", "material=" + event.getBrokenItem().getType());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onItemMend(PlayerItemMendEvent event) {
|
||||
stats.increment(event.getPlayer(), "items.mended.total");
|
||||
stats.increment(event.getPlayer(), "items.mended_durability", event.getRepairAmount());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onSwapHands(PlayerSwapHandItemsEvent event) {
|
||||
stats.increment(event.getPlayer(), "controls.swap_hands");
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onToggleSneak(PlayerToggleSneakEvent event) {
|
||||
if (event.isSneaking()) {
|
||||
stats.increment(event.getPlayer(), "controls.sneak_started");
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onToggleSprint(PlayerToggleSprintEvent event) {
|
||||
if (event.isSprinting()) {
|
||||
stats.increment(event.getPlayer(), "controls.sprint_started");
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onToggleFlight(PlayerToggleFlightEvent event) {
|
||||
stats.increment(event.getPlayer(), "controls.flight_toggles");
|
||||
}
|
||||
|
||||
private static String location(Location location) {
|
||||
if (location == null || location.getWorld() == null) {
|
||||
return "unknown";
|
||||
}
|
||||
return location.getWorld().getName() + ":" + location.getBlockX() + "," + location.getBlockY() + "," + location.getBlockZ();
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
return value == null ? "" : value.replace('\n', ' ').replace('\r', ' ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package net.therosegarden.playerstats;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
public final class RosePlayerStatsPlugin extends JavaPlugin {
|
||||
private static final LegacyComponentSerializer AMPERSAND_COLORS = LegacyComponentSerializer.legacyAmpersand();
|
||||
private static final LegacyComponentSerializer SECTION_COLORS = LegacyComponentSerializer.legacySection();
|
||||
|
||||
private StatsService stats;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
saveDefaultConfig();
|
||||
|
||||
try {
|
||||
StatsStorage storage = createStorage();
|
||||
stats = new StatsService(this, storage);
|
||||
} catch (Exception ex) {
|
||||
getLogger().severe("Could not initialize RosePlayerStats storage: " + ex.getMessage());
|
||||
getLogger().severe("RosePlayerStats will be disabled rather than writing statistics to an unintended backend.");
|
||||
Bukkit.getPluginManager().disablePlugin(this);
|
||||
return;
|
||||
}
|
||||
|
||||
Bukkit.getPluginManager().registerEvents(new PlayerStatsListener(this, stats), this);
|
||||
|
||||
PluginCommand command = Objects.requireNonNull(getCommand("playerstats"), "playerstats command missing from plugin.yml");
|
||||
PlayerStatsCommand commandHandler = new PlayerStatsCommand(this, stats);
|
||||
command.setExecutor(commandHandler);
|
||||
command.setTabCompleter(commandHandler);
|
||||
|
||||
long autosaveSeconds = Math.max(10L, getConfig().getLong("storage.autosave-seconds", 60L));
|
||||
long autosaveTicks = autosaveSeconds * 20L;
|
||||
Bukkit.getScheduler().runTaskTimerAsynchronously(this, () -> {
|
||||
stats.saveAll();
|
||||
stats.flushJournal();
|
||||
}, autosaveTicks, autosaveTicks);
|
||||
|
||||
Bukkit.getScheduler().runTaskTimerAsynchronously(this, stats::flushJournal, 20L, 20L);
|
||||
|
||||
for (var player : Bukkit.getOnlinePlayers()) {
|
||||
try {
|
||||
stats.preload(player.getUniqueId(), player.getName());
|
||||
} catch (Exception ex) {
|
||||
getLogger().warning("Could not preload already-online player " + player.getName() + ": " + ex.getMessage());
|
||||
stats.record(player);
|
||||
}
|
||||
}
|
||||
|
||||
getLogger().info("RosePlayerStats enabled using " + getConfig().getString("storage.type", "flatfile")
|
||||
+ " storage. Player name and UUID are recorded with statistics and journal events.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (stats != null) {
|
||||
stats.close();
|
||||
}
|
||||
}
|
||||
|
||||
private StatsStorage createStorage() throws Exception {
|
||||
String type = getConfig().getString("storage.type", "flatfile").trim().toLowerCase(java.util.Locale.ROOT);
|
||||
boolean journalEnabled = getConfig().getBoolean("journal.enabled", true);
|
||||
|
||||
if (type.equals("flatfile")) {
|
||||
Path dataDirectory = getDataFolder().toPath();
|
||||
return new FlatFileStatsStorage(dataDirectory, journalEnabled);
|
||||
}
|
||||
if (type.equals("mysql") || type.equals("mariadb")) {
|
||||
String path = "storage.sql.";
|
||||
return new SqlStatsStorage(
|
||||
getConfig().getString(path + "host", "127.0.0.1"),
|
||||
Math.max(1, getConfig().getInt(path + "port", 3306)),
|
||||
getConfig().getString(path + "database", "minecraft"),
|
||||
getConfig().getString(path + "username", "playerstats"),
|
||||
getConfig().getString(path + "password", ""),
|
||||
getConfig().getString(path + "parameters", "useUnicode=true&characterEncoding=utf8&useSSL=false"),
|
||||
journalEnabled
|
||||
);
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported storage.type '" + type + "'. Use flatfile, mysql, or mariadb.");
|
||||
}
|
||||
|
||||
String prefix() {
|
||||
return color(getConfig().getString("messages.prefix", "&8[&dPlayerStats&8] &r"));
|
||||
}
|
||||
|
||||
String color(String text) {
|
||||
return SECTION_COLORS.serialize(AMPERSAND_COLORS.deserialize(text == null ? "" : text));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package net.therosegarden.playerstats;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
|
||||
final class SqlStatsStorage implements StatsStorage {
|
||||
private final String url;
|
||||
private final String username;
|
||||
private final String password;
|
||||
private final boolean journalEnabled;
|
||||
private Connection connection;
|
||||
|
||||
SqlStatsStorage(String host, int port, String database, String username,
|
||||
String password, String parameters, boolean journalEnabled) throws Exception {
|
||||
this.url = "jdbc:mariadb://" + host + ":" + port + "/" + database
|
||||
+ (parameters == null || parameters.isBlank() ? "" : "?" + parameters);
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.journalEnabled = journalEnabled;
|
||||
Class.forName("org.mariadb.jdbc.Driver");
|
||||
ensureConnection();
|
||||
initializeSchema();
|
||||
}
|
||||
|
||||
private synchronized Connection ensureConnection() throws SQLException {
|
||||
if (connection == null || connection.isClosed() || !connection.isValid(3)) {
|
||||
closeQuietly();
|
||||
connection = DriverManager.getConnection(url, username, password);
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
private void initializeSchema() throws SQLException {
|
||||
Connection db = ensureConnection();
|
||||
try (Statement statement = db.createStatement()) {
|
||||
statement.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS rose_players (
|
||||
uuid CHAR(36) NOT NULL PRIMARY KEY,
|
||||
last_name VARCHAR(64) NOT NULL,
|
||||
first_seen BIGINT NOT NULL,
|
||||
last_seen BIGINT NOT NULL,
|
||||
INDEX idx_player_name (last_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""");
|
||||
statement.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS rose_player_stats (
|
||||
uuid CHAR(36) NOT NULL,
|
||||
player_name VARCHAR(64) NOT NULL,
|
||||
stat_key VARCHAR(190) NOT NULL,
|
||||
stat_value BIGINT NOT NULL,
|
||||
PRIMARY KEY (uuid, stat_key),
|
||||
INDEX idx_stats_player_name (player_name),
|
||||
INDEX idx_stat_key (stat_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""");
|
||||
statement.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS rose_player_events (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
event_time BIGINT NOT NULL,
|
||||
uuid CHAR(36) NOT NULL,
|
||||
player_name VARCHAR(64) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
INDEX idx_event_player_time (uuid, event_time),
|
||||
INDEX idx_event_player_name (player_name),
|
||||
INDEX idx_event_type_time (event_type, event_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized PlayerRecord load(UUID uuid, String currentName) throws Exception {
|
||||
Connection db = ensureConnection();
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("meta.uuid", uuid.toString());
|
||||
properties.setProperty("meta.last_name", currentName == null ? "unknown" : currentName);
|
||||
long now = System.currentTimeMillis();
|
||||
properties.setProperty("meta.first_seen", Long.toString(now));
|
||||
properties.setProperty("meta.last_seen", Long.toString(now));
|
||||
|
||||
try (PreparedStatement statement = db.prepareStatement(
|
||||
"SELECT last_name, first_seen, last_seen FROM rose_players WHERE uuid=?")) {
|
||||
statement.setString(1, uuid.toString());
|
||||
try (ResultSet result = statement.executeQuery()) {
|
||||
if (result.next()) {
|
||||
properties.setProperty("meta.last_name", result.getString(1));
|
||||
properties.setProperty("meta.first_seen", Long.toString(result.getLong(2)));
|
||||
properties.setProperty("meta.last_seen", Long.toString(result.getLong(3)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try (PreparedStatement statement = db.prepareStatement(
|
||||
"SELECT stat_key, stat_value FROM rose_player_stats WHERE uuid=?")) {
|
||||
statement.setString(1, uuid.toString());
|
||||
try (ResultSet result = statement.executeQuery()) {
|
||||
while (result.next()) {
|
||||
properties.setProperty("counter." + result.getString(1), Long.toString(result.getLong(2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlayerRecord record = PlayerRecord.fromProperties(properties);
|
||||
record.touch(currentName, now);
|
||||
return record;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void save(PlayerRecord record) throws Exception {
|
||||
Connection db = ensureConnection();
|
||||
boolean previousAutoCommit = db.getAutoCommit();
|
||||
db.setAutoCommit(false);
|
||||
try {
|
||||
try (PreparedStatement statement = db.prepareStatement("""
|
||||
INSERT INTO rose_players (uuid, last_name, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE last_name=VALUES(last_name), first_seen=LEAST(first_seen, VALUES(first_seen)), last_seen=VALUES(last_seen)
|
||||
""")) {
|
||||
statement.setString(1, record.uuid().toString());
|
||||
statement.setString(2, record.lastName());
|
||||
statement.setLong(3, record.firstSeen());
|
||||
statement.setLong(4, record.lastSeen());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
|
||||
try (PreparedStatement statement = db.prepareStatement("""
|
||||
INSERT INTO rose_player_stats (uuid, player_name, stat_key, stat_value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE player_name=VALUES(player_name), stat_value=VALUES(stat_value)
|
||||
""")) {
|
||||
for (Map.Entry<String, Long> entry : record.countersSnapshot().entrySet()) {
|
||||
statement.setString(1, record.uuid().toString());
|
||||
statement.setString(2, record.lastName());
|
||||
statement.setString(3, entry.getKey());
|
||||
statement.setLong(4, entry.getValue());
|
||||
statement.addBatch();
|
||||
}
|
||||
statement.executeBatch();
|
||||
}
|
||||
db.commit();
|
||||
} catch (SQLException ex) {
|
||||
db.rollback();
|
||||
throw ex;
|
||||
} finally {
|
||||
db.setAutoCommit(previousAutoCommit);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void journal(UUID uuid, String playerName, String eventType, String detail) throws Exception {
|
||||
if (!journalEnabled) {
|
||||
return;
|
||||
}
|
||||
Connection db = ensureConnection();
|
||||
try (PreparedStatement statement = db.prepareStatement(
|
||||
"INSERT INTO rose_player_events (event_time, uuid, player_name, event_type, detail) VALUES (?, ?, ?, ?, ?)")) {
|
||||
statement.setLong(1, System.currentTimeMillis());
|
||||
statement.setString(2, uuid.toString());
|
||||
statement.setString(3, playerName == null ? "unknown" : playerName);
|
||||
statement.setString(4, eventType);
|
||||
statement.setString(5, detail == null ? "" : detail);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() throws IOException {
|
||||
try {
|
||||
closeQuietly();
|
||||
} catch (Exception ex) {
|
||||
throw new IOException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void closeQuietly() {
|
||||
if (connection != null) {
|
||||
try {
|
||||
connection.close();
|
||||
} catch (SQLException ignored) {
|
||||
}
|
||||
connection = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package net.therosegarden.playerstats;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class StatsService {
|
||||
private final RosePlayerStatsPlugin plugin;
|
||||
private final StatsStorage storage;
|
||||
private final ConcurrentHashMap<UUID, PlayerRecord> records = new ConcurrentHashMap<>();
|
||||
private final ConcurrentLinkedQueue<JournalEntry> journalQueue = new ConcurrentLinkedQueue<>();
|
||||
|
||||
StatsService(RosePlayerStatsPlugin plugin, StatsStorage storage) {
|
||||
this.plugin = plugin;
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
void preload(UUID uuid, String name) throws Exception {
|
||||
records.put(uuid, storage.load(uuid, name));
|
||||
}
|
||||
|
||||
PlayerRecord record(Player player) {
|
||||
long now = System.currentTimeMillis();
|
||||
PlayerRecord record = records.computeIfAbsent(player.getUniqueId(),
|
||||
id -> new PlayerRecord(id, player.getName(), now));
|
||||
record.touch(player.getName(), now);
|
||||
return record;
|
||||
}
|
||||
|
||||
PlayerRecord record(UUID uuid, String name) {
|
||||
long now = System.currentTimeMillis();
|
||||
PlayerRecord record = records.computeIfAbsent(uuid, id -> new PlayerRecord(id, name, now));
|
||||
record.touch(name, now);
|
||||
return record;
|
||||
}
|
||||
|
||||
PlayerRecord findLoaded(String nameOrUuid) {
|
||||
try {
|
||||
PlayerRecord byUuid = records.get(UUID.fromString(nameOrUuid));
|
||||
if (byUuid != null) {
|
||||
return byUuid;
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
for (PlayerRecord record : records.values()) {
|
||||
if (record.lastName().equalsIgnoreCase(nameOrUuid)) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void increment(Player player, String key) {
|
||||
increment(player, key, 1L);
|
||||
}
|
||||
|
||||
void increment(Player player, String key, long amount) {
|
||||
record(player).increment(normalizeKey(key), amount);
|
||||
}
|
||||
|
||||
void set(Player player, String key, long value) {
|
||||
record(player).set(normalizeKey(key), value);
|
||||
}
|
||||
|
||||
void journal(Player player, String eventType, String detail) {
|
||||
if (!plugin.getConfig().getBoolean("journal.enabled", true)) {
|
||||
return;
|
||||
}
|
||||
record(player);
|
||||
journalQueue.add(new JournalEntry(player.getUniqueId(), player.getName(), normalizeKey(eventType), detail));
|
||||
}
|
||||
|
||||
void save(PlayerRecord record) {
|
||||
try {
|
||||
storage.save(record);
|
||||
} catch (Exception ex) {
|
||||
plugin.getLogger().severe("Failed saving statistics for " + record.lastName() + ": " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
void saveAll() {
|
||||
try {
|
||||
storage.saveAll(new ArrayList<>(records.values()));
|
||||
} catch (Exception ex) {
|
||||
plugin.getLogger().severe("Failed saving player statistics: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
void flushJournal() {
|
||||
JournalEntry entry;
|
||||
int processed = 0;
|
||||
while ((entry = journalQueue.poll()) != null) {
|
||||
try {
|
||||
storage.journal(entry.uuid(), entry.playerName(), entry.eventType(), entry.detail());
|
||||
} catch (Exception ex) {
|
||||
journalQueue.add(entry);
|
||||
plugin.getLogger().severe("Failed writing player event journal: " + ex.getMessage());
|
||||
break;
|
||||
}
|
||||
processed++;
|
||||
if (processed >= 5000) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void savePlayerAsync(Player player) {
|
||||
PlayerRecord record = record(player);
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> save(record));
|
||||
}
|
||||
|
||||
Collection<PlayerRecord> records() {
|
||||
return records.values();
|
||||
}
|
||||
|
||||
void close() {
|
||||
saveAll();
|
||||
flushJournal();
|
||||
try {
|
||||
storage.close();
|
||||
} catch (Exception ex) {
|
||||
plugin.getLogger().warning("Failed closing statistics storage: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
static String normalizeKey(String key) {
|
||||
if (key == null || key.isBlank()) {
|
||||
return "unknown";
|
||||
}
|
||||
return key.toLowerCase(Locale.ROOT)
|
||||
.replace(' ', '_')
|
||||
.replace(':', '.')
|
||||
.replaceAll("[^a-z0-9._-]", "_");
|
||||
}
|
||||
|
||||
private record JournalEntry(UUID uuid, String playerName, String eventType, String detail) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package net.therosegarden.playerstats;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.util.Collection;
|
||||
import java.util.UUID;
|
||||
|
||||
interface StatsStorage extends Closeable {
|
||||
PlayerRecord load(UUID uuid, String currentName) throws Exception;
|
||||
|
||||
void save(PlayerRecord record) throws Exception;
|
||||
|
||||
default void saveAll(Collection<PlayerRecord> records) throws Exception {
|
||||
for (PlayerRecord record : records) {
|
||||
save(record);
|
||||
}
|
||||
}
|
||||
|
||||
void journal(UUID uuid, String playerName, String eventType, String detail) throws Exception;
|
||||
|
||||
@Override
|
||||
default void close() throws java.io.IOException {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# RosePlayerStats
|
||||
# Comprehensive player statistics and event journaling.
|
||||
|
||||
storage:
|
||||
# flatfile, mysql, or mariadb
|
||||
type: flatfile
|
||||
autosave-seconds: 60
|
||||
|
||||
sql:
|
||||
host: 127.0.0.1
|
||||
port: 3306
|
||||
database: minecraft
|
||||
username: playerstats
|
||||
password: change-me
|
||||
# Optional JDBC parameters appended to the connection URL.
|
||||
parameters: 'useUnicode=true&characterEncoding=utf8&useSSL=false'
|
||||
|
||||
movement:
|
||||
enabled: true
|
||||
# Minecraft does not expose literal footfall counts. RosePlayerStats records
|
||||
# movement distance and estimates steps using this average step length.
|
||||
estimated-step-length-centimeters: 76.2
|
||||
# Ignore larger deltas as teleports/server corrections.
|
||||
max-single-move-blocks: 12.0
|
||||
|
||||
journal:
|
||||
enabled: true
|
||||
# Detailed event history is JSONL for flatfile storage and rows in
|
||||
# rose_player_events for MySQL/MariaDB.
|
||||
log-statistic-increments: true
|
||||
log-chat-content: true
|
||||
log-command-content: true
|
||||
|
||||
# These commands are still counted, but their arguments are replaced with
|
||||
# <redacted> so authentication data is not stored in the event journal.
|
||||
redact-command-arguments:
|
||||
- login
|
||||
- register
|
||||
- changepassword
|
||||
- password
|
||||
- 2fa
|
||||
- otp
|
||||
- token
|
||||
- auth
|
||||
|
||||
messages:
|
||||
prefix: '&8[&dPlayerStats&8] &r'
|
||||
@@ -0,0 +1,20 @@
|
||||
name: RosePlayerStats
|
||||
version: '${version}'
|
||||
main: net.therosegarden.playerstats.RosePlayerStatsPlugin
|
||||
api-version: '26.2'
|
||||
description: Comprehensive player statistics and event journaling for The Rose Garden.
|
||||
commands:
|
||||
playerstats:
|
||||
description: View or manage RosePlayerStats data.
|
||||
aliases: [pstats, stats]
|
||||
usage: /playerstats [player|save|reload]
|
||||
permissions:
|
||||
roseplayerstats.view:
|
||||
description: View your own player statistics.
|
||||
default: true
|
||||
roseplayerstats.view.others:
|
||||
description: View another player's statistics.
|
||||
default: op
|
||||
roseplayerstats.admin:
|
||||
description: Save and reload RosePlayerStats.
|
||||
default: op
|
||||
Reference in New Issue
Block a user