From 45ed042e4a626a1974e08d1324ec866374327490 Mon Sep 17 00:00:00 2001 From: Pablete1234 Date: Sun, 5 Jan 2020 06:07:36 +0100 Subject: [PATCH] Add several enhancments to map voting, including book gui Signed-off-by: Pablete1234 --- src/main/i18n/templates/strings.properties | 2 + .../tc/oc/pgm/commands/MapPoolCommands.java | 32 +-- src/main/java/tc/oc/pgm/rotation/MapPoll.java | 199 ++++++++++++++++++ .../java/tc/oc/pgm/rotation/VotingPool.java | 184 +++------------- src/main/java/tc/oc/world/NMSHacks.java | 14 ++ 5 files changed, 259 insertions(+), 172 deletions(-) create mode 100644 src/main/java/tc/oc/pgm/rotation/MapPoll.java diff --git a/src/main/i18n/templates/strings.properties b/src/main/i18n/templates/strings.properties index 3f9d17801b..e1d9aa6577 100644 --- a/src/main/i18n/templates/strings.properties +++ b/src/main/i18n/templates/strings.properties @@ -545,6 +545,8 @@ command.pools.mapPoolList.title = Loaded Map Pools command.pools.skip.message = Skipped a total of {0} positions. command.pools.skip.noNegative = You may not skip negative positions! +command.pool.vote.book.title = Map Vote +command.pool.vote.book.header = Click to select all maps you'd like to play: command.pool.vote.hover = Click to toggle vote command.pool.vote.noVote = There is no poll to vote in currently. command.pool.vote.voted = You have voted for {0}. diff --git a/src/main/java/tc/oc/pgm/commands/MapPoolCommands.java b/src/main/java/tc/oc/pgm/commands/MapPoolCommands.java index 83dc185fda..bbe5a54990 100644 --- a/src/main/java/tc/oc/pgm/commands/MapPoolCommands.java +++ b/src/main/java/tc/oc/pgm/commands/MapPoolCommands.java @@ -5,6 +5,7 @@ import app.ashcon.intake.parametric.annotation.Default; import app.ashcon.intake.parametric.annotation.Switch; import app.ashcon.intake.parametric.annotation.Text; +import java.text.DecimalFormat; import java.util.List; import net.md_5.bungee.api.ChatColor; import org.bukkit.command.CommandSender; @@ -17,26 +18,26 @@ import tc.oc.pgm.api.match.MatchManager; import tc.oc.pgm.api.player.MatchPlayer; import tc.oc.pgm.map.PGMMap; -import tc.oc.pgm.rotation.MapPool; -import tc.oc.pgm.rotation.MapPoolManager; -import tc.oc.pgm.rotation.Rotation; -import tc.oc.pgm.rotation.VotingPool; +import tc.oc.pgm.rotation.*; import tc.oc.pgm.util.PrettyPaginatedResult; import tc.oc.util.components.ComponentUtils; public class MapPoolCommands { + private static final DecimalFormat SCORE_FORMAT = new DecimalFormat("00.00%"); + @Command( aliases = {"rotation", "rot", "pool"}, - desc = "Shows the maps in the active rotation", - usage = "[page] [-r rotation]", - help = "Shows all the maps that are currently in the active rotation.") + desc = "Shows the maps in the active map pool", + usage = "[page] [-p pool] [-s scores]", + help = "Shows all the maps that are currently in the active map pool.") public static void rotation( Audience audience, CommandSender sender, MatchManager matchManager, @Default("1") int page, @Switch('r') String rotationName, - @Switch('p') String poolName) + @Switch('p') String poolName, + @Switch('s') boolean scores) throws CommandException { if (rotationName != null) poolName = rotationName; @@ -61,15 +62,20 @@ public static void rotation( ChatColor.DARK_AQUA + " (" + ChatColor.AQUA + mapPool.getName() + ChatColor.DARK_AQUA + ")"; title = ComponentUtils.paginate(title, page, pages); title = ComponentUtils.horizontalLineHeading(title, ChatColor.BLUE, 250); + + VotingPool votes = scores && mapPool instanceof VotingPool ? (VotingPool) mapPool : null; + int nextPos = mapPool instanceof Rotation ? ((Rotation) mapPool).getNextPosition() : -1; new PrettyPaginatedResult(title, resultsPerPage) { @Override public String format(PGMMap map, int index) { index++; - String indexString = - nextPos == index ? ChatColor.DARK_AQUA.toString() + index : String.valueOf(index); - return (indexString) + ". " + ChatColor.RESET + map.getInfo().getShortDescription(sender); + String str = (nextPos == index ? ChatColor.DARK_AQUA + "" : "") + index + ". "; + if (votes != null) + str += ChatColor.YELLOW + SCORE_FORMAT.format(votes.getMapScore(map)) + " "; + str += ChatColor.RESET + map.getInfo().getShortDescription(sender); + return str; } }.display(audience, maps, page); } @@ -168,7 +174,7 @@ public static void voteNext( MatchPlayer player, CommandSender sender, MatchManager matchManager, @Text PGMMap map) throws CommandException { MapPool pool = getMapPoolManager(sender, matchManager).getActiveMapPool(); - VotingPool.Poll poll = pool instanceof VotingPool ? ((VotingPool) pool).getCurrentPoll() : null; + MapPoll poll = pool instanceof VotingPool ? ((VotingPool) pool).getCurrentPoll() : null; if (poll == null) { sender.sendMessage( ChatColor.RED + AllTranslations.get().translate("command.pool.vote.noVote", sender)); @@ -181,7 +187,7 @@ public static void voteNext( voteResult ? "command.pool.vote.voted" : "command.pool.vote.removedVote", map.getName()); sender.sendMessage(new PersonalizedText(tr, voteResult ? ChatColor.GREEN : ChatColor.RED)); - poll.sendMessage(player, false); + poll.sendBook(player); } private static MapPoolManager getMapPoolManager(CommandSender sender, MatchManager matchManager) diff --git a/src/main/java/tc/oc/pgm/rotation/MapPoll.java b/src/main/java/tc/oc/pgm/rotation/MapPoll.java new file mode 100644 index 0000000000..21d2c9b821 --- /dev/null +++ b/src/main/java/tc/oc/pgm/rotation/MapPoll.java @@ -0,0 +1,199 @@ +package tc.oc.pgm.rotation; + +import app.ashcon.intake.CommandException; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.HoverEvent; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BookMeta; +import tc.oc.component.Component; +import tc.oc.component.types.PersonalizedText; +import tc.oc.component.types.PersonalizedTranslatable; +import tc.oc.pgm.AllTranslations; +import tc.oc.pgm.api.Permissions; +import tc.oc.pgm.api.match.Match; +import tc.oc.pgm.api.player.MatchPlayer; +import tc.oc.pgm.map.PGMMap; +import tc.oc.world.NMSHacks; + +import java.lang.ref.WeakReference; +import java.util.*; +import java.util.stream.Collectors; + +/** Represents a polling process, with a set of options. */ +public class MapPoll { + private static final String SYMBOL_IGNORE = "\u2715"; // ✕ + private static final String SYMBOL_VOTED = "\u2714"; // ✔ + + private final WeakReference match; + private final Map mapScores; + private final Map> votes = new HashMap<>(); + + MapPoll(Match match, Map mapScores, int voteSize) { + this.match = new WeakReference<>(match); + this.mapScores = mapScores; + // Sorting beforehand, saves future key remaps, as bigger values are placed at the end + List sortedDist = + mapScores.entrySet().stream() + .sorted(Comparator.comparingDouble(Map.Entry::getValue)) + .map(Map.Entry::getKey) + .collect(Collectors.toList()); + + NavigableMap cumulativeScores = new TreeMap<>(); + double maxWeight = cummulativeMap(0, sortedDist, cumulativeScores); + + for (int i = 0; i < voteSize; i++) { + NavigableMap subMap = + cumulativeScores.tailMap(Math.random() * maxWeight, true); + Map.Entry selected = subMap.pollFirstEntry(); + // Add map to votes + votes.put(selected.getValue(), new HashSet<>()); + // No need to do replace logic after maps have been selected + if (votes.size() >= voteSize) break; + + // Remove map from pool, updating cumulative scores + double selectedWeight = mapScores.get(selected.getValue()); + maxWeight -= selectedWeight; + + NavigableMap temp = new TreeMap<>(); + cummulativeMap(selected.getKey() - selectedWeight, subMap.values(), temp); + + subMap.clear(); + cumulativeScores.putAll(temp); + } + } + + private double cummulativeMap( + double currWeight, Collection maps, Map result) { + for (PGMMap map : maps) { + double score = mapScores.get(map); + if (score > 0) result.put(currWeight += score, map); + } + return currWeight; + } + + public void sendMessage(MatchPlayer viewer) { + for (PGMMap pgmMap : votes.keySet()) viewer.sendMessage(getMapChatComponent(viewer, pgmMap)); + } + + private Component getMapChatComponent(MatchPlayer viewer, PGMMap map) { + boolean voted = votes.get(map).contains(viewer.getId()); + return new PersonalizedText( + new PersonalizedText("["), + new PersonalizedText( + voted ? SYMBOL_VOTED : SYMBOL_IGNORE, voted ? ChatColor.GREEN : ChatColor.DARK_RED), + new PersonalizedText(" ").bold(!voted), // Fix 1px symbol diff + new PersonalizedText("" + countVotes(votes.get(map)), ChatColor.YELLOW), + new PersonalizedText("] "), + new PersonalizedText(map.getInfo().getShortDescription(viewer.getBukkit()) + " ")); + } + + public void sendBook(MatchPlayer viewer) { + String title = ChatColor.GOLD + "" + ChatColor.BOLD; + title += AllTranslations.get().translate("command.pool.vote.book.title", viewer.getBukkit()); + + ItemStack is = new ItemStack(Material.WRITTEN_BOOK); + BookMeta meta = (BookMeta) is.getItemMeta(); + meta.setAuthor("PGM"); + meta.setTitle(title); + + List content = new ArrayList<>(votes.size() + 2); + content.add( + new PersonalizedText( + new PersonalizedTranslatable("command.pool.vote.book.header"), + ChatColor.DARK_PURPLE)); + content.add(new PersonalizedText("\n\n")); + + for (PGMMap pgmMap : votes.keySet()) content.add(getMapBookComponent(viewer, pgmMap)); + + NMSHacks.setBookPages(meta, new PersonalizedText(content).render(viewer.getBukkit())); + is.setItemMeta(meta); + + ItemStack held = viewer.getInventory().getItemInHand(); + if (held.getType() != Material.WRITTEN_BOOK + || !title.equals(((BookMeta) is.getItemMeta()).getTitle())) { + viewer.getInventory().setHeldItemSlot(2); + } + viewer.getInventory().setItemInHand(is); + NMSHacks.openBook(is, viewer.getBukkit()); + } + + private Component getMapBookComponent(MatchPlayer viewer, PGMMap map) { + boolean voted = votes.get(map).contains(viewer.getId()); + return new PersonalizedText( + new PersonalizedText( + voted ? SYMBOL_VOTED : SYMBOL_IGNORE, + voted ? ChatColor.DARK_GREEN : ChatColor.DARK_RED), + new PersonalizedText(" ").bold(!voted), // Fix 1px symbol diff + new PersonalizedText(map.getName() + "\n", ChatColor.BOLD, ChatColor.GOLD)) + .hoverEvent( + HoverEvent.Action.SHOW_TEXT, + new PersonalizedTranslatable("command.pool.vote.hover").render(viewer.getBukkit())) + .clickEvent(ClickEvent.Action.RUN_COMMAND, "/votenext " + map.getName()); + } + + /** + * Toggle the vote of a user for a certain map. Player is allowed to vote for several maps. + * + * @param vote The map to vote for/against + * @param player The player voting + * @return true if the player is now voting for the map, false otherwise + * @throws CommandException If the map is not an option in the poll + */ + public boolean toggleVote(PGMMap vote, UUID player) throws CommandException { + Set votes = this.votes.get(vote); + if (votes == null) + throw new CommandException(vote.getName() + " is not an option in the poll"); + + if (votes.add(player)) return true; + votes.remove(player); + return false; + } + + /** @return The map currently winning the vote, null if no vote is running. */ + private PGMMap getMostVotedMap() { + return votes.entrySet().stream() + .max(Comparator.comparingInt(e -> countVotes(e.getValue()))) + .map(Map.Entry::getKey) + .orElse(null); + } + + /** + * Count the amount of votes for a set of uuids. Players with the pgm.premium permission get + * double votes. + * + * @param uuids The players who voted + * @return The number of votes counted + */ + private int countVotes(Set uuids) { + return uuids.stream() + .map(Bukkit::getPlayer) + // Count disconnected players as 1, can't test for their perms + .mapToInt(p -> p == null || !p.hasPermission(Permissions.PREMIUM) ? 1 : 2) + .sum(); + } + + /** + * Picks a winner and ends the vote, updating map scores based on votes + * + * @return The picked map to play after the vote + */ + PGMMap finishVote() { + PGMMap picked = getMostVotedMap(); + Match match = this.match.get(); + if (match != null) { + match.getPlayers().forEach(this::sendMessage); + } + + updateScores(); + return picked; + } + + private void updateScores() { + double voters = votes.values().stream().flatMap(Collection::stream).distinct().count(); + if (voters == 0) return; + votes.forEach((m, v) -> mapScores.put(m, Math.max(v.size() / voters, Double.MIN_VALUE))); + } +} diff --git a/src/main/java/tc/oc/pgm/rotation/VotingPool.java b/src/main/java/tc/oc/pgm/rotation/VotingPool.java index 6b71be32c6..6e2cb27d50 100644 --- a/src/main/java/tc/oc/pgm/rotation/VotingPool.java +++ b/src/main/java/tc/oc/pgm/rotation/VotingPool.java @@ -1,22 +1,13 @@ package tc.oc.pgm.rotation; -import app.ashcon.intake.CommandException; -import java.lang.ref.WeakReference; -import java.util.*; -import java.util.stream.Collectors; -import net.md_5.bungee.api.ChatColor; -import net.md_5.bungee.api.chat.ClickEvent; -import net.md_5.bungee.api.chat.HoverEvent; -import org.bukkit.Bukkit; import org.bukkit.configuration.ConfigurationSection; -import tc.oc.component.Component; -import tc.oc.component.types.PersonalizedText; -import tc.oc.component.types.PersonalizedTranslatable; -import tc.oc.pgm.api.Permissions; import tc.oc.pgm.api.match.Match; -import tc.oc.pgm.api.player.MatchPlayer; +import tc.oc.pgm.api.match.MatchScope; import tc.oc.pgm.map.PGMMap; +import java.util.HashMap; +import java.util.Map; + public class VotingPool extends MapPool { // Number of maps in the vote, unless not enough maps in pool @@ -26,42 +17,51 @@ public class VotingPool extends MapPool { // Amount of maps to display on vote private final int VOTE_SIZE; + private final double ADJUST_FACTOR; private final Map mapScores = new HashMap<>(); - private Poll currentPoll; + private MapPoll currentPoll; public VotingPool(MapPoolManager manager, ConfigurationSection section, String name) { super(manager, section, name); VOTE_SIZE = Math.min(MAX_VOTE_OPTIONS, maps.size() - 1); + ADJUST_FACTOR = 1d / maps.size(); for (PGMMap map : maps) { mapScores.put(map, DEFAULT_WEIGHT); } } - public Poll getCurrentPoll() { + public MapPoll getCurrentPoll() { return currentPoll; } + public double getMapScore(PGMMap map) { + return mapScores.get(map); + } + /** Ticks scores for all maps, making them go slowly towards DEFAULT_WEIGHT. */ private void tickScores() { - mapScores.replaceAll((mapScores, value) -> (value * 3 + DEFAULT_WEIGHT) / 4); + mapScores.replaceAll( + (mapScores, value) -> + value > DEFAULT_WEIGHT + ? Math.max(value - ADJUST_FACTOR, DEFAULT_WEIGHT) + : Math.min(value + ADJUST_FACTOR, DEFAULT_WEIGHT)); } @Override public PGMMap popNextMap() { if (currentPoll == null) return getRandom(); + tickScores(); PGMMap map = currentPoll.finishVote(); currentPoll = null; - tickScores(); - mapScores.put(map, 0d); - return map; + return map != null ? map : getRandom(); } @Override public PGMMap getNextMap() { - return currentPoll == null ? null : currentPoll.getMostVotedMap(); + return null; } @Override @@ -71,146 +71,12 @@ public void setNextMap(PGMMap map) { @Override public void matchEnded(Match match) { + mapScores.put(match.getMap(), 0d); // Ensure same map isn't in vote if (manager.getOverriderMap() != null) return; - currentPoll = new Poll(match); - match.getPlayers().forEach(p -> currentPoll.sendMessage(p, false)); + currentPoll = new MapPoll(match, mapScores, VOTE_SIZE); + match + .getScheduler(MatchScope.LOADED) + .runTaskLater(20 * 5, () -> match.getPlayers().forEach(currentPoll::sendBook)); } - /** Represents a polling process, with a set of options. */ - public class Poll { - private static final String SYMBOL_IGNORE = "\u274c"; // ❌ - private static final String SYMBOL_VOTED = "\u2714"; // ✔ - - private final WeakReference match; - private final Map> votes = new HashMap<>(); - - Poll(Match match) { - this.match = new WeakReference<>(match); - // Sorting beforehand, saves future key remaps, as bigger values are placed at the end - List sortedDist = - mapScores.entrySet().stream() - .sorted(Comparator.comparingDouble(Map.Entry::getValue)) - .map(Map.Entry::getKey) - .collect(Collectors.toList()); - - NavigableMap cumulativeScores = new TreeMap<>(); - double maxWeight = cummulativeMap(0, sortedDist, cumulativeScores); - - for (int i = 0; i < VOTE_SIZE; i++) { - NavigableMap subMap = - cumulativeScores.tailMap(Math.random() * maxWeight, true); - Map.Entry selected = subMap.pollFirstEntry(); - // Add map to votes - votes.put(selected.getValue(), new HashSet<>()); - // No need to do replace logic after maps have been selected - if (votes.size() >= VOTE_SIZE) break; - - // Remove map from pool, updating cumulative scores - double selectedWeight = mapScores.get(selected.getValue()); - maxWeight -= selectedWeight; - - NavigableMap temp = new TreeMap<>(); - cummulativeMap(selected.getKey() - selectedWeight, subMap.values(), temp); - - subMap.clear(); - cumulativeScores.putAll(temp); - } - } - - private double cummulativeMap( - double currWeight, Collection maps, Map result) { - for (PGMMap map : maps) { - double score = mapScores.get(map); - if (score > 0) result.put(currWeight += mapScores.get(map), map); - } - return currWeight; - } - - public void sendMessage(MatchPlayer viewer, boolean showVotes) { - for (PGMMap pgmMap : votes.keySet()) { - viewer.sendMessage(getMapComponent(viewer, pgmMap, showVotes)); - } - } - - private Component getMapComponent(MatchPlayer viewer, PGMMap map, boolean showVotes) { - boolean voted = votes.get(map).contains(viewer.getId()); - return new PersonalizedText( - new PersonalizedText("["), - new PersonalizedText( - voted ? SYMBOL_VOTED : SYMBOL_IGNORE, - voted ? ChatColor.GREEN : ChatColor.DARK_RED), - new PersonalizedText( - showVotes ? " " + countVotes(votes.get(map)) : "", ChatColor.YELLOW), - new PersonalizedText("] "), - new PersonalizedText(map.getInfo().getShortDescription(viewer.getBukkit()) + " ") - // PGM isn't reading this from xml currently - // new PersonalizedText(map.getInfo().getLocalizedGenre()) - ) - .hoverEvent( - HoverEvent.Action.SHOW_TEXT, - new PersonalizedTranslatable("command.pool.vote.hover").render(viewer.getBukkit())) - .clickEvent(ClickEvent.Action.RUN_COMMAND, "/votenext " + map.getName()); - } - - /** - * Toggle the vote of a user for a certain map. Player is allowed to vote for several maps. - * - * @param vote The map to vote for/against - * @param player The player voting - * @return true if the player is now voting for the map, false otherwise - * @throws CommandException If the map is not an option in the poll - */ - public boolean toggleVote(PGMMap vote, UUID player) throws CommandException { - Set votes = this.votes.get(vote); - if (votes == null) - throw new CommandException(vote.getName() + " is not an option in the poll"); - - if (votes.add(player)) return true; - votes.remove(player); - return false; - } - - /** @return The map currently winning the vote, null if no vote is running. */ - private PGMMap getMostVotedMap() { - return votes.entrySet().stream() - .max(Comparator.comparingInt(e -> countVotes(e.getValue()))) - .map(Map.Entry::getKey) - .orElse(null); - } - - /** - * Count the amount of votes for a set of uuids. Players with the pgm.premium permission get - * double votes. - * - * @param uuids The players who voted - * @return The number of votes counted - */ - private int countVotes(Set uuids) { - return uuids.stream() - .map(Bukkit::getPlayer) - // Count disconnected players as 1, can't test for their perms - .mapToInt(p -> p == null || !p.hasPermission(Permissions.PREMIUM) ? 1 : 2) - .sum(); - } - - /** - * Picks a winner and ends the vote - * - * @return The picked map to play after the vote - */ - PGMMap finishVote() { - PGMMap picked = getMostVotedMap(); - if (picked == null) picked = getRandom(); - Match match = this.match.get(); - if (match != null) { - match.getPlayers().forEach(p -> currentPoll.sendMessage(p, true)); - } - - // Amount of players that voted, smaller or equal to amount of votes - double voters = votes.values().stream().flatMap(Collection::stream).distinct().count(); - if (voters > 0) votes.forEach((map, votes) -> mapScores.put(map, votes.size() / voters)); - mapScores.put(picked, 0d); - return picked; - } - } } diff --git a/src/main/java/tc/oc/world/NMSHacks.java b/src/main/java/tc/oc/world/NMSHacks.java index 52160bbcda..cece949be4 100644 --- a/src/main/java/tc/oc/world/NMSHacks.java +++ b/src/main/java/tc/oc/world/NMSHacks.java @@ -7,6 +7,7 @@ import java.util.*; import javax.annotation.Nullable; import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.chat.ComponentSerializer; import net.minecraft.server.v1_8_R3.*; import net.minecraft.server.v1_8_R3.Item; import net.minecraft.server.v1_8_R3.WorldBorder; @@ -18,12 +19,14 @@ import org.bukkit.craftbukkit.v1_8_R3.CraftWorld; import org.bukkit.craftbukkit.v1_8_R3.entity.*; import org.bukkit.craftbukkit.v1_8_R3.inventory.CraftItemStack; +import org.bukkit.craftbukkit.v1_8_R3.inventory.CraftMetaBook; import org.bukkit.craftbukkit.v1_8_R3.potion.CraftPotionEffectType; import org.bukkit.craftbukkit.v1_8_R3.scoreboard.CraftTeam; import org.bukkit.craftbukkit.v1_8_R3.util.CraftMagicNumbers; import org.bukkit.craftbukkit.v1_8_R3.util.Skins; import org.bukkit.entity.*; import org.bukkit.entity.Entity; +import org.bukkit.inventory.meta.BookMeta; import org.bukkit.material.MaterialData; import org.bukkit.potion.PotionEffectType; import org.bukkit.potion.PotionEffectTypeWrapper; @@ -1012,4 +1015,15 @@ static boolean isChunkEmpty(org.bukkit.Chunk chunk) { return true; } + + static void setBookPages(BookMeta book, BaseComponent... pages) { + for (BaseComponent page : pages) { + ((CraftMetaBook) book) + .pages.add(IChatBaseComponent.ChatSerializer.a(ComponentSerializer.toString(page))); + } + } + + static void openBook(org.bukkit.inventory.ItemStack book, Player player) { + ((CraftPlayer) player).getHandle().openBook(CraftItemStack.asNMSCopy(book)); + } }