From 3ded7545e4f3c9d7db6547f6c1967b180bc21777 Mon Sep 17 00:00:00 2001 From: Michael Burgess Date: Tue, 18 Aug 2026 12:13:06 -0400 Subject: [PATCH] Exclude NPCs from player statistics --- .../playerstats/FlatFileStatsStorage.java | 6 ++ .../playerstats/PlayerStatsListener.java | 9 +++ .../playerstats/RosePlayerStatsPlugin.java | 46 +++++++++++++++ .../playerstats/SqlStatsStorage.java | 29 ++++++++++ .../playerstats/StatsService.java | 58 +++++++++++++++++++ .../playerstats/StatsStorage.java | 2 + src/main/resources/plugin.yml | 1 + 7 files changed, 151 insertions(+) diff --git a/src/main/java/net/therosegarden/playerstats/FlatFileStatsStorage.java b/src/main/java/net/therosegarden/playerstats/FlatFileStatsStorage.java index a9c6f27..2fd2a2a 100644 --- a/src/main/java/net/therosegarden/playerstats/FlatFileStatsStorage.java +++ b/src/main/java/net/therosegarden/playerstats/FlatFileStatsStorage.java @@ -60,6 +60,12 @@ final class FlatFileStatsStorage implements StatsStorage { } } + @Override + public void delete(UUID uuid) throws Exception { + Files.deleteIfExists(playersDirectory.resolve(uuid + ".properties")); + Files.deleteIfExists(playersDirectory.resolve(uuid + ".properties.tmp")); + } + @Override public synchronized void journal(UUID uuid, String playerName, String eventType, String detail) throws Exception { if (!journalEnabled) { diff --git a/src/main/java/net/therosegarden/playerstats/PlayerStatsListener.java b/src/main/java/net/therosegarden/playerstats/PlayerStatsListener.java index b02c7a2..9c2a7ef 100644 --- a/src/main/java/net/therosegarden/playerstats/PlayerStatsListener.java +++ b/src/main/java/net/therosegarden/playerstats/PlayerStatsListener.java @@ -86,6 +86,10 @@ final class PlayerStatsListener implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void onJoin(PlayerJoinEvent event) { Player player = event.getPlayer(); + if (stats.isNpc(player)) { + stats.purgeNpcAsync(player); + return; + } stats.record(player); stats.increment(player, "sessions.joins"); sessionStarts.put(player.getUniqueId(), System.currentTimeMillis()); @@ -105,6 +109,11 @@ final class PlayerStatsListener implements Listener { } private void finishSession(Player player, String eventType) { + if (stats.isNpc(player)) { + sessionStarts.remove(player.getUniqueId()); + stats.purgeNpcAsync(player); + return; + } Long started = sessionStarts.remove(player.getUniqueId()); if (started != null) { stats.increment(player, "sessions.play_time_ms", Math.max(0L, System.currentTimeMillis() - started)); diff --git a/src/main/java/net/therosegarden/playerstats/RosePlayerStatsPlugin.java b/src/main/java/net/therosegarden/playerstats/RosePlayerStatsPlugin.java index 51bd42c..a64cee3 100644 --- a/src/main/java/net/therosegarden/playerstats/RosePlayerStatsPlugin.java +++ b/src/main/java/net/therosegarden/playerstats/RosePlayerStatsPlugin.java @@ -2,6 +2,7 @@ package net.therosegarden.playerstats; import java.nio.file.Path; import java.util.Objects; +import java.util.UUID; import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import org.bukkit.Bukkit; import org.bukkit.command.PluginCommand; @@ -44,6 +45,10 @@ public final class RosePlayerStatsPlugin extends JavaPlugin { Bukkit.getScheduler().runTaskTimerAsynchronously(this, stats::flushJournal, 20L, 20L); for (var player : Bukkit.getOnlinePlayers()) { + if (stats.isNpc(player)) { + stats.purgeNpcAsync(player); + continue; + } try { stats.preload(player.getUniqueId(), player.getName()); } catch (Exception ex) { @@ -52,10 +57,51 @@ public final class RosePlayerStatsPlugin extends JavaPlugin { } } + Bukkit.getScheduler().runTaskLater(this, () -> { + purgeCitizensNpcData(); + for (var world : Bukkit.getWorlds()) { + for (var player : world.getEntitiesByClass(org.bukkit.entity.Player.class)) { + if (stats.isNpc(player)) { + stats.purgeNpcAsync(player); + } + } + } + }, 40L); + getLogger().info("RosePlayerStats enabled using " + getConfig().getString("storage.type", "flatfile") + " storage. Player name and UUID are recorded with statistics and journal events."); } + private void purgeCitizensNpcData() { + var citizens = Bukkit.getPluginManager().getPlugin("Citizens"); + if (citizens == null || !citizens.isEnabled()) { + return; + } + + try { + Class citizensApi = Class.forName("net.citizensnpcs.api.CitizensAPI"); + Object registry = citizensApi.getMethod("getNPCRegistry").invoke(null); + if (!(registry instanceof Iterable npcs)) { + getLogger().warning("Citizens NPC registry was not iterable; existing NPC statistics could not be purged by registry."); + return; + } + + Class npcType = Class.forName("net.citizensnpcs.api.npc.NPC"); + var getUniqueId = npcType.getMethod("getUniqueId"); + var getName = npcType.getMethod("getName"); + + for (Object npc : npcs) { + Object uuidValue = getUniqueId.invoke(npc); + if (uuidValue instanceof UUID uuid) { + Object nameValue = getName.invoke(npc); + stats.purgeNpcAsync(uuid, nameValue == null ? "unknown" : nameValue.toString()); + } + } + } catch (ReflectiveOperationException | LinkageError ex) { + getLogger().warning("Could not enumerate Citizens NPCs for statistics cleanup: " + ex.getMessage()); + } + } + @Override public void onDisable() { if (stats != null) { diff --git a/src/main/java/net/therosegarden/playerstats/SqlStatsStorage.java b/src/main/java/net/therosegarden/playerstats/SqlStatsStorage.java index 3a6c99a..d985068 100644 --- a/src/main/java/net/therosegarden/playerstats/SqlStatsStorage.java +++ b/src/main/java/net/therosegarden/playerstats/SqlStatsStorage.java @@ -191,6 +191,35 @@ final class SqlStatsStorage implements StatsStorage { } } + @Override + public synchronized void delete(UUID uuid) throws Exception { + Connection db = ensureConnection(); + boolean previousAutoCommit = db.getAutoCommit(); + db.setAutoCommit(false); + try { + try (PreparedStatement statement = db.prepareStatement( + "DELETE FROM rose_player_events WHERE uuid=?")) { + statement.setString(1, uuid.toString()); + statement.executeUpdate(); + } + try (PreparedStatement statement = db.prepareStatement( + "DELETE FROM rose_player_stats WHERE uuid=?")) { + statement.setString(1, uuid.toString()); + statement.executeUpdate(); + } + try (PreparedStatement statement = db.prepareStatement( + "DELETE FROM rose_players WHERE uuid=?")) { + statement.setString(1, uuid.toString()); + statement.executeUpdate(); + } + 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) { diff --git a/src/main/java/net/therosegarden/playerstats/StatsService.java b/src/main/java/net/therosegarden/playerstats/StatsService.java index d44e3e1..bf51388 100644 --- a/src/main/java/net/therosegarden/playerstats/StatsService.java +++ b/src/main/java/net/therosegarden/playerstats/StatsService.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; @@ -16,6 +17,7 @@ final class StatsService { private final StatsStorage storage; private final ConcurrentHashMap records = new ConcurrentHashMap<>(); private final ConcurrentLinkedQueue journalQueue = new ConcurrentLinkedQueue<>(); + private final Set npcUuids = ConcurrentHashMap.newKeySet(); StatsService(RosePlayerStatsPlugin plugin, StatsStorage storage) { this.plugin = plugin; @@ -27,6 +29,11 @@ final class StatsService { } PlayerRecord record(Player player) { + if (isNpc(player)) { + purgeNpcAsync(player); + return new PlayerRecord(player.getUniqueId(), player.getName(), System.currentTimeMillis()); + } + long now = System.currentTimeMillis(); PlayerRecord record = records.computeIfAbsent(player.getUniqueId(), id -> new PlayerRecord(id, player.getName(), now)); @@ -62,14 +69,26 @@ final class StatsService { } void increment(Player player, String key, long amount) { + if (isNpc(player)) { + purgeNpcAsync(player); + return; + } record(player).increment(normalizeKey(key), amount); } void set(Player player, String key, long value) { + if (isNpc(player)) { + purgeNpcAsync(player); + return; + } record(player).set(normalizeKey(key), value); } void journal(Player player, String eventType, String detail) { + if (isNpc(player)) { + purgeNpcAsync(player); + return; + } if (!plugin.getConfig().getBoolean("journal.enabled", true)) { return; } @@ -112,10 +131,49 @@ final class StatsService { } void savePlayerAsync(Player player) { + if (isNpc(player)) { + purgeNpcAsync(player); + return; + } PlayerRecord record = record(player); Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> save(record)); } + boolean isNpc(Player player) { + return player != null && player.hasMetadata("NPC"); + } + + void purgeNpcAsync(Player player) { + if (player == null || !isNpc(player)) { + return; + } + purgeNpcAsync(player.getUniqueId(), player.getName()); + } + + void purgeNpcAsync(UUID uuid, String playerName) { + if (uuid == null) { + return; + } + + records.remove(uuid); + journalQueue.removeIf(entry -> entry.uuid().equals(uuid)); + + if (!npcUuids.add(uuid)) { + return; + } + + String displayName = playerName == null || playerName.isBlank() ? "unknown" : playerName; + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + storage.delete(uuid); + plugin.getLogger().info("Removed NPC from player statistics: " + displayName + " (" + uuid + ")"); + } catch (Exception ex) { + npcUuids.remove(uuid); + plugin.getLogger().warning("Failed removing NPC statistics for " + displayName + " (" + uuid + "): " + ex.getMessage()); + } + }); + } + Collection records() { return records.values(); } diff --git a/src/main/java/net/therosegarden/playerstats/StatsStorage.java b/src/main/java/net/therosegarden/playerstats/StatsStorage.java index 1217ac1..358a4f7 100644 --- a/src/main/java/net/therosegarden/playerstats/StatsStorage.java +++ b/src/main/java/net/therosegarden/playerstats/StatsStorage.java @@ -9,6 +9,8 @@ interface StatsStorage extends Closeable { void save(PlayerRecord record) throws Exception; + void delete(UUID uuid) throws Exception; + default void saveAll(Collection records) throws Exception { for (PlayerRecord record : records) { save(record); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 5938dd6..78ac50e 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,6 +3,7 @@ version: '${version}' main: net.therosegarden.playerstats.RosePlayerStatsPlugin api-version: '26.2' description: Comprehensive player statistics and event journaling for The Rose Garden. +softdepend: [Citizens] commands: playerstats: description: View or manage RosePlayerStats data.