Introduce TeamScoreboard and highest stat tracking functionality.

This commit refactors scoreboard management into a dedicated TeamScoreboard class, improving modularity and readability. It also adds a new command to track and display the highest player stats across all game metrics, enhancing gameplay insights.
This commit is contained in:
Teriuihi 2025-02-28 22:21:23 +01:00
parent 14158f73b8
commit 3550d75634
10 changed files with 241 additions and 44 deletions

View File

@ -147,10 +147,11 @@ public class Main extends JavaPlugin {
gameManager.setPlayerStats(playerStats);
final JsonConfigManager<PlayerStat> jsonConfigManager = new JsonConfigManager<>(JacksonConfig.configureMapper());
Runnable runnable = () -> {
log.info("Saving player stats");
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"));
jsonConfigManager.saveConfig(playerStat, playerStatsDirectory, playerStat.getUuid().toString());
playerStat.setUnTouched();
} catch (IOException e) {
log.error("Failed to save player stats for [{}].", playerStat.getUuid(), e);

View File

@ -38,6 +38,7 @@ public class CommandManager implements CommandExecutor, TabExecutor {
new SkipPhase(gameManager),
new Start(gameManager, flag),
new CreateTeam(main, gameManager),
new HighestStat(gameManager),
new SelectClass(gameManager, worldBorderApi),
new Reload(main)
);
@ -120,4 +121,4 @@ public class CommandManager implements CommandExecutor, TabExecutor {
.findFirst()
.orElse(null);
}
}
}

View File

@ -0,0 +1,79 @@
package com.alttd.ctf.commands.subcommands;
import com.alttd.ctf.commands.SubCommand;
import com.alttd.ctf.config.Messages;
import com.alttd.ctf.game.GameManager;
import com.alttd.ctf.stats.PlayerStat;
import com.alttd.ctf.stats.Stat;
import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.util.*;
@Slf4j
public class HighestStat extends SubCommand {
private final GameManager gameManager;
private final MiniMessage miniMessage = MiniMessage.miniMessage();
public HighestStat(GameManager gameManager) {
this.gameManager = gameManager;
}
private record HighestStatValue(Stat stat, UUID uuid, double value) {
}
@Override
public int onCommand(CommandSender commandSender, String[] args) {
ArrayList<HighestStatValue> highestStatValues = new ArrayList<>();
Collection<PlayerStat> playerStats = gameManager.getPlayerStats();
for (Stat stat : Stat.values()) {
Optional<PlayerStat> max = playerStats.stream().max(Comparator.comparingDouble(playerStat -> playerStat.getStat(stat)));
if (max.isEmpty()) {
log.warn("No max stat found for {}", stat.toString());
continue;
}
PlayerStat playerStat = max.get();
highestStatValues.add(new HighestStatValue(stat, playerStat.getUuid(), playerStat.getStat(stat)));
}
Component message = highestStatValues.stream().map(highestStatValue -> {
TextComponent messageBuilder = Component.empty();
OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(highestStatValue.uuid);
Player player = offlinePlayer.getPlayer();
if (!offlinePlayer.isOnline() || player == null) {
messageBuilder = messageBuilder.append(miniMessage.deserialize(offlinePlayer.getName() == null ? highestStatValue.uuid.toString() : offlinePlayer.getName()));
} else {
messageBuilder = messageBuilder.append(player.name());
}
messageBuilder = messageBuilder.append(Component.text(": "));
messageBuilder = messageBuilder.append(Component.text(highestStatValue.stat.toString()));
messageBuilder = messageBuilder.append(Component.text(": "));
messageBuilder = messageBuilder.append(Component.text(highestStatValue.value()));
return messageBuilder;
}).reduce(Component.empty(), (a, b) -> a.append(Component.newline()).append(b));
commandSender.sendMessage(message);
return 0;
}
@Override
public String getName() {
return "higheststat";
}
@Override
public List<String> getTabComplete(CommandSender commandSender, String[] args) {
return List.of();
}
@Override
public String getHelpMessage() {
return Messages.HELP.HIGHEST_STAT;
}
}

