Add persistent player statistics tracking

Introduced the `PlayerStat` system to persist player statistics across sessions. Enhanced player management by linking `PlayerStat` with teams and saving/updating stats periodically. Refactored related methods to support the new functionality and ensure data integrity.
This commit is contained in:
Teriuihi 2025-02-26 22:00:46 +01:00
parent e62d0df9df
commit c738f02d17
7 changed files with 89 additions and 20 deletions

View File

@ -16,6 +16,7 @@ import com.alttd.ctf.gui.GUIInventory;
import com.alttd.ctf.gui.GUIListener;
import com.alttd.ctf.json_config.JacksonConfig;
import com.alttd.ctf.json_config.JsonConfigManager;
import com.alttd.ctf.stats.PlayerStat;
import com.alttd.ctf.team.Team;
import com.github.yannicklamprecht.worldborder.api.WorldBorderApi;
import lombok.extern.slf4j.Slf4j;
@ -29,6 +30,8 @@ import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.stream.Collectors;
@Slf4j
@ -49,16 +52,11 @@ public class Main extends JavaPlugin {
WorldBorderApi worldBorderApi = worldBorder();
this.gameManager = new GameManager(worldBorderApi);
registerTeams(); //Skipped in reloadConfig if gameManager is not created yet
loadPlayerStats(); //Skipped in reloadConfig if gameManager is not created yet
flag = new Flag(this, gameManager);
new CommandManager(this, gameManager, flag, worldBorderApi);
//Ensuring immediate respawn is on in all worlds
log.info("Enabling immediate respawn for {}.", GameConfig.FLAG.world);
World world = Bukkit.getWorld(GameConfig.FLAG.world);
if (world != null) {
world.setGameRule(GameRule.DO_IMMEDIATE_RESPAWN, true);
} else {
log.error("No valid flag world defined, unable to modify game rules");
}
enableImmediateRespawn();
registerEvents(flag, worldBorderApi);
}
@ -68,6 +66,7 @@ public class Main extends JavaPlugin {
GameConfig.reload(this);
if (gameManager != null) {
registerTeams();
loadPlayerStats();
}
}
@ -83,6 +82,16 @@ public class Main extends JavaPlugin {
return worldBorderApiRegisteredServiceProvider.getProvider();
}
private void enableImmediateRespawn() {
log.info("Enabling immediate respawn for {}.", GameConfig.FLAG.world);
World world = Bukkit.getWorld(GameConfig.FLAG.world);
if (world != null) {
world.setGameRule(GameRule.DO_IMMEDIATE_RESPAWN, true);
} else {
log.error("No valid flag world defined, unable to modify game rules");
}
}
private void registerEvents(Flag flag, WorldBorderApi worldBorderApi) {
PluginManager pluginManager = getServer().getPluginManager();
pluginManager.registerEvents(new SnowballEvent(gameManager), this);
@ -119,4 +128,38 @@ public class Main extends JavaPlugin {
teams.forEach(gameManager::registerTeam);
}
private void loadPlayerStats() {
JsonConfigManager<PlayerStat> config = new JsonConfigManager<>(JacksonConfig.configureMapper());
List<PlayerStat> playerStats;
try {
File playerStatsDirectory = new File(getDataFolder(), "player_stats");
if (!playerStatsDirectory.exists() && !playerStatsDirectory.mkdirs()) {
log.error("Unable to make playerStats directory at {} shutting down plugin", playerStatsDirectory.getAbsolutePath());
}
playerStats = config.loadConfigs(PlayerStat.class, playerStatsDirectory);
} catch (IOException e) {
log.error("Unable to load teams, shutting down plugin", e);
getServer().getPluginManager().disablePlugin(this);
return;
}
gameManager.setPlayerStats(playerStats);
final JsonConfigManager<PlayerStat> jsonConfigManager = new JsonConfigManager<>(JacksonConfig.configureMapper());
Runnable runnable = () -> {
File playerStatsDirectory = new File(getDataFolder(), "player_stats");
gameManager.getPlayerStats().stream().filter(PlayerStat::isTouched).forEach(playerStat -> {
try {
jsonConfigManager.saveConfig(playerStat, playerStatsDirectory, String.format("%s.%s", playerStat.getUuid(), "json"));
playerStat.setUnTouched();
} catch (IOException e) {
log.error("Failed to save player stats for [{}].", playerStat.getUuid(), e);
}
});
};
ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
scheduledExecutorService.scheduleAtFixedRate(runnable, 0, 1, java.util.concurrent.TimeUnit.MINUTES);
}
}

View File

