Add player stats tracking with StatService and /hg stats command integration

This commit is contained in:
akastijn 2026-06-15 22:56:27 +02:00
parent 2b6480c880
commit c8e85f186a
9 changed files with 331 additions and 10 deletions

View File

@ -24,6 +24,7 @@ public final class Main extends JavaPlugin {
private PlayerService playerService; private PlayerService playerService;
private PlayerTeleporterService playerTeleporterService; private PlayerTeleporterService playerTeleporterService;
private LootService lootService; private LootService lootService;
private StatService statService;
@Override @Override
public void onEnable() { public void onEnable() {
@ -40,6 +41,7 @@ public final class Main extends JavaPlugin {
lootService = new LootService(); lootService = new LootService();
roundService = RoundService.createSingletonInstance(round, this, lootService); roundService = RoundService.createSingletonInstance(round, this, lootService);
playerTeleporterService = PlayerTeleporterService.createSingletonInstance(); playerTeleporterService = PlayerTeleporterService.createSingletonInstance();
statService = new StatService(this, round);
playerService = PlayerService.createSingletonInstance(round, roundService, playerTeleporterService); playerService = PlayerService.createSingletonInstance(round, roundService, playerTeleporterService);
} }
@ -49,14 +51,14 @@ public final class Main extends JavaPlugin {
} }
private void registerCommands() { private void registerCommands() {
BaseCommand command = new BaseCommand(this, roundService, playerService, round); BaseCommand command = new BaseCommand(this, roundService, playerService, round, statService);
} }
private void registerEvents() { private void registerEvents() {
PluginManager pluginManager = getServer().getPluginManager(); PluginManager pluginManager = getServer().getPluginManager();
pluginManager.registerEvents(new PlayerDisconnectListener(playerService), this); pluginManager.registerEvents(new PlayerDisconnectListener(playerService), this);
pluginManager.registerEvents(new PlayerJoinListener(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); pluginManager.registerEvents(new ChestListener(roundService, lootService), this);
} }

View File

@ -6,6 +6,7 @@ import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.services.PlayerService; import com.alttd.hunger_games.services.PlayerService;
import com.alttd.hunger_games.services.Round; import com.alttd.hunger_games.services.Round;
import com.alttd.hunger_games.services.RoundService; import com.alttd.hunger_games.services.RoundService;
import com.alttd.hunger_games.services.StatService;
import lombok.Getter; import lombok.Getter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
@ -22,7 +23,7 @@ import java.util.stream.Collectors;
public class BaseCommand implements CommandExecutor, TabExecutor { public class BaseCommand implements CommandExecutor, TabExecutor {
private final List<SubCommand> subCommands; private final List<SubCommand> 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"); PluginCommand command = main.getCommand("hungergames");
if (command == null) { if (command == null) {
subCommands = null; subCommands = null;
@ -38,7 +39,8 @@ public class BaseCommand implements CommandExecutor, TabExecutor {
new RoundState(roundService), new RoundState(roundService),
new Register(playerService), new Register(playerService),
new StartRound(round, roundService), new StartRound(round, roundService),
new Stuck(main) new Stuck(main),
new Stats(statService)
)); ));
} }

View File

@ -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("<player>", 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<String> 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;
}
}

View File

