feat: add comprehensive player statistics logging

This commit is contained in:
Michael Burgess
2026-08-17 12:13:40 -04:00
commit 180a55a72d
18 changed files with 1918 additions and 0 deletions
@@ -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 {
}
}
+47
View File
@@ -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'
+20
View File
@@ -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