diff --git a/core/src/main/java/tc/oc/pgm/api/player/event/MatchPlayerDeathEvent.java b/core/src/main/java/tc/oc/pgm/api/player/event/MatchPlayerDeathEvent.java index 8c90f981a0..91f375642b 100644 --- a/core/src/main/java/tc/oc/pgm/api/player/event/MatchPlayerDeathEvent.java +++ b/core/src/main/java/tc/oc/pgm/api/player/event/MatchPlayerDeathEvent.java @@ -122,6 +122,11 @@ public final PlayerRelation getRelation() { return PlayerRelation.get(getVictim().getParticipantState(), getKiller()); } + /** Get the relationship between the victim and assister */ + public final PlayerRelation getAssistRelation() { + return PlayerRelation.get(getVictim().getParticipantState(), getAssister()); + } + /** * Get whether the death was caused by a teammate. * @@ -140,6 +145,15 @@ public final boolean isEnemyKill() { return PlayerRelation.ENEMY == getRelation(); } + /** + * Get whether the assist was caused by an enemy. + * + * @return Whether the assist was from an enemy. + */ + public final boolean isEnemyAssist() { + return PlayerRelation.ENEMY == getAssistRelation(); + } + /** * Get whether the {@link MatchPlayer} killed themselves. * @@ -149,17 +163,17 @@ public final boolean isSuicide() { return PlayerRelation.SELF == getRelation(); } - /** - * Get whether the victim and killer are the same {@link MatchPlayer}. - * - * @return - */ + /** Get whether the victim and killer are the same {@link MatchPlayer}. */ public final boolean isSelfKill() { return getKiller() != null && getKiller().isPlayer(getVictim()); } + public final boolean isSelfAssist() { + return getAssister() != null && getAssister().isPlayer(getVictim()); + } + /** - * Get whether the death was from an enemy and it was no caused by suicide. + * Get whether the death was from an enemy and it was not caused by suicide. * * @return Whether the death was actually "challenging." */ @@ -167,6 +181,15 @@ public final boolean isChallengeKill() { return isEnemyKill() && !isSelfKill(); } + /** + * Get whether the assist was from an enemy and it was not caused by suicide. + * + * @return Whether the assist was actually "challenging." + */ + public final boolean isChallengeAssist() { + return isEnemyAssist() && !isSelfAssist(); + } + private static final HandlerList handlers = new HandlerList(); @Override diff --git a/core/src/main/java/tc/oc/pgm/command/StatsCommand.java b/core/src/main/java/tc/oc/pgm/command/StatsCommand.java index 7bdf8c9234..461f32fb46 100644 --- a/core/src/main/java/tc/oc/pgm/command/StatsCommand.java +++ b/core/src/main/java/tc/oc/pgm/command/StatsCommand.java @@ -30,13 +30,12 @@ public void stats( if (match.isFinished() && PGM.get().getConfiguration().showVerboseStats() && match.hasModule(TeamMatchModule.class)) { // Should not try to trigger on FFA - stats.giveVerboseStatsItem(player, true); + stats.openStatsMenu(player); } else if (player.getSettings().getValue(SettingKey.STATS).equals(SettingValue.STATS_ON)) { - audience.sendMessage( - TextFormatter.horizontalLineHeading( - sender, - translatable("match.stats.you", NamedTextColor.DARK_GREEN), - NamedTextColor.WHITE)); + audience.sendMessage(TextFormatter.horizontalLineHeading( + sender, + translatable("match.stats.you", NamedTextColor.DARK_GREEN), + NamedTextColor.WHITE)); audience.sendMessage(stats.getBasicStatsMessage(player.getId())); } else { throw exception("match.stats.disabled"); diff --git a/core/src/main/java/tc/oc/pgm/stats/PlayerStats.java b/core/src/main/java/tc/oc/pgm/stats/PlayerStats.java index 651234986a..5c1ab7db50 100644 --- a/core/src/main/java/tc/oc/pgm/stats/PlayerStats.java +++ b/core/src/main/java/tc/oc/pgm/stats/PlayerStats.java @@ -1,16 +1,25 @@ package tc.oc.pgm.stats; -import static net.kyori.adventure.text.Component.translatable; -import static tc.oc.pgm.util.text.NumberComponent.number; +import static tc.oc.pgm.stats.StatType.ASSISTS; +import static tc.oc.pgm.stats.StatType.DEATHS; +import static tc.oc.pgm.stats.StatType.KILLS; +import static tc.oc.pgm.stats.StatType.KILL_DEATH_RATIO; +import static tc.oc.pgm.stats.StatType.KILL_STREAK; import java.time.Duration; import java.time.Instant; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; +import tc.oc.pgm.api.match.MatchScope; +import tc.oc.pgm.api.player.MatchPlayer; +import tc.oc.pgm.api.setting.SettingKey; +import tc.oc.pgm.api.setting.SettingValue; +import tc.oc.pgm.util.Audience; /** A wrapper for stat info belonging to a {@link tc.oc.pgm.api.player.MatchPlayer} */ -public class PlayerStats { +public class PlayerStats implements StatHolder { // A reference to the players global stats to be incremented along with team based stats private final PlayerStats parent; @@ -22,6 +31,7 @@ public class PlayerStats { // K/D private int kills; private int deaths; + private int assists; private int killstreak; // Current killstreak private int killstreakMax; // The highest killstreak reached this match @@ -53,7 +63,7 @@ public class PlayerStats { // The task responsible for displaying the stats over the hotbar // See StatsMatchModule#sendLongHotbarMessage - private Future hotbarTaskCache; + private Future hotbarTask; public PlayerStats() { this(null, null); @@ -68,17 +78,25 @@ public PlayerStats(PlayerStats parent, Component component) { // Methods to update the stats, should only be accessed by StatsMatchModule - protected void onMurder() { + protected void onMurder(MatchPlayer player) { kills++; killstreak++; if (killstreak > killstreakMax) killstreakMax = killstreak; - if (parent != null) parent.onMurder(); + if (parent != null) parent.onMurder(null); + sendPlayerStats(player); } - protected void onDeath() { + protected void onDeath(MatchPlayer player) { deaths++; killstreak = 0; - if (parent != null) parent.onDeath(); + if (parent != null) parent.onDeath(null); + sendPlayerStats(player); + } + + protected void onAssist(MatchPlayer player) { + assists++; + if (parent != null) parent.onAssist(null); + sendPlayerStats(player); } protected void onDamage(double damage, boolean bow) { @@ -148,9 +166,7 @@ protected void onWoolTouch() { } protected void setLongestBowKill(double distance) { - if (distance > longestBowKill) { - longestBowKill = (int) Math.round(distance); - } + if (distance > longestBowKill) longestBowKill = (int) Math.round(distance); if (parent != null) parent.setLongestBowKill(distance); } @@ -167,16 +183,24 @@ protected void onTeamSwitch() { // Makes a simple stat message for this player that fits in one line public Component getBasicStatsMessage() { - return translatable( - "match.stats", - NamedTextColor.GRAY, - number(kills, NamedTextColor.GREEN), - number(killstreak, NamedTextColor.GREEN), - number(deaths, NamedTextColor.RED), - number(getKD(), NamedTextColor.GREEN)); + return pipeSeparated(KILLS, DEATHS, ASSISTS, KILL_STREAK, KILL_DEATH_RATIO) + .color(NamedTextColor.GRAY); } // Getters, both raw stats and some handy calculations + @Override + public Number getStat(StatType type) { + return switch (type) { + case KILLS -> kills; + case DEATHS -> deaths; + case ASSISTS -> assists; + case KILL_STREAK -> killstreak; + case BEST_KILL_STREAK -> killstreakMax; + case KILL_DEATH_RATIO -> getKD(); + case LONGEST_BOW_SHOT -> longestBowKill; + case DAMAGE -> damageDone; + }; + } public double getKD() { return kills / Math.max(1d, deaths); @@ -196,6 +220,10 @@ public int getDeaths() { return deaths; } + public int getAssists() { + return assists; + } + public int getKillstreak() { return killstreak; } @@ -264,12 +292,18 @@ public Duration getLongestFlagHold() { return longestFlagHold; } - public Future getHotbarTask() { - return hotbarTaskCache; + private void setHotbarTask(Future task) { + if (hotbarTask != null && !hotbarTask.isDone()) hotbarTask.cancel(true); + hotbarTask = task; } - public void putHotbarTaskCache(Future task) { - hotbarTaskCache = task; + public void sendPlayerStats(MatchPlayer player) { + if (player == null) return; + if (player.getSettings().getValue(SettingKey.STATS) == SettingValue.STATS_OFF) return; + setHotbarTask(player + .getMatch() + .getExecutor(MatchScope.LOADED) + .scheduleWithFixedDelay(new HotbarStatsRunner(player), 0, 1, TimeUnit.SECONDS)); } public Component getPlayerComponent() { @@ -297,6 +331,23 @@ public Duration getTimePlayed() { } public Duration getActiveSessionDuration() { - return (inTime == null) ? Duration.ZERO : Duration.between(inTime, Instant.now()); + return inTime == null ? Duration.ZERO : Duration.between(inTime, Instant.now()); + } + + protected class HotbarStatsRunner implements Runnable { + private final Audience audience; + private final Component message; + private int remaining = 4; + + private HotbarStatsRunner(Audience audience) { + this.audience = audience; + this.message = getBasicStatsMessage(); + } + + @Override + public void run() { + audience.sendActionBar(message); + if (remaining-- < 0) setHotbarTask(null); + } } } diff --git a/core/src/main/java/tc/oc/pgm/stats/StatHolder.java b/core/src/main/java/tc/oc/pgm/stats/StatHolder.java new file mode 100644 index 0000000000..000dca5a4b --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/stats/StatHolder.java @@ -0,0 +1,29 @@ +package tc.oc.pgm.stats; + +import static net.kyori.adventure.text.Component.join; +import static net.kyori.adventure.text.Component.text; +import static net.kyori.adventure.text.JoinConfiguration.separator; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; + +public interface StatHolder { + JoinConfiguration PIPE = separator(text(" | ")); + JoinConfiguration SPACES = separator(text(" ")); + + Number getStat(StatType type); + + default Component pipeSeparated(StatType... types) { + return getComponent(PIPE, types); + } + + default Component spaceSeparated(StatType... types) { + return getComponent(SPACES, types); + } + + default Component getComponent(JoinConfiguration joining, StatType... types) { + Component[] children = new Component[types.length]; + for (int i = 0; i < types.length; i++) children[i] = types[i].component(this); + return join(joining, children); + } +} diff --git a/core/src/main/java/tc/oc/pgm/stats/StatType.java b/core/src/main/java/tc/oc/pgm/stats/StatType.java new file mode 100644 index 0000000000..fe43005b2d --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/stats/StatType.java @@ -0,0 +1,43 @@ +package tc.oc.pgm.stats; + +import static net.kyori.adventure.text.Component.translatable; +import static net.kyori.adventure.text.format.NamedTextColor.*; +import static tc.oc.pgm.stats.StatsMatchModule.damageComponent; +import static tc.oc.pgm.util.text.NumberComponent.number; + +import java.util.Locale; +import net.kyori.adventure.text.Component; + +public enum StatType { + KILLS, + DEATHS, + ASSISTS, + KILL_STREAK, + BEST_KILL_STREAK, + KILL_DEATH_RATIO, + LONGEST_BOW_SHOT { + private final String blocks = key + ".blocks"; + + @Override + public Component makeNumber(Number number) { + return translatable(blocks, number(number, YELLOW)); + } + }, + DAMAGE { + @Override + public Component makeNumber(Number number) { + return damageComponent(number.doubleValue(), GREEN); + } + }; + + public final String key = "match.stats.type." + name().toLowerCase(Locale.ROOT); + public static final StatType[] VALUES = values(); + + public Component makeNumber(Number number) { + return number(number, this == DEATHS ? RED : GREEN); + } + + public Component component(StatHolder stats) { + return translatable(key, makeNumber(stats.getStat(this))); + } +} diff --git a/core/src/main/java/tc/oc/pgm/stats/StatsMatchModule.java b/core/src/main/java/tc/oc/pgm/stats/StatsMatchModule.java index a2ce93b7dd..7e22c60309 100644 --- a/core/src/main/java/tc/oc/pgm/stats/StatsMatchModule.java +++ b/core/src/main/java/tc/oc/pgm/stats/StatsMatchModule.java @@ -1,31 +1,31 @@ package tc.oc.pgm.stats; +import static net.kyori.adventure.text.Component.empty; import static net.kyori.adventure.text.Component.text; import static net.kyori.adventure.text.Component.translatable; -import static net.kyori.adventure.text.event.HoverEvent.showText; -import static tc.oc.pgm.util.nms.PlayerUtils.PLAYER_UTILS; import static tc.oc.pgm.util.player.PlayerComponent.player; import static tc.oc.pgm.util.text.NumberComponent.number; +import static tc.oc.pgm.util.text.TextFormatter.list; +import com.google.common.collect.Collections2; import com.google.common.collect.HashBasedTable; -import com.google.common.collect.Lists; import com.google.common.collect.Table; import com.google.common.collect.Tables; import java.time.Duration; import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; -import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import java.util.stream.Stream; 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.format.TextDecoration; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -50,7 +50,6 @@ import tc.oc.pgm.api.player.MatchPlayer; import tc.oc.pgm.api.player.MatchPlayerState; import tc.oc.pgm.api.player.ParticipantState; -import tc.oc.pgm.api.player.PlayerRelation; import tc.oc.pgm.api.player.event.MatchPlayerDeathEvent; import tc.oc.pgm.api.setting.SettingKey; import tc.oc.pgm.api.setting.SettingValue; @@ -72,11 +71,13 @@ import tc.oc.pgm.stats.menu.StatsMainMenu; import tc.oc.pgm.stats.menu.items.PlayerStatsMenuItem; import tc.oc.pgm.stats.menu.items.TeamStatsMenuItem; +import tc.oc.pgm.stats.menu.items.VerboseStatsMenuItem; import tc.oc.pgm.teams.Team; import tc.oc.pgm.tracker.TrackerMatchModule; import tc.oc.pgm.tracker.info.ProjectileInfo; -import tc.oc.pgm.util.Pair; import tc.oc.pgm.util.named.NameStyle; +import tc.oc.pgm.util.player.PlayerComponent; +import tc.oc.pgm.util.text.RenderableComponent; import tc.oc.pgm.util.text.TextFormatter; import tc.oc.pgm.util.usernames.UsernameResolvers; import tc.oc.pgm.wool.MonumentWool; @@ -131,12 +132,12 @@ public void onPlayerJoinMatch(final PlayerJoinPartyEvent event) { // End time tracking for old party if (event.getOldParty() instanceof Competitor) { - computeTeamStatsIfAbsent(event.getPlayer().getId(), event.getOldParty()).endParticipation(); + getPlayerTeamStats(event.getPlayer().getId(), event.getOldParty()).endParticipation(); } // When joining a party that's playing, start time tracking if (event.getNewParty() instanceof Competitor) { - computeTeamStatsIfAbsent(event.getPlayer().getId(), event.getNewParty()).startParticipation(); + getPlayerTeamStats(event.getPlayer().getId(), event.getNewParty()).startParticipation(); } } @@ -238,33 +239,21 @@ public void onFlagDrop(FlagStateChangeEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onPlayerDeath(MatchPlayerDeathEvent event) { MatchPlayer victim = event.getVictim(); - MatchPlayer murderer = null; - - if (event.getKiller() != null) - murderer = event.getKiller().getParty().getPlayer(event.getKiller().getId()); - - PlayerStats victimStats = getPlayerStat(victim); - - victimStats.onDeath(); - - sendPlayerStats(victim, victimStats); - - if (murderer != null - && PlayerRelation.get(victim.getParticipantState(), murderer) != PlayerRelation.ALLY - && PlayerRelation.get(victim.getParticipantState(), murderer) != PlayerRelation.SELF) { - - PlayerStats murdererStats = getPlayerStat(murderer); - - if (event.getDamageInfo() instanceof ProjectileInfo) { - murdererStats.setLongestBowKill(victim - .getState() - .getLocation() - .distance(((ProjectileInfo) event.getDamageInfo()).getOrigin())); - } - - murdererStats.onMurder(); + getPlayerStat(victim).onDeath(victim); + + if (event.isChallengeKill()) { + MatchPlayerState killer = event.getKiller(); + assert killer != null; + PlayerStats murdererStats = getPlayerStat(killer); + if (event.getDamageInfo() instanceof ProjectileInfo projectile) + murdererStats.setLongestBowKill(victim.getLocation().distance(projectile.getOrigin())); + murdererStats.onMurder(killer.getPlayer().orElse(null)); + } - sendPlayerStats(murderer, murdererStats); + if (event.isChallengeAssist()) { + MatchPlayerState assister = event.getAssister(); + assert assister != null; + getPlayerStat(assister).onAssist(assister.getPlayer().orElse(null)); } } @@ -273,24 +262,6 @@ public void onParticipationStop(PlayerParticipationStopEvent event) { getPlayerStat(event.getPlayer()).onTeamSwitch(); } - private void sendPlayerStats(MatchPlayer player, PlayerStats stats) { - if (player.getSettings().getValue(SettingKey.STATS) == SettingValue.STATS_OFF) return; - if (stats.getHotbarTask() != null && !stats.getHotbarTask().isDone()) { - stats.getHotbarTask().cancel(true); - } - stats.putHotbarTaskCache(sendLongHotbarMessage(player, stats.getBasicStatsMessage())); - } - - private Future sendLongHotbarMessage(MatchPlayer player, Component message) { - Future task = match - .getExecutor(MatchScope.LOADED) - .scheduleWithFixedDelay(() -> player.sendActionBar(message), 0, 1, TimeUnit.SECONDS); - - match.getExecutor(MatchScope.LOADED).schedule(() -> task.cancel(true), 4, TimeUnit.SECONDS); - - return task; - } - @EventHandler(priority = EventPriority.MONITOR) public void onMatchEnd(MatchFinishEvent event) { if (allPlayerStats.isEmpty() || showAfter.isNegative()) return; @@ -299,7 +270,9 @@ public void onMatchEnd(MatchFinishEvent event) { // when the inventory GUI is created. If usernames needs to be resolved using the mojang api // (UsernameResolver) it can take some time, and we cant really know how long. UsernameResolvers.startBatch(); - this.getOfflinePlayersWithStats().forEach(id -> PGM.get().getDatastore().getUsername(id)); + allPlayerStats.keySet().stream() + .filter(id -> match.getPlayer(id) == null) + .forEach(id -> PGM.get().getDatastore().getUsername(id)); UsernameResolvers.endBatch(); // Schedule displaying stats after match end @@ -313,42 +286,25 @@ public void onMatchEnd(MatchFinishEvent event) { @EventHandler(ignoreCancelled = true) public void onStatsDisplay(MatchStatsEvent event) { - if (allPlayerStats.isEmpty()) return; - - // Gather all player stats from this match - Pair bestKills = null; - Pair bestStreaks = null; - Pair bestDeaths = null; - Pair bestBowShots = null; - Pair bestDamage = null; - - for (Map.Entry mapEntry : allPlayerStats.entrySet()) { - UUID uuid = mapEntry.getKey(); - PlayerStats s = mapEntry.getValue(); - bestKills = getBest(bestKills, uuid, s.getKills()); - bestStreaks = getBest(bestStreaks, uuid, s.getMaxKillstreak()); - bestDeaths = getBest(bestDeaths, uuid, s.getDeaths()); - bestBowShots = getBest(bestBowShots, uuid, s.getLongestBowKill()); - bestDamage = getBest(bestDamage, uuid, s.getDamageDone()); - } + if (allPlayerStats.isEmpty() || (!event.isShowOwn() && !event.isShowBest())) return; - List best = new ArrayList<>(); - if (event.isShowBest()) { - best.add(getMessage("match.stats.kills", bestKills, NamedTextColor.GREEN)); - best.add(getMessage("match.stats.killstreak", bestStreaks, NamedTextColor.GREEN)); - best.add(getMessage("match.stats.deaths", bestDeaths, NamedTextColor.RED)); + // Gather aggregated player stats from this match + List> stats = new ArrayList<>(); + stats.add(new AggStat<>(StatType.KILLS, 0, new HashSet<>())); + stats.add(new AggStat<>(StatType.DEATHS, 0, new HashSet<>())); + stats.add(new AggStat<>(StatType.ASSISTS, 0, new HashSet<>())); + stats.add(new AggStat<>(StatType.BEST_KILL_STREAK, 0, new HashSet<>())); + stats.add(new AggStat<>(StatType.LONGEST_BOW_SHOT, 0, new HashSet<>())); + if (verboseStats) stats.add(new AggStat<>(StatType.DAMAGE, 0d, new HashSet<>())); - if (bestBowShots.getRight() > 0) - best.add(getMessage("match.stats.bowshot", bestBowShots, NamedTextColor.YELLOW)); + allPlayerStats.forEach((uuid, s) -> stats.replaceAll(stat -> stat.track(uuid, s))); - if (verboseStats) { - best.add(translatable( - "match.stats.damage", - player(bestDamage.getLeft(), NameStyle.VERBOSE), - damageComponent(bestDamage.getRight(), NamedTextColor.GREEN))); - } - } + var best = stats.stream() + .filter(agg -> agg.value().doubleValue() > 0) + .map(stat -> getMessage(stat, event.isShowBest(), event.isShowOwn())) + .toList(); + var item = new VerboseStatsMenuItem(); for (MatchPlayer viewer : match.getPlayers()) { if (viewer.getSettings().getValue(SettingKey.STATS) == SettingValue.STATS_OFF) continue; @@ -358,23 +314,7 @@ public void onStatsDisplay(MatchStatsEvent event) { NamedTextColor.WHITE)); best.forEach(viewer::sendMessage); - - PlayerStats stats = getPlayerStat(viewer); - - if (event.isShowOwn() && stats != null) { - Component ksHover = translatable( - "match.stats.killstreak.concise", number(stats.getKillstreak(), NamedTextColor.GREEN)); - - viewer.sendMessage(translatable( - "match.stats.own", - number(stats.getKills(), NamedTextColor.GREEN), - number(stats.getMaxKillstreak(), NamedTextColor.GREEN).hoverEvent(showText(ksHover)), - number(stats.getDeaths(), NamedTextColor.RED), - number(stats.getKD(), NamedTextColor.GREEN), - damageComponent(stats.getDamageDone(), NamedTextColor.GREEN))); - } - - giveVerboseStatsItem(viewer, false); + viewer.getInventory().setItem(verboseItemSlot, item.createItem(viewer.getBukkit())); } } @@ -385,47 +325,59 @@ public void onToolClick(PlayerInteractEvent event) { Action action = event.getAction(); if (action == Action.RIGHT_CLICK_AIR || action == Action.RIGHT_CLICK_BLOCK) { MatchPlayer player = match.getPlayer(event.getPlayer()); - if (player != null) giveVerboseStatsItem(player, true); + if (player != null) openStatsMenu(player); } } - public PlayerStatsMenuItem getPlayerStatsItem(MatchPlayer player) { - return new PlayerStatsMenuItem( - player.getId(), - this.getGlobalPlayerStat(player), - PLAYER_UTILS.getPlayerSkin(player.getBukkit())); - } - - public void giveVerboseStatsItem(MatchPlayer player, boolean forceOpen) { + public void openStatsMenu(MatchPlayer player) { if (!verboseStats) return; - final Collection competitors = match.getSortedCompetitors(); - if (teams == null) { - teams = Lists.newArrayList(); + var competitors = match.getSortedCompetitors(); + teams = new ArrayList<>(competitors.size()); for (Competitor competitor : competitors) { - if (competitor instanceof Team t) { + MatchPlayer tribute; + if (competitor instanceof Team t) teams.add(new TeamStatsMenuItem(match, competitor, stats.row(t))); - } else if (competitor instanceof Tribute t) { - MatchPlayer tribute = t.getPlayer(); - if (tribute != null) teams.add(getPlayerStatsItem(tribute)); - } + else if (competitor instanceof Tribute t && (tribute = t.getPlayer()) != null) + teams.add(new PlayerStatsMenuItem(tribute, getGlobalPlayerStat(tribute))); } } + new StatsMainMenu(player, teams, this).open(); + } - StatsMainMenu menu = new StatsMainMenu(player, teams, this); - player.getInventory().setItem(verboseItemSlot, menu.getItem()); - if (forceOpen) menu.open(); + private Component getMessage(AggStat agg, boolean best, boolean own) { + Component who = empty(); + if (best) + who = translatable("misc.authorship", agg.type.makeNumber(agg.value), credit(agg.players)); + if (own) + who = who.append((RenderableComponent) v -> { + if (!(v instanceof Player p)) return empty(); + if (agg.players.contains(p.getUniqueId()) || hasNoStats(p.getUniqueId())) return empty(); + var number = agg.type.makeNumber(getGlobalPlayerStat(p.getUniqueId()).getStat(agg.type)); + return !best ? number : text(" ").append(translatable("match.stats.you.short", number)); + }); + return translatable(agg.type.key, who); } - private > Pair getBest(Pair curr, UUID uuid, T alt) { - return curr != null && curr.getRight().compareTo(alt) >= 0 ? curr : Pair.of(uuid, alt); + private Component credit(Set players) { + if (players.size() >= 10) + return translatable("objective.credit.many", NamedTextColor.GRAY, TextDecoration.ITALIC); + + var list = list(Collections2.transform(players, this::getPlayerComponent), null); + if (players.size() > 3) + return translatable("match.stats.severalPlayers", NamedTextColor.GRAY, TextDecoration.ITALIC) + .hoverEvent(list); + return list; } - Component getMessage(String messageKey, Pair mapEntry, TextColor color) { - return translatable( - messageKey, - player(mapEntry.getLeft(), NameStyle.VERBOSE), - number(mapEntry.getRight(), color)); + private Component getPlayerComponent(UUID uuid) { + var player = player(uuid, NameStyle.VERBOSE); + if (player != PlayerComponent.UNKNOWN_PLAYER && player != PlayerComponent.UNKNOWN) + return player; + return stats.column(uuid).values().stream() + .max(Comparator.comparing(PlayerStats::getTimePlayed)) + .map(PlayerStats::getPlayerComponent) + .orElse(player); } /** Formats raw damage to damage relative to the amount of hearths the player would have broken */ @@ -434,20 +386,12 @@ public static Component damageComponent(double damage, TextColor color) { return number(hearts, color).append(HEART_SYMBOL); } - private Stream getOfflinePlayersWithStats() { - return allPlayerStats.keySet().stream().filter(id -> match.getPlayer(id) == null); - } - @Deprecated public final PlayerStats getPlayerStat(UUID uuid) { return getGlobalPlayerStat(uuid); } - private void putNewPlayer(UUID player) { - allPlayerStats.put(player, new PlayerStats()); - } - - private PlayerStats computeTeamStatsIfAbsent(UUID id, Party party) { + private PlayerStats getPlayerTeamStats(UUID id, Party party) { // Only players on a team have team specific stats PlayerStats globalStats = getGlobalPlayerStat(id); if (!(party instanceof Team team)) return globalStats; @@ -466,7 +410,7 @@ private PlayerStats computeTeamStatsIfAbsent(UUID id, Party party) { } public boolean hasNoStats(UUID player) { - return allPlayerStats.get(player) == null; + return !allPlayerStats.containsKey(player); } public final PlayerStats getGlobalPlayerStat(MatchPlayer player) { @@ -475,20 +419,19 @@ public final PlayerStats getGlobalPlayerStat(MatchPlayer player) { // Creates a new PlayerStat if the player does not have one yet public final PlayerStats getGlobalPlayerStat(UUID uuid) { - if (hasNoStats(uuid)) putNewPlayer(uuid); - return allPlayerStats.get(uuid); + return allPlayerStats.computeIfAbsent(uuid, k -> new PlayerStats()); } public final PlayerStats getPlayerStat(ParticipantState player) { - return computeTeamStatsIfAbsent(player.getId(), player.getParty()); + return getPlayerTeamStats(player.getId(), player.getParty()); } public final PlayerStats getPlayerStat(MatchPlayer player) { - return computeTeamStatsIfAbsent(player.getId(), player.getParty()); + return getPlayerTeamStats(player.getId(), player.getParty()); } private PlayerStats getPlayerStat(MatchPlayerState playerState) { - return computeTeamStatsIfAbsent(playerState.getId(), playerState.getParty()); + return getPlayerTeamStats(playerState.getId(), playerState.getParty()); } public Component getBasicStatsMessage(UUID player) { @@ -519,4 +462,16 @@ public Team getPrimaryTeam(UUID uuid, boolean includeObservers) { return primaryTeam.getKey(); } + + private record AggStat>( + StatType type, T value, Set players) { + public AggStat track(UUID uuid, StatHolder stat) { + T newVal = (T) stat.getStat(type); + int cmp = value.compareTo(newVal); + if (cmp > 0) return this; + if (cmp < 0) players.clear(); + players.add(uuid); + return cmp == 0 ? this : new AggStat<>(type, newVal, players); + } + } } diff --git a/core/src/main/java/tc/oc/pgm/stats/TeamStats.java b/core/src/main/java/tc/oc/pgm/stats/TeamStats.java index f54cece4bd..e4edc6ce44 100644 --- a/core/src/main/java/tc/oc/pgm/stats/TeamStats.java +++ b/core/src/main/java/tc/oc/pgm/stats/TeamStats.java @@ -3,7 +3,7 @@ import java.util.Collection; // Holds calculated total stats for a single team -public class TeamStats { +public class TeamStats implements StatHolder { private int teamKills = 0; private int teamDeaths = 0; @@ -34,6 +34,17 @@ public TeamStats(Collection playerStats) { teamBowAcc = shotsTaken == 0 ? Double.NaN : shotsHit / (shotsTaken / (double) 100); } + @Override + public Number getStat(StatType type) { + return switch (type) { + case KILLS -> teamKills; + case DEATHS -> teamDeaths; + case KILL_DEATH_RATIO -> teamKD; + case DAMAGE -> damageDone; + default -> Double.NaN; + }; + } + public int getTeamKills() { return teamKills; } diff --git a/core/src/main/java/tc/oc/pgm/stats/menu/StatsMainMenu.java b/core/src/main/java/tc/oc/pgm/stats/menu/StatsMainMenu.java index 5c6f5b6680..09af70db73 100644 --- a/core/src/main/java/tc/oc/pgm/stats/menu/StatsMainMenu.java +++ b/core/src/main/java/tc/oc/pgm/stats/menu/StatsMainMenu.java @@ -13,6 +13,7 @@ import tc.oc.pgm.menu.MenuItem; import tc.oc.pgm.menu.PagedInventoryMenu; import tc.oc.pgm.stats.StatsMatchModule; +import tc.oc.pgm.stats.menu.items.PlayerStatsMenuItem; import tc.oc.pgm.stats.menu.items.TeamStatsMenuItem; import tc.oc.pgm.stats.menu.items.VerboseStatsMenuItem; @@ -59,7 +60,11 @@ public ItemStack getItem() { @Override public void init(Player player, InventoryContents contents) { - contents.set(0, 4, stats.getPlayerStatsItem(getViewer()).getClickableItem(getBukkit())); + contents.set( + 0, + 4, + new PlayerStatsMenuItem(getViewer(), stats.getGlobalPlayerStat(player.getUniqueId())) + .getClickableItem(getBukkit())); // Use pagination when too many teams are present if (teams.size() > MAX_FANCY_SLOTS) { diff --git a/core/src/main/java/tc/oc/pgm/stats/menu/items/PlayerStatsMenuItem.java b/core/src/main/java/tc/oc/pgm/stats/menu/items/PlayerStatsMenuItem.java index fac0aeeaf6..655735cf76 100644 --- a/core/src/main/java/tc/oc/pgm/stats/menu/items/PlayerStatsMenuItem.java +++ b/core/src/main/java/tc/oc/pgm/stats/menu/items/PlayerStatsMenuItem.java @@ -1,19 +1,24 @@ package tc.oc.pgm.stats.menu.items; +import static net.kyori.adventure.text.Component.empty; import static net.kyori.adventure.text.Component.text; import static net.kyori.adventure.text.Component.translatable; +import static net.kyori.adventure.text.JoinConfiguration.separator; import static net.kyori.adventure.text.format.NamedTextColor.GRAY; +import static tc.oc.pgm.stats.StatType.*; import static tc.oc.pgm.stats.StatsMatchModule.damageComponent; import static tc.oc.pgm.util.nms.NMSHacks.NMS_HACKS; import static tc.oc.pgm.util.nms.PlayerUtils.PLAYER_UTILS; import static tc.oc.pgm.util.player.PlayerComponent.player; import static tc.oc.pgm.util.text.NumberComponent.number; +import com.google.common.collect.Lists; import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.UUID; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; import org.bukkit.Bukkit; @@ -22,6 +27,7 @@ import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.SkullMeta; +import tc.oc.pgm.api.player.MatchPlayer; import tc.oc.pgm.menu.MenuItem; import tc.oc.pgm.stats.PlayerStats; import tc.oc.pgm.util.material.Materials; @@ -32,11 +38,16 @@ /** Represents a player's stats via player head & lore * */ public class PlayerStatsMenuItem implements MenuItem { + private static final JoinConfiguration TWO_SPACES = separator(text(" ")); private final UUID uuid; private final PlayerStats stats; private final Skin skin; + public PlayerStatsMenuItem(MatchPlayer player, PlayerStats stats) { + this(player.getId(), stats, PLAYER_UTILS.getPlayerSkin(player.getBukkit())); + } + public PlayerStatsMenuItem(UUID uuid, PlayerStats stats, Skin skin) { this.uuid = uuid; this.stats = stats; @@ -52,65 +63,42 @@ public Component getDisplayName() { @Override public List getLore(Player player) { - List lore = new ArrayList<>(); - - Component statLore = translatable( - "match.stats.concise", - GRAY, - number(stats.getKills(), NamedTextColor.GREEN), - number(stats.getDeaths(), NamedTextColor.RED), - number(stats.getKD(), NamedTextColor.GREEN)); - Component killstreakLore = translatable( - "match.stats.killstreak.concise", - GRAY, - number(stats.getMaxKillstreak(), NamedTextColor.GREEN)); - Component damageDealtLore = translatable( + List lore = new ArrayList<>(); + + lore.add(stats.spaceSeparated(KILLS, DEATHS, KILL_DEATH_RATIO)); + lore.add(stats.spaceSeparated(ASSISTS, KILL_STREAK)); + lore.add(translatable( "match.stats.damage.dealt", - GRAY, damageComponent(stats.getDamageDone(), NamedTextColor.GREEN), - damageComponent(stats.getBowDamage(), NamedTextColor.YELLOW)); - Component damageReceivedLore = translatable( + damageComponent(stats.getBowDamage(), NamedTextColor.YELLOW))); + lore.add(translatable( "match.stats.damage.received", - GRAY, damageComponent(stats.getDamageTaken(), NamedTextColor.RED), - damageComponent(stats.getBowDamageTaken(), NamedTextColor.GOLD)); - Component bowLore = translatable( + damageComponent(stats.getBowDamageTaken(), NamedTextColor.GOLD))); + lore.add(translatable( "match.stats.bow", - GRAY, number(stats.getShotsHit(), NamedTextColor.YELLOW), number(stats.getShotsTaken(), NamedTextColor.YELLOW), - number(stats.getArrowAccuracy(), NamedTextColor.YELLOW).append(text('%'))); - - lore.add(TextTranslations.translateLegacy(statLore, player)); - lore.add(TextTranslations.translateLegacy(killstreakLore, player)); - lore.add(TextTranslations.translateLegacy(damageDealtLore, player)); - lore.add(TextTranslations.translateLegacy(damageReceivedLore, player)); - lore.add(TextTranslations.translateLegacy(bowLore, player)); + number(stats.getArrowAccuracy(), NamedTextColor.YELLOW).append(text('%')))); - if (!optionalStat( - lore, stats.getFlagsCaptured(), "match.stats.flagsCaptured.concise", player)) { + if (!optionalStat(lore, stats.getFlagsCaptured(), "match.stats.flagsCaptured.concise")) { if (!stats.getLongestFlagHold().equals(Duration.ZERO)) { - lore.add(null); - lore.add(TextTranslations.translateLegacy( - translatable( - "match.stats.flaghold.concise", - GRAY, - TemporalComponent.briefNaturalApproximate(stats.getLongestFlagHold()) - .color(NamedTextColor.AQUA) - .decoration(TextDecoration.BOLD, true)), - player)); + lore.add(empty()); + lore.add(translatable( + "match.stats.flaghold.concise", + TemporalComponent.duration(stats.getLongestFlagHold(), NamedTextColor.AQUA) + .decoration(TextDecoration.BOLD, true))); } } - optionalStat(lore, stats.getDestroyablePiecesBroken(), "match.stats.broken.concise", player); + optionalStat(lore, stats.getDestroyablePiecesBroken(), "match.stats.broken.concise"); - return lore; + return Lists.transform(lore, c -> TextTranslations.translateLegacy(c.color(GRAY), player)); } - private boolean optionalStat(List lore, Number stat, String key, Player player) { + private boolean optionalStat(List lore, Number stat, String key) { if (stat.doubleValue() > 0) { - lore.add(null); - Component loreComponent = translatable(key, GRAY, number(stat, NamedTextColor.AQUA)); - lore.add(TextTranslations.translateLegacy(loreComponent, player)); + lore.add(empty()); + lore.add(translatable(key, number(stat, NamedTextColor.AQUA))); return true; } return false; diff --git a/core/src/main/java/tc/oc/pgm/stats/menu/items/TeamStatsMenuItem.java b/core/src/main/java/tc/oc/pgm/stats/menu/items/TeamStatsMenuItem.java index ad88f5d21f..6a96f1f309 100644 --- a/core/src/main/java/tc/oc/pgm/stats/menu/items/TeamStatsMenuItem.java +++ b/core/src/main/java/tc/oc/pgm/stats/menu/items/TeamStatsMenuItem.java @@ -2,9 +2,12 @@ import static net.kyori.adventure.text.Component.text; import static net.kyori.adventure.text.Component.translatable; +import static net.kyori.adventure.text.format.NamedTextColor.GRAY; +import static tc.oc.pgm.stats.StatType.DEATHS; +import static tc.oc.pgm.stats.StatType.KILLS; +import static tc.oc.pgm.stats.StatType.KILL_DEATH_RATIO; import static tc.oc.pgm.stats.StatsMatchModule.damageComponent; import static tc.oc.pgm.util.nms.PlayerUtils.PLAYER_UTILS; -import static tc.oc.pgm.util.player.PlayerComponent.player; import static tc.oc.pgm.util.text.NumberComponent.number; import com.google.common.collect.Lists; @@ -63,38 +66,24 @@ public Component getDisplayName() { @Override public List getLore(Player player) { - List lore = Lists.newArrayList(); + List lore = Lists.newArrayList(); - Component statLore = translatable( - "match.stats.concise", - NamedTextColor.GRAY, - number(stats.getTeamKills(), NamedTextColor.GREEN), - number(stats.getTeamDeaths(), NamedTextColor.RED), - number(stats.getTeamKD(), NamedTextColor.GREEN)); - - Component damageDealtLore = translatable( + lore.add(stats.spaceSeparated(KILLS, DEATHS, KILL_DEATH_RATIO)); + lore.add(translatable( "match.stats.damage.dealt", - NamedTextColor.GRAY, damageComponent(stats.getDamageDone(), NamedTextColor.GREEN), - damageComponent(stats.getBowDamage(), NamedTextColor.YELLOW)); - Component damageReceivedLore = translatable( + damageComponent(stats.getBowDamage(), NamedTextColor.YELLOW))); + lore.add(translatable( "match.stats.damage.received", - NamedTextColor.GRAY, damageComponent(stats.getDamageTaken(), NamedTextColor.RED), - damageComponent(stats.getBowDamageTaken(), NamedTextColor.GOLD)); - Component bowLore = translatable( + damageComponent(stats.getBowDamageTaken(), NamedTextColor.GOLD))); + lore.add(translatable( "match.stats.bow", - NamedTextColor.GRAY, number(stats.getShotsHit(), NamedTextColor.YELLOW), number(stats.getShotsTaken(), NamedTextColor.YELLOW), - number(stats.getTeamBowAcc(), NamedTextColor.YELLOW).append(text('%'))); - - lore.add(TextTranslations.translateLegacy(statLore, player)); - lore.add(TextTranslations.translateLegacy(damageDealtLore, player)); - lore.add(TextTranslations.translateLegacy(damageReceivedLore, player)); - lore.add(TextTranslations.translateLegacy(bowLore, player)); + number(stats.getTeamBowAcc(), NamedTextColor.YELLOW).append(text('%')))); - return lore; + return Lists.transform(lore, c -> TextTranslations.translateLegacy(c.color(GRAY), player)); } @Override diff --git a/util/src/main/i18n/templates/match.properties b/util/src/main/i18n/templates/match.properties index 9ad3b474a6..9b5f0107ef 100644 --- a/util/src/main/i18n/templates/match.properties +++ b/util/src/main/i18n/templates/match.properties @@ -47,46 +47,22 @@ match.class.notEnabled = Classes are not enabled on this map. match.class.notFound = No class matched query. -# {0} = number of kills -# {1} = number of consecutive kills -# {2} = number of deaths -# {3} = numbers of kills divided by the number of deaths (kill-death ratio) -match.stats = Kills: {0} | Killstreak: {1} | Deaths: {2} | K/D: {3} - -# {0} = number of kills -# {1} = number of deaths -# {2} = numbers of kills divided by the number of deaths (kill-death ratio) -match.stats.concise = Kills: {0} Deaths: {1} K/D: {2} - -# {0} = number of kills -# {1} = number of consecutive kills -# {2} = number of deaths -# {3} = numbers of kills divided by the number of deaths (kill-death ratio) -# {4} = an amount of damage -match.stats.own = Your stats: {0} Kills ({1}), {2} Deaths, {3} K/D, {4} Damage - -# {0} = a player name -# {1} = an amount of kills(number) -match.stats.kills = Kills: {1} by {0} - -# {0} = a player name -# {1} = a killstreak(number) -match.stats.killstreak = Killstreak: {1} by {0} - -# {0} = a killstreak(number) -match.stats.killstreak.concise = Best Killstreak: {0} - -# {0} = a player name -# {1} = an amount of deaths(number) -match.stats.deaths = Deaths: {1} by {0} - -# {0} = a player name -# {1} = an amount of blocks -match.stats.bowshot = Longest Shot: {1} blocks by {0} - -# {0} = a player name -# {1} = an amount of damage -match.stats.damage = Damage: {1} by {0} +# These are tied to tc.oc.pgm.stats.StatType enum: +match.stats.type.kills = Kills: {0} +match.stats.type.deaths = Deaths: {0} +match.stats.type.assists = Assists: {0} +match.stats.type.kill_streak = Killstreak: {0} +match.stats.type.best_kill_streak = Killstreak: {0} +match.stats.type.kill_death_ratio = K/D: {0} +match.stats.type.longest_bow_shot = Longest Shot: {0} +match.stats.type.longest_bow_shot.blocks = {0} blocks +match.stats.type.damage = Damage: {0} + +# Used for anywhere from 4 to 10 players +match.stats.severalPlayers = Several Players + +# {0} = a number, how much of a certain stat you got +match.stats.you.short = (You: {0}) # {0} = an amount of damage # {1} = an amount of damage