@ -61,7 +61,7 @@ public class OnPlayerOnlineStatus implements Listener {
log.error("No team found when attempting to add freshly joined player to a team");
return;
}
teamPlayer = min.get().addPlayer(player);
teamPlayer = gameManager.registerPlayer(min.get(), player);
} else {
teamPlayer = optionalTeamPlayer.get();
teamPlayer.getTeam().addToScoreboard(player);

View File

@ -6,6 +6,7 @@ import com.alttd.ctf.game.phases.CombatPhase;
import com.alttd.ctf.game.phases.EndedPhase;
import com.alttd.ctf.game.phases.GatheringPhase;
import com.alttd.ctf.game_class.creation.FighterCreator;
import com.alttd.ctf.stats.PlayerStat;
import com.alttd.ctf.team.Team;
import com.alttd.ctf.team.TeamPlayer;
import com.github.yannicklamprecht.worldborder.api.WorldBorderApi;
@ -24,6 +25,7 @@ public class GameManager {
private final HashMap<GamePhase, GamePhaseExecutor> phases;
private RunningGame runningGame;
private final HashMap<Integer, Team> teams = new HashMap<>();
private final HashMap<UUID, PlayerStat> playerStats = new HashMap<>();
public GameManager(WorldBorderApi worldBorderApi) {
phases = new HashMap<>();
@ -37,9 +39,12 @@ public class GameManager {
return runningGame == null ? Optional.empty() : Optional.of(runningGame.getCurrentPhase());
}
public void registerPlayer(Team team, Player player) {
public TeamPlayer registerPlayer(Team team, Player player) {
unregisterPlayer(player);
teams.get(team.getId()).addPlayer(player);
UUID uuid = player.getUniqueId();
PlayerStat playerStat = playerStats
.computeIfAbsent(uuid, (ignored) -> new PlayerStat(uuid, player.getName()));
return teams.get(team.getId()).addPlayer(player, playerStat);
}
public void unregisterPlayer(Player player) {
@ -107,4 +112,13 @@ public class GameManager {
}
return runningGame.skipCurrentPhase();
}
public void setPlayerStats(List<PlayerStat> playerStats) {
this.playerStats.clear();
playerStats.forEach(playerStat -> this.playerStats.put(playerStat.getUuid(), playerStat));
}
public Collection<PlayerStat> getPlayerStats() {
return this.playerStats.values();
}
}

View File

@ -62,7 +62,7 @@ public class ClassSelectionPhase implements GamePhaseExecutor {
.filter(player -> gameManager.getTeamPlayer(player).isEmpty())
.forEach(player -> {
Team team = teamCircularIterator.next();
team.addPlayer(player);
gameManager.registerPlayer(team, player);
player.sendRichMessage("You joined <team>!", Placeholder.component("team", team.getName()));
});
} else {

View File

@ -1,15 +1,25 @@
package com.alttd.ctf.stats;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
@RequiredArgsConstructor
@Getter
public final class PlayerStat {
@JsonIgnore
private boolean touched;
@JsonIgnore
private static final CommandSender commandSender = Bukkit.getConsoleSender();
@NotNull
private final UUID uuid;
@NotNull
private final String inGameName;
private boolean completedGame = false;
@ -23,11 +33,6 @@ public final class PlayerStat {
private long timeSpendCapturingFlag = 0;
private int deathsInPowderedSnow = 0;
public PlayerStat(UUID uuid, String inGameName) {
this.uuid = uuid;
this.inGameName = inGameName;
}
public void increaseStat(Stat stat) throws IllegalArgumentException {
switch (stat) {
case COMPLETED_GAME -> {
@ -45,6 +50,7 @@ public final class PlayerStat {
case DEATHS_IN_POWDERED_SNOW -> deathsInPowderedSnow++; //TODO announce if they are the first person to do this and save they are the first
case DAMAGE_DONE, DAMAGE_HEALED -> throw new IllegalArgumentException(String.format("%s requires a number", stat.name()));
}
touched = true;
}
public void increaseStat(Stat stat, double value) throws IllegalArgumentException {
@ -53,6 +59,11 @@ public final class PlayerStat {
case DAMAGE_HEALED -> damageHealed += value;
default -> throw new IllegalArgumentException(String.format("%s cannot be passed with a number", stat.name()));
}
touched = true;
}
public void setUnTouched() {
touched = false;
}
}

View File

@ -1,6 +1,7 @@
package com.alttd.ctf.team;
import com.alttd.ctf.game.GameManager;
import com.alttd.ctf.stats.PlayerStat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
@ -73,10 +74,10 @@ public class Team {
discordTeam = new DiscordTeam(gameManager);
}
public TeamPlayer addPlayer(Player player) {
public TeamPlayer addPlayer(Player player, PlayerStat playerStat) {
removeFromScoreBoard(player);
UUID uuid = player.getUniqueId();
TeamPlayer teamPlayer = new TeamPlayer(player, this);
TeamPlayer teamPlayer = new TeamPlayer(player, this, playerStat);
players.put(uuid, teamPlayer);
addToScoreboard(player);
if (discordTeam != null) {

View File

@ -30,10 +30,10 @@ public class TeamPlayer {
private GameClass gameClass;
private boolean isDead = false;
protected TeamPlayer(Player player, Team team) {
protected TeamPlayer(Player player, Team team, PlayerStat playerStat) {
this.uuid = player.getUniqueId();
this.team = team;
this.playerStat = new PlayerStat(uuid, player.getName());
this.playerStat = playerStat;
}
public void respawn(@NotNull Player player, @NotNull WorldBorderApi worldBorderApi, @NotNull GamePhase gamePhase) {