From c8e85f186a02e167465729e1a275b3af91b37afa Mon Sep 17 00:00:00 2001 From: akastijn Date: Mon, 15 Jun 2026 22:56:27 +0200 Subject: [PATCH] Add player stats tracking with `StatService` and `/hg stats` command integration --- .../java/com/alttd/hunger_games/Main.java | 6 +- .../hunger_games/commands/BaseCommand.java | 6 +- .../commands/subcommands/Stats.java | 78 +++++++++++ .../alttd/hunger_games/config/Messages.java | 20 +++ .../data_objects/LifetimeStats.java | 36 ++++++ .../hunger_games/data_objects/RoundStats.java | 34 +++++ .../event_listeners/PlayerDamageListener.java | 22 +++- .../hunger_games/services/RoundService.java | 18 ++- .../hunger_games/services/StatService.java | 121 ++++++++++++++++++ 9 files changed, 331 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/alttd/hunger_games/commands/subcommands/Stats.java create mode 100644 src/main/java/com/alttd/hunger_games/data_objects/LifetimeStats.java create mode 100644 src/main/java/com/alttd/hunger_games/data_objects/RoundStats.java create mode 100644 src/main/java/com/alttd/hunger_games/services/StatService.java diff --git a/src/main/java/com/alttd/hunger_games/Main.java b/src/main/java/com/alttd/hunger_games/Main.java index a4ffde2..c1ef04b 100644 --- a/src/main/java/com/alttd/hunger_games/Main.java +++ b/src/main/java/com/alttd/hunger_games/Main.java @@ -24,6 +24,7 @@ public final class Main extends JavaPlugin { private PlayerService playerService; private PlayerTeleporterService playerTeleporterService; private LootService lootService; + private StatService statService; @Override public void onEnable() { @@ -40,6 +41,7 @@ public final class Main extends JavaPlugin { lootService = new LootService(); roundService = RoundService.createSingletonInstance(round, this, lootService); playerTeleporterService = PlayerTeleporterService.createSingletonInstance(); + statService = new StatService(this, round); playerService = PlayerService.createSingletonInstance(round, roundService, playerTeleporterService); } @@ -49,14 +51,14 @@ public final class Main extends JavaPlugin { } private void registerCommands() { - BaseCommand command = new BaseCommand(this, roundService, playerService, round); + BaseCommand command = new BaseCommand(this, roundService, playerService, round, statService); } private void registerEvents() { PluginManager pluginManager = getServer().getPluginManager(); pluginManager.registerEvents(new PlayerDisconnectListener(playerService), this); pluginManager.registerEvents(new PlayerJoinListener(playerService), this); - pluginManager.registerEvents(new PlayerDamageListener(roundService, playerService), this); + pluginManager.registerEvents(new PlayerDamageListener(roundService, playerService, statService), this); pluginManager.registerEvents(new ChestListener(roundService, lootService), this); } diff --git a/src/main/java/com/alttd/hunger_games/commands/BaseCommand.java b/src/main/java/com/alttd/hunger_games/commands/BaseCommand.java index 4bfaf5a..a908078 100644 --- a/src/main/java/com/alttd/hunger_games/commands/BaseCommand.java +++ b/src/main/java/com/alttd/hunger_games/commands/BaseCommand.java @@ -6,6 +6,7 @@ import com.alttd.hunger_games.config.Messages; import com.alttd.hunger_games.services.PlayerService; import com.alttd.hunger_games.services.Round; import com.alttd.hunger_games.services.RoundService; +import com.alttd.hunger_games.services.StatService; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -22,7 +23,7 @@ import java.util.stream.Collectors; public class BaseCommand implements CommandExecutor, TabExecutor { private final List subCommands; - public BaseCommand(Main main, RoundService roundService, PlayerService playerService, Round round) { + public BaseCommand(Main main, RoundService roundService, PlayerService playerService, Round round, StatService statService) { PluginCommand command = main.getCommand("hungergames"); if (command == null) { subCommands = null; @@ -38,7 +39,8 @@ public class BaseCommand implements CommandExecutor, TabExecutor { new RoundState(roundService), new Register(playerService), new StartRound(round, roundService), - new Stuck(main) + new Stuck(main), + new Stats(statService) )); } diff --git a/src/main/java/com/alttd/hunger_games/commands/subcommands/Stats.java b/src/main/java/com/alttd/hunger_games/commands/subcommands/Stats.java new file mode 100644 index 0000000..6475cb3 --- /dev/null +++ b/src/main/java/com/alttd/hunger_games/commands/subcommands/Stats.java @@ -0,0 +1,78 @@ +package com.alttd.hunger_games.commands.subcommands; + +import com.alttd.hunger_games.commands.SubCommand; +import com.alttd.hunger_games.config.Messages; +import com.alttd.hunger_games.data_objects.LifetimeStats; +import com.alttd.hunger_games.services.StatService; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class Stats extends SubCommand { + + private final StatService statService; + + public Stats(StatService statService) { + this.statService = statService; + } + + @Override + public boolean onCommand(CommandSender commandSender, String[] args) { + OfflinePlayer target; + if (args.length < 2) { + if (!(commandSender instanceof Player player)) { + commandSender.sendRichMessage(Messages.GENERIC.PLAYER_ONLY); + return true; + } + target = player; + } else { + target = Bukkit.getOfflinePlayerIfCached(args[1]); + if (target == null) { + target = Bukkit.getPlayer(args[1]); + } + if (target == null) { + commandSender.sendRichMessage(Messages.GENERIC.PLAYER_NOT_FOUND.replaceAll("", args[1])); + return true; + } + } + + LifetimeStats stats = statService.getOrCreateLifetimeStats(target.getUniqueId()); + + commandSender.sendRichMessage(Messages.STATS.STATS_FORMAT, + Placeholder.parsed("player", target.getName() != null ? target.getName() : "Unknown"), + Placeholder.parsed("kills", String.valueOf(stats.getTotalKills())), + Placeholder.parsed("deaths", String.valueOf(stats.getTotalDeaths())), + Placeholder.parsed("damage_dealt", String.format("%.2f", stats.getTotalDamageDealt())), + Placeholder.parsed("damage_taken", String.format("%.2f", stats.getTotalDamageTaken())), + Placeholder.parsed("wins", String.valueOf(stats.getTotalWins())), + Placeholder.parsed("rounds_played", String.valueOf(stats.getTotalRoundsPlayed())), + Placeholder.parsed("kd_ratio", String.format("%.2f", stats.getKDRatio())) + ); + + return true; + } + + @Override + public String getName() { + return "stats"; + } + + @Override + public List getTabComplete(CommandSender commandSender, String[] args) { + if (args.length == 2) { + return Bukkit.getOnlinePlayers().stream().map(Player::getName).collect(Collectors.toList()); + } + return Collections.emptyList(); + } + + @Override + public String getHelpMessage() { + return Messages.HELP.STATS; + } +} diff --git a/src/main/java/com/alttd/hunger_games/config/Messages.java b/src/main/java/com/alttd/hunger_games/config/Messages.java index 4750c31..31ffc2d 100644 --- a/src/main/java/com/alttd/hunger_games/config/Messages.java +++ b/src/main/java/com/alttd/hunger_games/config/Messages.java @@ -31,6 +31,7 @@ public class Messages extends AbstractConfig { public static String START_ROUND = "Start the game: /hg start"; public static String RELOAD = "Reload config and messages: /hg reload"; public static String STUCK = "Teleport to safety if stuck: /hg stuck"; + public static String STATS = "Show player stats: /hg stats [player]"; @SuppressWarnings("unused") private static void load() { @@ -41,6 +42,7 @@ public class Messages extends AbstractConfig { START_ROUND = config.getString(prefix, "start", START_ROUND); RELOAD = config.getString(prefix, "reload", RELOAD); STUCK = config.getString(prefix, "stuck", STUCK); + STATS = config.getString(prefix, "stats", STATS); } } @@ -145,4 +147,22 @@ public class Messages extends AbstractConfig { ALREADY_WARMING_UP = config.getString(prefix, "already-warming-up", ALREADY_WARMING_UP); } } + + public static class STATS { + private static final String prefix = "stats."; + + public static String STATS_FORMAT = "Stats for :\n" + + "Kills: \n" + + "Deaths: \n" + + "Damage Dealt: \n" + + "Damage Taken: \n" + + "Wins: \n" + + "Rounds Played: \n" + + "K/D Ratio: "; + + @SuppressWarnings("unused") + private static void load() { + STATS_FORMAT = config.getString(prefix, "format", STATS_FORMAT); + } + } } diff --git a/src/main/java/com/alttd/hunger_games/data_objects/LifetimeStats.java b/src/main/java/com/alttd/hunger_games/data_objects/LifetimeStats.java new file mode 100644 index 0000000..2b67cfc --- /dev/null +++ b/src/main/java/com/alttd/hunger_games/data_objects/LifetimeStats.java @@ -0,0 +1,36 @@ +package com.alttd.hunger_games.data_objects; + +import lombok.Builder; +import lombok.Data; + +import java.util.UUID; + +@Data +@Builder +public class LifetimeStats { + private final UUID uuid; + private int totalKills; + private int totalDeaths; + private double totalDamageDealt; + private double totalDamageTaken; + private int totalWins; + private int totalRoundsPlayed; + + public void addRoundStats(RoundStats roundStats) { + totalKills += roundStats.getKills(); + totalDeaths += roundStats.getDeaths(); + totalDamageDealt += roundStats.getDamageDealt(); + totalDamageTaken += roundStats.getDamageTaken(); + if (roundStats.isWon()) { + totalWins++; + } + totalRoundsPlayed++; + } + + public double getKDRatio() { + if (totalDeaths == 0) { + return totalKills; + } + return (double) totalKills / totalDeaths; + } +} diff --git a/src/main/java/com/alttd/hunger_games/data_objects/RoundStats.java b/src/main/java/com/alttd/hunger_games/data_objects/RoundStats.java new file mode 100644 index 0000000..fff6331 --- /dev/null +++ b/src/main/java/com/alttd/hunger_games/data_objects/RoundStats.java @@ -0,0 +1,34 @@ +package com.alttd.hunger_games.data_objects; + +import lombok.Builder; +import lombok.Data; + +import java.util.UUID; + +@Data +@Builder +public class RoundStats { + private final UUID uuid; + private int kills; + private int deaths; + private double damageDealt; + private double damageTaken; + private int placement; + private boolean won; + + public void addKill() { + kills++; + } + + public void addDeath() { + deaths++; + } + + public void addDamageDealt(double damage) { + damageDealt += damage; + } + + public void addDamageTaken(double damage) { + damageTaken += damage; + } +} diff --git a/src/main/java/com/alttd/hunger_games/event_listeners/PlayerDamageListener.java b/src/main/java/com/alttd/hunger_games/event_listeners/PlayerDamageListener.java index a8fffa3..8191d2c 100644 --- a/src/main/java/com/alttd/hunger_games/event_listeners/PlayerDamageListener.java +++ b/src/main/java/com/alttd/hunger_games/event_listeners/PlayerDamageListener.java @@ -3,10 +3,12 @@ package com.alttd.hunger_games.event_listeners; import com.alttd.hunger_games.data_objects.ROUND_STATE; import com.alttd.hunger_games.services.PlayerService; import com.alttd.hunger_games.services.RoundService; +import com.alttd.hunger_games.services.StatService; import lombok.RequiredArgsConstructor; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageByEntityEvent; import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.entity.PlayerDeathEvent; import org.bukkit.event.player.PlayerRespawnEvent; @@ -16,21 +18,37 @@ public class PlayerDamageListener implements Listener { private final RoundService roundService; private final PlayerService playerService; + private final StatService statService; @EventHandler public void onEntityDamage(EntityDamageEvent event) { - if (!(event.getEntity() instanceof Player)) { + if (!(event.getEntity() instanceof Player player)) { return; } ROUND_STATE roundState = roundService.getRoundState(); if (roundState != ROUND_STATE.KILL_PHASE && roundState != ROUND_STATE.FINALE) { event.setCancelled(true); + return; + } + + double damage = Math.min(event.getFinalDamage(), player.getHealth()); + statService.addDamageTaken(player.getUniqueId(), damage); + + if (event instanceof EntityDamageByEntityEvent damageByEntityEvent && damageByEntityEvent.getDamager() instanceof Player attacker) { + statService.addDamageDealt(attacker.getUniqueId(), damage); } } @EventHandler public void onPlayerDeath(PlayerDeathEvent event) { - playerService.handlePlayerDeath(event.getPlayer()); + Player player = event.getPlayer(); + playerService.handlePlayerDeath(player); + statService.addDeath(player.getUniqueId()); + + Player killer = player.getKiller(); + if (killer != null) { + statService.addKill(killer.getUniqueId()); + } } @EventHandler diff --git a/src/main/java/com/alttd/hunger_games/services/RoundService.java b/src/main/java/com/alttd/hunger_games/services/RoundService.java index 92beb4e..7feb057 100644 --- a/src/main/java/com/alttd/hunger_games/services/RoundService.java +++ b/src/main/java/com/alttd/hunger_games/services/RoundService.java @@ -4,6 +4,7 @@ import com.alttd.hunger_games.Main; import com.alttd.hunger_games.data_objects.PLAYER_STATE; import com.alttd.hunger_games.data_objects.ROUND_STATE; import com.alttd.hunger_games.event_listeners.PlayerMovementListener; +import com.alttd.hunger_games.services.StatService; import lombok.Getter; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -18,23 +19,25 @@ public class RoundService implements RoundListener { private PlayerMovementListener playerMovementListener; private final Main main; private final LootService lootService; + private final StatService statService; @Getter private ROUND_STATE roundState; private final HashMap players = new HashMap<>(); - public static RoundService createSingletonInstance(Round round, Main main, LootService lootService) { + public static RoundService createSingletonInstance(Round round, Main main, LootService lootService, StatService statService) { if (instance != null) { throw new IllegalStateException("RoundService is already initialized."); } - instance = new RoundService(round, main, lootService); + instance = new RoundService(round, main, lootService, statService); return instance; } - private RoundService(Round round, Main main, LootService lootService) { + private RoundService(Round round, Main main, LootService lootService, StatService statService) { this.roundState = round.register(this); this.main = main; this.lootService = lootService; + this.statService = statService; } public Set getPlayers(PLAYER_STATE playerState) { @@ -45,7 +48,12 @@ public class RoundService implements RoundListener { } protected void setPlayerState(UUID uuid, PLAYER_STATE playerState) { - players.put(uuid, playerState); + PLAYER_STATE oldState = players.put(uuid, playerState); + if (playerState == PLAYER_STATE.SPECTATING && oldState == PLAYER_STATE.REGISTERED) { + int remaining = getPlayers(PLAYER_STATE.REGISTERED).size(); + statService.setPlacement(uuid, remaining + 1); + } + if (!roundState.equals(ROUND_STATE.KILL_PHASE) && !roundState.equals(ROUND_STATE.FINALE)) { return; } @@ -58,6 +66,8 @@ public class RoundService implements RoundListener { private void declareWinner(UUID winnerUUID) { Player player = Bukkit.getPlayer(winnerUUID); + statService.setPlacement(winnerUUID, 1); + statService.setWon(winnerUUID, true); //TODO message //TODO reset round } diff --git a/src/main/java/com/alttd/hunger_games/services/StatService.java b/src/main/java/com/alttd/hunger_games/services/StatService.java new file mode 100644 index 0000000..34913c3 --- /dev/null +++ b/src/main/java/com/alttd/hunger_games/services/StatService.java @@ -0,0 +1,121 @@ +package com.alttd.hunger_games.services; + +import com.alttd.hunger_games.Main; +import com.alttd.hunger_games.data_objects.LifetimeStats; +import com.alttd.hunger_games.data_objects.ROUND_STATE; +import com.alttd.hunger_games.data_objects.RoundStats; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class StatService implements RoundListener { + + private final Main main; + private final Map currentRoundStats = new ConcurrentHashMap<>(); + private final Map lifetimeStatsCache = new ConcurrentHashMap<>(); + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private final File statsDirectory; + private final File lifetimeStatsFile; + + public StatService(Main main, Round round) { + this.main = main; + this.statsDirectory = new File(main.getDataFolder(), "stats"); + if (!statsDirectory.exists()) { + statsDirectory.mkdirs(); + } + this.lifetimeStatsFile = new File(statsDirectory, "lifetime.json"); + round.register(this); + loadLifetimeStats(); + } + + public RoundStats getOrCreateRoundStats(UUID uuid) { + return currentRoundStats.computeIfAbsent(uuid, k -> RoundStats.builder().uuid(k).build()); + } + + public LifetimeStats getOrCreateLifetimeStats(UUID uuid) { + return lifetimeStatsCache.computeIfAbsent(uuid, k -> LifetimeStats.builder().uuid(k).build()); + } + + @Override + public void stateChange(ROUND_STATE roundState) { + if (roundState == ROUND_STATE.PLAYER_REGISTRATION) { + saveRoundStats(); + currentRoundStats.clear(); + } + } + + private void saveRoundStats() { + if (currentRoundStats.isEmpty()) { + return; + } + + long timestamp = System.currentTimeMillis(); + File roundFile = new File(statsDirectory, "round_" + timestamp + ".json"); + try (FileWriter writer = new FileWriter(roundFile)) { + gson.toJson(currentRoundStats.values(), writer); + } catch (IOException e) { + main.getLogger().severe("Could not save round stats: " + e.getMessage()); + } + + for (RoundStats roundStats : currentRoundStats.values()) { + LifetimeStats lifetime = getOrCreateLifetimeStats(roundStats.getUuid()); + lifetime.addRoundStats(roundStats); + } + saveLifetimeStats(); + } + + private void loadLifetimeStats() { + if (!lifetimeStatsFile.exists()) { + return; + } + try (FileReader reader = new FileReader(lifetimeStatsFile)) { + LifetimeStats[] stats = gson.fromJson(reader, LifetimeStats[].class); + if (stats != null) { + for (LifetimeStats s : stats) { + lifetimeStatsCache.put(s.getUuid(), s); + } + } + } catch (IOException e) { + main.getLogger().severe("Could not load lifetime stats: " + e.getMessage()); + } + } + + private void saveLifetimeStats() { + try (FileWriter writer = new FileWriter(lifetimeStatsFile)) { + gson.toJson(lifetimeStatsCache.values(), writer); + } catch (IOException e) { + main.getLogger().severe("Could not save lifetime stats: " + e.getMessage()); + } + } + + public void addKill(UUID uuid) { + getOrCreateRoundStats(uuid).addKill(); + } + + public void addDeath(UUID uuid) { + getOrCreateRoundStats(uuid).addDeath(); + } + + public void addDamageDealt(UUID uuid, double damage) { + getOrCreateRoundStats(uuid).addDamageDealt(damage); + } + + public void addDamageTaken(UUID uuid, double damage) { + getOrCreateRoundStats(uuid).addDamageTaken(damage); + } + + public void setPlacement(UUID uuid, int placement) { + getOrCreateRoundStats(uuid).setPlacement(placement); + } + + public void setWon(UUID uuid, boolean won) { + getOrCreateRoundStats(uuid).setWon(won); + } +}