Exclude NPCs from player statistics

This commit is contained in:
Michael Burgess
2026-08-18 12:13:06 -04:00
parent ab901cbd83
commit 3ded7545e4
7 changed files with 151 additions and 0 deletions
@@ -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 @Override
public synchronized void journal(UUID uuid, String playerName, String eventType, String detail) throws Exception { public synchronized void journal(UUID uuid, String playerName, String eventType, String detail) throws Exception {
if (!journalEnabled) { if (!journalEnabled) {
@@ -86,6 +86,10 @@ final class PlayerStatsListener implements Listener {
@EventHandler(priority = EventPriority.MONITOR) @EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent event) { public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer(); Player player = event.getPlayer();
if (stats.isNpc(player)) {
stats.purgeNpcAsync(player);
return;
}
stats.record(player); stats.record(player);
stats.increment(player, "sessions.joins"); stats.increment(player, "sessions.joins");
sessionStarts.put(player.getUniqueId(), System.currentTimeMillis()); sessionStarts.put(player.getUniqueId(), System.currentTimeMillis());
@@ -105,6 +109,11 @@ final class PlayerStatsListener implements Listener {
} }
private void finishSession(Player player, String eventType) { 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()); Long started = sessionStarts.remove(player.getUniqueId());
if (started != null) { if (started != null) {
stats.increment(player, "sessions.play_time_ms", Math.max(0L, System.currentTimeMillis() - started)); stats.increment(player, "sessions.play_time_ms", Math.max(0L, System.currentTimeMillis() - started));
@@ -2,6 +2,7 @@ package net.therosegarden.playerstats;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Objects; import java.util.Objects;
import java.util.UUID;
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.command.PluginCommand; import org.bukkit.command.PluginCommand;
@@ -44,6 +45,10 @@ public final class RosePlayerStatsPlugin extends JavaPlugin {
Bukkit.getScheduler().runTaskTimerAsynchronously(this, stats::flushJournal, 20L, 20L); Bukkit.getScheduler().runTaskTimerAsynchronously(this, stats::flushJournal, 20L, 20L);
for (var player : Bukkit.getOnlinePlayers()) { for (var player : Bukkit.getOnlinePlayers()) {
if (stats.isNpc(player)) {
stats.purgeNpcAsync(player);
continue;
}
try { try {
stats.preload(player.getUniqueId(), player.getName()); stats.preload(player.getUniqueId(), player.getName());
} catch (Exception ex) { } 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") getLogger().info("RosePlayerStats enabled using " + getConfig().getString("storage.type", "flatfile")
+ " storage. Player name and UUID are recorded with statistics and journal events."); + " 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 @Override
public void onDisable() { public void onDisable() {
if (stats != null) { if (stats != null) {
@@ -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 @Override
public synchronized void journal(UUID uuid, String playerName, String eventType, String detail) throws Exception { public synchronized void journal(UUID uuid, String playerName, String eventType, String detail) throws Exception {
if (!journalEnabled) { if (!journalEnabled) {
@@ -4,6 +4,7 @@ import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentLinkedQueue;
@@ -16,6 +17,7 @@ final class StatsService {
private final StatsStorage storage; private final StatsStorage storage;
private final ConcurrentHashMap<UUID, PlayerRecord> records = new ConcurrentHashMap<>(); private final ConcurrentHashMap<UUID, PlayerRecord> records = new ConcurrentHashMap<>();
private final ConcurrentLinkedQueue<JournalEntry> journalQueue = new ConcurrentLinkedQueue<>(); private final ConcurrentLinkedQueue<JournalEntry> journalQueue = new ConcurrentLinkedQueue<>();
private final Set<UUID> npcUuids = ConcurrentHashMap.newKeySet();
StatsService(RosePlayerStatsPlugin plugin, StatsStorage storage) { StatsService(RosePlayerStatsPlugin plugin, StatsStorage storage) {
this.plugin = plugin; this.plugin = plugin;
@@ -27,6 +29,11 @@ final class StatsService {
} }
PlayerRecord record(Player player) { PlayerRecord record(Player player) {
if (isNpc(player)) {
purgeNpcAsync(player);
return new PlayerRecord(player.getUniqueId(), player.getName(), System.currentTimeMillis());
}
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
PlayerRecord record = records.computeIfAbsent(player.getUniqueId(), PlayerRecord record = records.computeIfAbsent(player.getUniqueId(),
id -> new PlayerRecord(id, player.getName(), now)); id -> new PlayerRecord(id, player.getName(), now));
@@ -62,14 +69,26 @@ final class StatsService {
} }
void increment(Player player, String key, long amount) { void increment(Player player, String key, long amount) {
if (isNpc(player)) {
purgeNpcAsync(player);
return;
}
record(player).increment(normalizeKey(key), amount); record(player).increment(normalizeKey(key), amount);
} }
void set(Player player, String key, long value) { void set(Player player, String key, long value) {
if (isNpc(player)) {
purgeNpcAsync(player);
return;
}
record(player).set(normalizeKey(key), value); record(player).set(normalizeKey(key), value);
} }
void journal(Player player, String eventType, String detail) { void journal(Player player, String eventType, String detail) {
if (isNpc(player)) {
purgeNpcAsync(player);
return;
}
if (!plugin.getConfig().getBoolean("journal.enabled", true)) { if (!plugin.getConfig().getBoolean("journal.enabled", true)) {
return; return;
} }
@@ -112,10 +131,49 @@ final class StatsService {
} }
void savePlayerAsync(Player player) { void savePlayerAsync(Player player) {
if (isNpc(player)) {
purgeNpcAsync(player);
return;
}
PlayerRecord record = record(player); PlayerRecord record = record(player);
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> save(record)); 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<PlayerRecord> records() { Collection<PlayerRecord> records() {
return records.values(); return records.values();
} }
@@ -9,6 +9,8 @@ interface StatsStorage extends Closeable {
void save(PlayerRecord record) throws Exception; void save(PlayerRecord record) throws Exception;
void delete(UUID uuid) throws Exception;
default void saveAll(Collection<PlayerRecord> records) throws Exception { default void saveAll(Collection<PlayerRecord> records) throws Exception {
for (PlayerRecord record : records) { for (PlayerRecord record : records) {
save(record); save(record);
+1
View File
@@ -3,6 +3,7 @@ version: '${version}'
main: net.therosegarden.playerstats.RosePlayerStatsPlugin main: net.therosegarden.playerstats.RosePlayerStatsPlugin
api-version: '26.2' api-version: '26.2'
description: Comprehensive player statistics and event journaling for The Rose Garden. description: Comprehensive player statistics and event journaling for The Rose Garden.
softdepend: [Citizens]
commands: commands:
playerstats: playerstats:
description: View or manage RosePlayerStats data. description: View or manage RosePlayerStats data.