View File

@ -28,6 +28,7 @@ public class Messages extends AbstractConfig {
public static String START = "<green>Start a new game: <gold>/ctf start <time_in_minutes></gold></green>";
public static String SELECT_CLASS = "<green>Open class selection: <gold>/ctf selectclass</gold></green>";
public static String SKIP_PHASE = "<green>Skip the current phase: <gold>/ctf skipphase</gold></green>";
public static String HIGHEST_STAT = "<green>Display the highest stat and the player: <gold>/ctf higheststat</gold></green>";
@SuppressWarnings("unused")
private static void load() {
@ -39,6 +40,7 @@ public class Messages extends AbstractConfig {
START = config.getString(prefix, "start", START);
SELECT_CLASS = config.getString(prefix, "select-class", SELECT_CLASS);
SKIP_PHASE = config.getString(prefix, "skip-phase", SKIP_PHASE);
HIGHEST_STAT = config.getString(prefix, "highest-stat", HIGHEST_STAT);
}
}

View File

@ -121,4 +121,11 @@ public class GameManager {
public Collection<PlayerStat> getPlayerStats() {
return this.playerStats.values();
}
public Optional<Duration> getRemainingTime() {
if (runningGame == null || runningGame.getCurrentPhase().equals(GamePhase.ENDED)) {
return Optional.empty();
}
return Optional.of(runningGame.getRemainingTime());
}
}

View File

@ -2,6 +2,7 @@ package com.alttd.ctf.game;
import com.alttd.ctf.config.GameConfig;
import com.alttd.ctf.flag.Flag;
import com.alttd.ctf.team.TeamScoreboard;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.text.minimessage.MiniMessage;
@ -14,6 +15,7 @@ import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ScheduledExecutorService;
@Slf4j
@ -51,6 +53,7 @@ public class RunningGame implements Runnable {
} else {
executorService.shutdown();
}
TeamScoreboard.refreshScoreboard(gameManager);
} catch (Exception e) {
log.error("Unexpected error in running game", e);
throw new RuntimeException(e);
@ -115,4 +118,9 @@ public class RunningGame implements Runnable {
//TODO say the phase ended early?
nextPhaseActions(currentPhase, GamePhase.ENDED);
}
public Duration getRemainingTime() {
Duration duration = phaseDurations.get(currentPhase);
return duration.minus(Duration.between(phaseStartTime, Instant.now()));
}
}

View File