@ -31,6 +31,7 @@ public class Messages extends AbstractConfig {
public static String START_ROUND = "<green>Start the game: <gold>/hg start</gold></green>"; public static String START_ROUND = "<green>Start the game: <gold>/hg start</gold></green>";
public static String RELOAD = "<green>Reload config and messages: <gold>/hg reload</gold></green>"; public static String RELOAD = "<green>Reload config and messages: <gold>/hg reload</gold></green>";
public static String STUCK = "<green>Teleport to safety if stuck: <gold>/hg stuck</gold></green>"; public static String STUCK = "<green>Teleport to safety if stuck: <gold>/hg stuck</gold></green>";
public static String STATS = "<green>Show player stats: <gold>/hg stats [player]</gold></green>";
@SuppressWarnings("unused") @SuppressWarnings("unused")
private static void load() { private static void load() {
@ -41,6 +42,7 @@ public class Messages extends AbstractConfig {
START_ROUND = config.getString(prefix, "start", START_ROUND); START_ROUND = config.getString(prefix, "start", START_ROUND);
RELOAD = config.getString(prefix, "reload", RELOAD); RELOAD = config.getString(prefix, "reload", RELOAD);
STUCK = config.getString(prefix, "stuck", STUCK); 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); 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 = "<gold>Stats for <player>:</gold>\n" +
"<green>Kills: <gold><kills></gold></green>\n" +
"<green>Deaths: <gold><deaths></gold></green>\n" +
"<green>Damage Dealt: <gold><damage_dealt></gold></green>\n" +
"<green>Damage Taken: <gold><damage_taken></gold></green>\n" +
"<green>Wins: <gold><wins></gold></green>\n" +
"<green>Rounds Played: <gold><rounds_played></gold></green>\n" +
"<green>K/D Ratio: <gold><kd_ratio></gold></green>";
@SuppressWarnings("unused")
private static void load() {
STATS_FORMAT = config.getString(prefix, "format", STATS_FORMAT);
}
}
} }

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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.data_objects.ROUND_STATE;
import com.alttd.hunger_games.services.PlayerService; import com.alttd.hunger_games.services.PlayerService;
import com.alttd.hunger_games.services.RoundService; import com.alttd.hunger_games.services.RoundService;
import com.alttd.hunger_games.services.StatService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.PlayerDeathEvent; import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerRespawnEvent; import org.bukkit.event.player.PlayerRespawnEvent;
@ -16,21 +18,37 @@ public class PlayerDamageListener implements Listener {
private final RoundService roundService; private final RoundService roundService;
private final PlayerService playerService; private final PlayerService playerService;
private final StatService statService;
@EventHandler @EventHandler
public void onEntityDamage(EntityDamageEvent event) { public void onEntityDamage(EntityDamageEvent event) {
if (!(event.getEntity() instanceof Player)) { if (!(event.getEntity() instanceof Player player)) {
return; return;
} }
ROUND_STATE roundState = roundService.getRoundState(); ROUND_STATE roundState = roundService.getRoundState();
if (roundState != ROUND_STATE.KILL_PHASE && roundState != ROUND_STATE.FINALE) { if (roundState != ROUND_STATE.KILL_PHASE && roundState != ROUND_STATE.FINALE) {
event.setCancelled(true); 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 @EventHandler
public void onPlayerDeath(PlayerDeathEvent event) { 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 @EventHandler

View File

@ -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.PLAYER_STATE;
import com.alttd.hunger_games.data_objects.ROUND_STATE; import com.alttd.hunger_games.data_objects.ROUND_STATE;
import com.alttd.hunger_games.event_listeners.PlayerMovementListener; import com.alttd.hunger_games.event_listeners.PlayerMovementListener;
import com.alttd.hunger_games.services.StatService;
import lombok.Getter; import lombok.Getter;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
@ -18,23 +19,25 @@ public class RoundService implements RoundListener {
private PlayerMovementListener playerMovementListener; private PlayerMovementListener playerMovementListener;
private final Main main; private final Main main;
private final LootService lootService; private final LootService lootService;
private final StatService statService;
@Getter @Getter
private ROUND_STATE roundState; private ROUND_STATE roundState;
private final HashMap<UUID, PLAYER_STATE> players = new HashMap<>(); private final HashMap<UUID, PLAYER_STATE> 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) { if (instance != null) {
throw new IllegalStateException("RoundService is already initialized."); throw new IllegalStateException("RoundService is already initialized.");
} }
instance = new RoundService(round, main, lootService); instance = new RoundService(round, main, lootService, statService);
return instance; 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.roundState = round.register(this);
this.main = main; this.main = main;
this.lootService = lootService; this.lootService = lootService;
this.statService = statService;
} }
public Set<UUID> getPlayers(PLAYER_STATE playerState) { public Set<UUID> getPlayers(PLAYER_STATE playerState) {
@ -45,7 +48,12 @@ public class RoundService implements RoundListener {
} }
protected void setPlayerState(UUID uuid, PLAYER_STATE playerState) { 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)) { if (!roundState.equals(ROUND_STATE.KILL_PHASE) && !roundState.equals(ROUND_STATE.FINALE)) {
return; return;
} }
@ -58,6 +66,8 @@ public class RoundService implements RoundListener {
private void declareWinner(UUID winnerUUID) { private void declareWinner(UUID winnerUUID) {
Player player = Bukkit.getPlayer(winnerUUID); Player player = Bukkit.getPlayer(winnerUUID);
statService.setPlacement(winnerUUID, 1);
statService.setWon(winnerUUID, true);
//TODO message //TODO message
//TODO reset round //TODO reset round
} }

View File

@ -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<UUID, RoundStats> currentRoundStats = new ConcurrentHashMap<>();
private final Map<UUID, LifetimeStats> 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);
}
}