@ -1,14 +1,21 @@
package com.alttd.ctf.stats;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import net.kyori.adventure.text.minimessage.MiniMessage;
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 org.jetbrains.annotations.NotNull;
import java.util.UUID;
@NoArgsConstructor
@RequiredArgsConstructor
@Getter
public final class PlayerStat {
@ -16,11 +23,15 @@ public final class PlayerStat {
private boolean touched;
@JsonIgnore
private static final CommandSender commandSender = Bukkit.getConsoleSender();
@JsonIgnore
private static boolean someoneDiedInPowderedSnow = false;
@JsonProperty
@NotNull
private final UUID uuid;
private UUID uuid;
@JsonProperty
@NotNull
private final String inGameName;
private String inGameName;
private boolean completedGame = false;
private int flagsCaptured = 0;
@ -37,7 +48,7 @@ public final class PlayerStat {
switch (stat) {
case COMPLETED_GAME -> {
if (!completedGame) {
Bukkit.dispatchCommand(commandSender, String.format("lp user %s permission set ctf.game.completed", inGameName));
Bukkit.dispatchCommand(commandSender, String.format("lp user %s permission settemp ctf.game.completed true 14d", inGameName));
}
completedGame = true;
}
@ -47,7 +58,24 @@ public final class PlayerStat {
case BLOCKS_PLACED -> blocksPlaced++;
case SNOWBALLS_THROWN -> snowballsThrown++;
case TIME_SPEND_CAPTURING_FLAG -> timeSpendCapturingFlag++;
case DEATHS_IN_POWDERED_SNOW -> deathsInPowderedSnow++; //TODO announce if they are the first person to do this and save they are the first
case DEATHS_IN_POWDERED_SNOW -> {
if (someoneDiedInPowderedSnow) {
deathsInPowderedSnow++; //TODO announce if they are the first person to do this and save they are the first
return;
}
OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(uuid);
if (!offlinePlayer.isOnline()) {
return;
}
Player player = offlinePlayer.getPlayer();
if (player == null) {
return;
}
Bukkit.broadcast(MiniMessage.miniMessage().deserialize("<red><player> will receive a consolation prize for being the first to die in powdered snow.</red>",
Placeholder.component("player",player.displayName())));
Bukkit.dispatchCommand(commandSender, String.format("lp user %s permission settemp ctf.game.first_powdered_snow_death true 14d", inGameName));
someoneDiedInPowderedSnow = true;
}
case DAMAGE_DONE, DAMAGE_HEALED -> throw new IllegalArgumentException(String.format("%s requires a number", stat.name()));
}
touched = true;
@ -66,4 +94,18 @@ public final class PlayerStat {
touched = false;
}
public double getStat(Stat stat) {
return switch (stat) {
case COMPLETED_GAME -> completedGame ? 1 : 0;
case FLAGS_CAPTURED -> flagsCaptured;
case KILLS -> kills;
case DAMAGE_DONE -> damageDone;
case DAMAGE_HEALED -> damageHealed;
case SNOW_MINED -> snowMined;
case BLOCKS_PLACED -> blocksPlaced;
case SNOWBALLS_THROWN -> snowballsThrown;
case TIME_SPEND_CAPTURING_FLAG -> timeSpendCapturingFlag;
case DEATHS_IN_POWDERED_SNOW -> deathsInPowderedSnow;
};
}
}

View File

@ -9,15 +9,9 @@ import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.*;
import org.jetbrains.annotations.NotNull;
import java.util.*;
@ -28,7 +22,7 @@ import java.util.*;
public class Team {
@JsonIgnore
private final static Scoreboard scoreboard = Bukkit.getScoreboardManager().getNewScoreboard();
private final TeamScoreboard scoreboard = new TeamScoreboard(this);
@JsonIgnore
private final HashMap<UUID, TeamPlayer> players = new HashMap<>();
@JsonProperty("name")
@ -75,7 +69,7 @@ public class Team {
}
public TeamPlayer addPlayer(Player player, PlayerStat playerStat) {
removeFromScoreBoard(player);
removeFromScoreboard(player);
UUID uuid = player.getUniqueId();
TeamPlayer teamPlayer = new TeamPlayer(player, this, playerStat);
players.put(uuid, teamPlayer);
@ -101,7 +95,7 @@ public class Team {
}
public void removePlayer(@NotNull Player player) {
removeFromScoreBoard(player);
removeFromScoreboard(player);
TeamPlayer remove = players.remove(player.getUniqueId());
if (remove == null) {
return;
@ -115,38 +109,15 @@ public class Team {
}
public void addToScoreboard(Player player) {
org.bukkit.scoreboard.Team team = scoreboard.getTeam("ctf_" + id);
if (team == null) {
team = scoreboard.registerNewTeam("ctf_" + id);
team.displayName(name);
NamedTextColor namedTextColor = NamedTextColor.nearestTo(TextColor.color(color.r(), color.g(), color.b()));
team.color(namedTextColor);
}
team.addPlayer(player);
player.setScoreboard(scoreboard);
scoreboard.addToScoreboard(player);
}
private void removeFromScoreBoard(Player player) {
scoreboard.getTeams().stream()
.filter(team -> team.getName().startsWith("ctf_"))
.filter(team -> team.hasPlayer(player))
.forEach(team -> team.removePlayer(player));
private void removeFromScoreboard(Player player) {
scoreboard.removeFromScoreboard(player);
}
public void setScore(int newScore) {
Objective objective = getOrCreateObjective();
Score score = objective.getScore(legacyTeamColor + PlainTextComponentSerializer.plainText().serialize(name));
score.setScore(newScore);
}
private Objective getOrCreateObjective() {
Objective objective = scoreboard.getObjective("teamScores");
if (objective == null) {
objective = scoreboard.registerNewObjective("teamScores", Criteria.DUMMY,
MiniMessage.miniMessage().deserialize("<gold>CTF score</gold>"));
objective.setDisplaySlot(DisplaySlot.SIDEBAR);
}
return objective;
scoreboard.setScore(newScore);
}
@Override

View File

@ -0,0 +1,86 @@
package com.alttd.ctf.team;
import com.alttd.ctf.game.GameManager;
import com.alttd.ctf.game.GamePhase;
import io.papermc.paper.scoreboard.numbers.NumberFormat;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.*;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.*;
import java.time.Duration;
public class TeamScoreboard {
private final static Scoreboard scoreboard = Bukkit.getScoreboardManager().getNewScoreboard();
private final Team team;
public TeamScoreboard(Team team) {
this.team = team;
}
public void addToScoreboard(Player player) {
org.bukkit.scoreboard.Team scoreboardTeam = scoreboard.getTeam("ctf_" + team.getId());
if (scoreboardTeam == null) {
scoreboardTeam = scoreboard.registerNewTeam("ctf_" + team.getId());
scoreboardTeam.displayName(team.getName());
TeamColor color = team.getColor();
NamedTextColor namedTextColor = NamedTextColor.nearestTo(TextColor.color(color.r(), color.g(), color.b()));
scoreboardTeam.color(namedTextColor);
}
scoreboardTeam.addPlayer(player);
player.setScoreboard(scoreboard);
}
protected void removeFromScoreboard(Player player) {
scoreboard.getTeams().stream()
.filter(team -> team.getName().startsWith("ctf_"))
.filter(team -> team.hasPlayer(player))
.forEach(team -> team.removePlayer(player));
}
protected void setScore(int newScore) {
Objective objective = getOrCreateObjective("teamScores", "<gold>CTF score</gold>");
Score score = objective.getScore(team.getLegacyTeamColor() +
PlainTextComponentSerializer.plainText().serialize(team.getName()));
score.setScore(newScore);
}
private static Objective getOrCreateObjective(String internalName, String displayName) {
Objective objective = scoreboard.getObjective(internalName);
if (objective == null) {
objective = scoreboard.registerNewObjective(internalName, Criteria.DUMMY,
MiniMessage.miniMessage().deserialize(displayName));
objective.setDisplaySlot(DisplaySlot.SIDEBAR);
}
return objective;
}
private record PhaseScore(GamePhase gamePhase, Score score) {}
private static PhaseScore phaseScore = null;
private static void updateTime(GamePhase gamePhase, Duration duration) {
if (phaseScore == null) {
phaseScore = new PhaseScore(gamePhase, getOrCreateObjective("teamScores", "<gold>CTF score</gold>")
.getScore(ChatColor.GREEN + PlainTextComponentSerializer.plainText().serialize(gamePhase.getDisplayName())));
} else if (phaseScore.gamePhase() != gamePhase) {
phaseScore.score.resetScore();
phaseScore = new PhaseScore(gamePhase, getOrCreateObjective("teamScores", "<gold>CTF score</gold>")
.getScore(ChatColor.GREEN + PlainTextComponentSerializer.plainText().serialize(gamePhase.getDisplayName())));
}
phaseScore.score.setScore(duration.toMinutesPart() == 0 ? duration.toSecondsPart() : duration.toMinutesPart());
}
public static void refreshScoreboard(GameManager gameManager) {
gameManager.getGamePhase().ifPresent(gamePhase ->
gameManager.getRemainingTime().ifPresent(duration ->
updateTime(gamePhase, duration)));
// TODO if game is active show time + game phase
// if someone has flag show that
// show how many of each team in circle?
}
}

View File

@ -1,3 +1,3 @@
#Sun Feb 23 01:14:21 CET 2025
buildNumber=70
#Fri Feb 28 22:11:49 CET 2025
buildNumber=80
version=0.1