From a5b717864ea028e039f9aa06779cad2734eb3c00 Mon Sep 17 00:00:00 2001 From: Pablo Herrera Date: Fri, 15 Jul 2022 15:31:14 +0200 Subject: [PATCH] Rework map pools & map vote picker (#1010) Signed-off-by: Pablete1234 --- .../main/java/tc/oc/pgm/api/map/MapOrder.java | 5 +- .../java/tc/oc/pgm/command/MapCommand.java | 2 +- .../tc/oc/pgm/command/MapOrderCommand.java | 2 +- .../tc/oc/pgm/command/MapPoolCommand.java | 16 +- .../java/tc/oc/pgm/command/VotingCommand.java | 42 ++--- .../tc/oc/pgm/events/MapPoolAdjustEvent.java | 2 +- .../tc/oc/pgm/rotation/MapPoolManager.java | 46 ++--- .../tc/oc/pgm/rotation/RandomMapOrder.java | 15 +- .../rotation/{ => pools}/DisabledMapPool.java | 6 +- .../oc/pgm/rotation/{ => pools}/MapPool.java | 6 +- .../rotation/{ => pools}/RandomMapPool.java | 9 +- .../oc/pgm/rotation/{ => pools}/Rotation.java | 3 +- .../pgm/rotation/{ => pools}/VotingPool.java | 79 ++++----- .../oc/pgm/rotation/{ => vote}/MapPoll.java | 101 +++-------- .../oc/pgm/rotation/vote/MapVotePicker.java | 162 ++++++++++++++++++ .../VotePoolOptions.java} | 16 +- .../{ => vote}/VotingBookListener.java | 23 ++- core/src/main/resources/map-pools.yml | 32 ++++ pom.xml | 7 + .../java/tc/oc/pgm/util/math/Formula.java | 46 +++++ 20 files changed, 400 insertions(+), 220 deletions(-) rename core/src/main/java/tc/oc/pgm/rotation/{ => pools}/DisabledMapPool.java (86%) rename core/src/main/java/tc/oc/pgm/rotation/{ => pools}/MapPool.java (97%) rename core/src/main/java/tc/oc/pgm/rotation/{ => pools}/RandomMapPool.java (83%) rename core/src/main/java/tc/oc/pgm/rotation/{ => pools}/Rotation.java (96%) rename core/src/main/java/tc/oc/pgm/rotation/{ => pools}/VotingPool.java (51%) rename core/src/main/java/tc/oc/pgm/rotation/{ => vote}/MapPoll.java (72%) create mode 100644 core/src/main/java/tc/oc/pgm/rotation/vote/MapVotePicker.java rename core/src/main/java/tc/oc/pgm/rotation/{CustomVotingPoolOptions.java => vote/VotePoolOptions.java} (77%) rename core/src/main/java/tc/oc/pgm/rotation/{ => vote}/VotingBookListener.java (63%) create mode 100644 util/src/main/java/tc/oc/pgm/util/math/Formula.java diff --git a/core/src/main/java/tc/oc/pgm/api/map/MapOrder.java b/core/src/main/java/tc/oc/pgm/api/map/MapOrder.java index 1c7fec96f3..9addbf71c3 100644 --- a/core/src/main/java/tc/oc/pgm/api/map/MapOrder.java +++ b/core/src/main/java/tc/oc/pgm/api/map/MapOrder.java @@ -29,13 +29,10 @@ public interface MapOrder { * Forces a specific map to be played next. The underlying {@link MapOrder} may ignore this, but * it is recommended not to. * - * @param map The map to set next + * @param map The map to set next, null to reset */ void setNextMap(MapInfo map); - /** Removes any map that was set manually, returning the server to what was previously chosen. */ - void resetNextMap(); - /** * Returns the duration used for cycles in {@link CycleMatchModule}. * diff --git a/core/src/main/java/tc/oc/pgm/command/MapCommand.java b/core/src/main/java/tc/oc/pgm/command/MapCommand.java index d28635c380..ea0b481776 100644 --- a/core/src/main/java/tc/oc/pgm/command/MapCommand.java +++ b/core/src/main/java/tc/oc/pgm/command/MapCommand.java @@ -36,8 +36,8 @@ import tc.oc.pgm.api.map.MapLibrary; import tc.oc.pgm.api.map.MapTag; import tc.oc.pgm.api.map.Phase; -import tc.oc.pgm.rotation.MapPool; import tc.oc.pgm.rotation.MapPoolManager; +import tc.oc.pgm.rotation.pools.MapPool; import tc.oc.pgm.util.Audience; import tc.oc.pgm.util.PrettyPaginatedComponentResults; import tc.oc.pgm.util.named.MapNameStyle; diff --git a/core/src/main/java/tc/oc/pgm/command/MapOrderCommand.java b/core/src/main/java/tc/oc/pgm/command/MapOrderCommand.java index feee36e83e..0bf94bd74c 100644 --- a/core/src/main/java/tc/oc/pgm/command/MapOrderCommand.java +++ b/core/src/main/java/tc/oc/pgm/command/MapOrderCommand.java @@ -65,7 +65,7 @@ public void setNext( if (reset) { if (mapOrder.getNextMap() != null) { Component mapName = mapOrder.getNextMap().getStyledName(MapNameStyle.COLOR); - mapOrder.resetNextMap(); + mapOrder.setNextMap(null); ChatDispatcher.broadcastAdminChatMessage( translatable( "map.setNext.revert", diff --git a/core/src/main/java/tc/oc/pgm/command/MapPoolCommand.java b/core/src/main/java/tc/oc/pgm/command/MapPoolCommand.java index f8f65afe93..b69f800ce1 100644 --- a/core/src/main/java/tc/oc/pgm/command/MapPoolCommand.java +++ b/core/src/main/java/tc/oc/pgm/command/MapPoolCommand.java @@ -27,11 +27,11 @@ import tc.oc.pgm.api.match.Match; import tc.oc.pgm.api.player.MatchPlayer; import tc.oc.pgm.cycle.CycleCountdown; -import tc.oc.pgm.rotation.MapPoll; -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.pools.MapPool; +import tc.oc.pgm.rotation.pools.Rotation; +import tc.oc.pgm.rotation.pools.VotingPool; +import tc.oc.pgm.rotation.vote.MapPoll; import tc.oc.pgm.util.Audience; import tc.oc.pgm.util.PrettyPaginatedComponentResults; import tc.oc.pgm.util.named.MapNameStyle; @@ -43,7 +43,7 @@ public final class MapPoolCommand { private static final DecimalFormat SCORE_FORMAT = new DecimalFormat("00.00%"); @Command( - aliases = {"pool", "rotation", "rot"}, + aliases = {"pool"}, desc = "List the maps in the map pool", usage = "[page] [-p pool] [-s scores] [-c chance of vote]") public static void pool( @@ -96,7 +96,7 @@ public static void pool( if (chance && votes != null) { double maxWeight = 0, currWeight; for (MapInfo map : votes.getMaps()) { - chances.put(map, currWeight = MapPoll.getWeight(votes.getMapScore(map))); + chances.put(map, currWeight = votes.mapPicker.getWeight(null, map, votes.getMapScore(map))); maxWeight += currWeight; } double finalMaxWeight = maxWeight; @@ -127,7 +127,7 @@ public Component format(MapInfo map, int index) { } @Command( - aliases = {"pools", "rotations", "rots"}, + aliases = {"pools"}, desc = "List all the map pools", flags = "d") public static void pools( @@ -204,7 +204,7 @@ public Component format(MapPool mapPool, int index) { } @Command( - aliases = {"setpool", "setrot"}, + aliases = {"setpool"}, desc = "Change the map pool", usage = "[pool name] -r (revert to dynamic) -t (time limit for map pool) -m (match # limit)", flags = "rtm", diff --git a/core/src/main/java/tc/oc/pgm/command/VotingCommand.java b/core/src/main/java/tc/oc/pgm/command/VotingCommand.java index 270397ee7f..665c9a0ced 100644 --- a/core/src/main/java/tc/oc/pgm/command/VotingCommand.java +++ b/core/src/main/java/tc/oc/pgm/command/VotingCommand.java @@ -23,7 +23,9 @@ import tc.oc.pgm.api.match.Match; import tc.oc.pgm.listeners.ChatDispatcher; import tc.oc.pgm.rotation.MapPoolManager; -import tc.oc.pgm.rotation.VotingPool; +import tc.oc.pgm.rotation.pools.VotingPool; +import tc.oc.pgm.rotation.vote.MapVotePicker; +import tc.oc.pgm.rotation.vote.VotePoolOptions; import tc.oc.pgm.util.Audience; import tc.oc.pgm.util.UsernameFormatUtils; import tc.oc.pgm.util.named.MapNameStyle; @@ -44,7 +46,7 @@ public void addMap( MapOrder mapOrder, Match match) throws CommandException { - VotingPool vote = getVotingPool(sender, mapOrder); + VotePoolOptions vote = getVoteOptions(sender, mapOrder); Component addMessage = translatable( @@ -53,12 +55,12 @@ public void addMap( UsernameFormatUtils.formatStaffName(sender, match), map.getStyledName(MapNameStyle.COLOR)); - if (vote.getOptions().isAdded(map)) { + if (vote.isAdded(map)) { viewer.sendWarning(addMessage); return; } - if (vote.getOptions().addVote(map)) { + if (vote.addVote(map)) { ChatDispatcher.broadcastAdminChatMessage(addMessage, match); } else { viewer.sendWarning(translatable("vote.limit", NamedTextColor.RED)); @@ -77,8 +79,8 @@ public void removeMap( MapOrder mapOrder, Match match) throws CommandException { - VotingPool vote = getVotingPool(sender, mapOrder); - if (vote.getOptions().removeMap(map)) { + VotePoolOptions vote = getVoteOptions(sender, mapOrder); + if (vote.removeMap(map)) { ChatDispatcher.broadcastAdminChatMessage( translatable( "vote.remove", @@ -97,10 +99,10 @@ public void removeMap( perms = Permissions.SETNEXT) public void mode(Audience viewer, CommandSender sender, MapOrder mapOrder, Match match) throws CommandException { - VotingPool vote = getVotingPool(sender, mapOrder); + VotePoolOptions vote = getVoteOptions(sender, mapOrder); Component voteModeName = translatable( - vote.getOptions().toggleMode() ? "vote.mode.replace" : "vote.mode.create", + vote.toggleMode() ? "vote.mode.replace" : "vote.mode.create", NamedTextColor.LIGHT_PURPLE); ChatDispatcher.broadcastAdminChatMessage( translatable( @@ -117,10 +119,10 @@ public void mode(Audience viewer, CommandSender sender, MapOrder mapOrder, Match perms = Permissions.SETNEXT) public void clearMaps(Audience viewer, CommandSender sender, Match match, MapOrder mapOrder) throws CommandException { - VotingPool vote = getVotingPool(sender, mapOrder); + VotePoolOptions vote = getVoteOptions(sender, mapOrder); List maps = - vote.getOptions().getCustomVoteMaps().stream() + vote.getCustomVoteMaps().stream() .map(mi -> mi.getStyledName(MapNameStyle.COLOR)) .collect(Collectors.toList()); Component clearedMsg = @@ -130,7 +132,7 @@ public void clearMaps(Audience viewer, CommandSender sender, Match match, MapOrd UsernameFormatUtils.formatStaffName(sender, match), TextFormatter.list(maps, NamedTextColor.GRAY)); - vote.getOptions().clear(); + vote.clear(); if (maps.isEmpty()) { viewer.sendWarning(translatable("vote.noMapsFound")); @@ -144,17 +146,17 @@ public void clearMaps(Audience viewer, CommandSender sender, Match match, MapOrd desc = "View a list of maps that have been selected for the next vote") public void listMaps(CommandSender sender, Audience viewer, MapOrder mapOrder) throws CommandException { - VotingPool vote = getVotingPool(sender, mapOrder); + VotePoolOptions vote = getVoteOptions(sender, mapOrder); - int currentMaps = vote.getOptions().getCustomVoteMaps().size(); + int currentMaps = vote.getCustomVoteMaps().size(); TextColor listNumColor = - currentMaps >= VotingPool.MIN_CUSTOM_VOTE_OPTIONS - ? currentMaps < VotingPool.MAX_VOTE_OPTIONS + currentMaps >= MapVotePicker.MIN_CUSTOM_VOTE_OPTIONS + ? currentMaps < MapVotePicker.MAX_VOTE_OPTIONS ? NamedTextColor.GREEN : NamedTextColor.YELLOW : NamedTextColor.RED; - String modeKey = vote.getOptions().isReplace() ? "replace" : "create"; + String modeKey = vote.isReplace() ? "replace" : "create"; Component mode = translatable(String.format("vote.mode.%s", modeKey), NamedTextColor.LIGHT_PURPLE) .hoverEvent(showText(translatable("vote.mode.hover", NamedTextColor.AQUA))) @@ -166,7 +168,7 @@ public void listMaps(CommandSender sender, Audience viewer, MapOrder mapOrder) .append(text(": (")) .append(text(currentMaps, listNumColor)) .append(text("/")) - .append(text(VotingPool.MAX_VOTE_OPTIONS, NamedTextColor.RED)) + .append(text(MapVotePicker.MAX_VOTE_OPTIONS, NamedTextColor.RED)) .append(text(") ")) .append(text("\u00BB", NamedTextColor.GOLD)) .append(text(" [")) @@ -177,7 +179,7 @@ public void listMaps(CommandSender sender, Audience viewer, MapOrder mapOrder) viewer.sendMessage(listMsg); int index = 1; - for (MapInfo mi : vote.getOptions().getCustomVoteMaps()) { + for (MapInfo mi : vote.getCustomVoteMaps()) { Component indexedName = text() .append(text(index, NamedTextColor.YELLOW)) @@ -189,7 +191,7 @@ public void listMaps(CommandSender sender, Audience viewer, MapOrder mapOrder) } } - public static VotingPool getVotingPool(CommandSender sender, MapOrder mapOrder) + public static VotePoolOptions getVoteOptions(CommandSender sender, MapOrder mapOrder) throws CommandException { if (mapOrder instanceof MapPoolManager) { MapPoolManager manager = (MapPoolManager) mapOrder; @@ -199,7 +201,7 @@ public static VotingPool getVotingPool(CommandSender sender, MapOrder mapOrder) throw new CommandException( ChatColor.RED + TextTranslations.translate("vote.modify.disallow", sender)); } - return votePool; + return manager.getVoteOptions(); } throw new CommandException( ChatColor.RED + TextTranslations.translate("vote.disabled", sender)); diff --git a/core/src/main/java/tc/oc/pgm/events/MapPoolAdjustEvent.java b/core/src/main/java/tc/oc/pgm/events/MapPoolAdjustEvent.java index 4e178381c8..c36c815caf 100644 --- a/core/src/main/java/tc/oc/pgm/events/MapPoolAdjustEvent.java +++ b/core/src/main/java/tc/oc/pgm/events/MapPoolAdjustEvent.java @@ -6,7 +6,7 @@ import org.bukkit.event.Event; import org.bukkit.event.HandlerList; import tc.oc.pgm.api.match.Match; -import tc.oc.pgm.rotation.MapPool; +import tc.oc.pgm.rotation.pools.MapPool; /** MapPoolAdjustEvent is called when the active {@link MapPool} is set to another * */ public class MapPoolAdjustEvent extends Event { diff --git a/core/src/main/java/tc/oc/pgm/rotation/MapPoolManager.java b/core/src/main/java/tc/oc/pgm/rotation/MapPoolManager.java index 42bbd66d2f..e3585ef0a2 100644 --- a/core/src/main/java/tc/oc/pgm/rotation/MapPoolManager.java +++ b/core/src/main/java/tc/oc/pgm/rotation/MapPoolManager.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.time.Duration; import java.time.Instant; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; @@ -14,6 +15,7 @@ import java.util.stream.Collectors; import javax.annotation.Nullable; import org.apache.commons.io.FileUtils; +import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.FileConfiguration; @@ -27,6 +29,10 @@ import tc.oc.pgm.api.match.Match; import tc.oc.pgm.blitz.BlitzMatchModule; import tc.oc.pgm.events.MapPoolAdjustEvent; +import tc.oc.pgm.rotation.pools.MapPool; +import tc.oc.pgm.rotation.pools.Rotation; +import tc.oc.pgm.rotation.pools.VotingPool; +import tc.oc.pgm.rotation.vote.VotePoolOptions; import tc.oc.pgm.util.TimeUtils; /** @@ -55,15 +61,15 @@ public class MapPoolManager implements MapOrder { private MapInfo overriderMap; /** Options related to voting pools, allows for custom voting @see {@link VotingPool} * */ - private CustomVotingPoolOptions options; + private final VotePoolOptions options; - private Datastore database; + private final Datastore database; public MapPoolManager(Logger logger, File mapPoolsFile, Datastore database) { this.logger = logger; this.mapPoolsFile = mapPoolsFile; this.database = database; - this.options = new CustomVotingPoolOptions(); + this.options = new VotePoolOptions(); if (!mapPoolsFile.exists()) { try { @@ -204,11 +210,11 @@ public MapPool getMapPoolByName(String name) { .orElse(null); } - protected MapInfo getOverriderMap() { + public MapInfo getOverriderMap() { return overriderMap; } - public CustomVotingPoolOptions getCustomVoteOptions() { + public VotePoolOptions getVoteOptions() { return options; } @@ -236,33 +242,29 @@ public MapInfo popNextMap() { @Override public MapInfo getNextMap() { if (overriderMap != null) return overriderMap; - if (activeMapPool != null) return activeMapPool.getNextMap(); - if (activeMapPool == null) return getFallback().getNextMap(); - return null; + return getOrder().getNextMap(); } @Override public void setNextMap(MapInfo map) { overriderMap = map; - // Notify pool/fallback a next map has been set - if (activeMapPool != null) { - activeMapPool.setNextMap(map); - } else { - getFallback().setNextMap(map); - } + getOrder().setNextMap(map); } - @Override - public void resetNextMap() { - if (overriderMap != null) { - overriderMap = null; + public double getActivePlayers(Match match) { + if (match == null) { + Iterator matches = PGM.get().getMatchManager().getMatches(); + // Fallback to just raw online playercount + if (!matches.hasNext()) return Bukkit.getOnlinePlayers().size(); + match = matches.next(); } + double obsBias = match.getModule(BlitzMatchModule.class) != null ? 0.85 : 0.5; + return match.getParticipants().size() + match.getObservers().size() * obsBias; } public Optional getAppropriateDynamicPool(Match match) { - double obsBias = match.getModule(BlitzMatchModule.class) != null ? 0.85 : 0.5; - double activePlayers = match.getParticipants().size() + match.getObservers().size() * obsBias; + double activePlayers = getActivePlayers(match); return mapPools.keySet().stream() .filter(MapPool::isDynamic) .filter(pool -> activePlayers >= pool.getPlayers()) @@ -308,4 +310,8 @@ private boolean shouldRevert(Match match) { private boolean hasMatchCountLimit() { return !activeMapPool.isDynamic() && (matchCountLimit > 0); } + + private MapOrder getOrder() { + return activeMapPool != null ? activeMapPool : getFallback(); + } } diff --git a/core/src/main/java/tc/oc/pgm/rotation/RandomMapOrder.java b/core/src/main/java/tc/oc/pgm/rotation/RandomMapOrder.java index 678124581b..e0c618f3a1 100644 --- a/core/src/main/java/tc/oc/pgm/rotation/RandomMapOrder.java +++ b/core/src/main/java/tc/oc/pgm/rotation/RandomMapOrder.java @@ -19,7 +19,7 @@ public class RandomMapOrder implements MapOrder { private final Random random; private final Deque> deque; - private List origMaps; + private final List origMaps; public RandomMapOrder(List maps) { this.random = new Random(); @@ -69,14 +69,11 @@ public MapInfo getNextMap() { @Override public void setNextMap(MapInfo map) { - // Set next maps are sent to the front of the deque - deque.addFirst(new WeakReference<>(map)); - } - - @Override - public void resetNextMap() { - if (deque.pollFirst() != null) { - deque.removeFirst(); + if (map == null) { + if (deque.pollFirst() != null) deque.removeFirst(); + } else { + // Set next maps are sent to the front of the deque + deque.addFirst(new WeakReference<>(map)); } } } diff --git a/core/src/main/java/tc/oc/pgm/rotation/DisabledMapPool.java b/core/src/main/java/tc/oc/pgm/rotation/pools/DisabledMapPool.java similarity index 86% rename from core/src/main/java/tc/oc/pgm/rotation/DisabledMapPool.java rename to core/src/main/java/tc/oc/pgm/rotation/pools/DisabledMapPool.java index fc9e63240e..caa1663b9d 100644 --- a/core/src/main/java/tc/oc/pgm/rotation/DisabledMapPool.java +++ b/core/src/main/java/tc/oc/pgm/rotation/pools/DisabledMapPool.java @@ -1,7 +1,8 @@ -package tc.oc.pgm.rotation; +package tc.oc.pgm.rotation.pools; import org.bukkit.configuration.ConfigurationSection; import tc.oc.pgm.api.map.MapInfo; +import tc.oc.pgm.rotation.MapPoolManager; public class DisabledMapPool extends MapPool { DisabledMapPool(MapPoolManager manager, ConfigurationSection section, String name) { @@ -22,7 +23,4 @@ public MapInfo popNextMap() { public MapInfo getNextMap() { return null; } - - @Override - public void resetNextMap() {} } diff --git a/core/src/main/java/tc/oc/pgm/rotation/MapPool.java b/core/src/main/java/tc/oc/pgm/rotation/pools/MapPool.java similarity index 97% rename from core/src/main/java/tc/oc/pgm/rotation/MapPool.java rename to core/src/main/java/tc/oc/pgm/rotation/pools/MapPool.java index c06eed2760..7a3202e6f2 100644 --- a/core/src/main/java/tc/oc/pgm/rotation/MapPool.java +++ b/core/src/main/java/tc/oc/pgm/rotation/pools/MapPool.java @@ -1,4 +1,4 @@ -package tc.oc.pgm.rotation; +package tc.oc.pgm.rotation.pools; import static tc.oc.pgm.util.text.TextParser.parseDuration; @@ -15,6 +15,7 @@ import tc.oc.pgm.api.map.MapLibrary; import tc.oc.pgm.api.map.MapOrder; import tc.oc.pgm.api.match.Match; +import tc.oc.pgm.rotation.MapPoolManager; /** Rotation of maps, a type of {@link MapOrder} */ public abstract class MapPool implements MapOrder, Comparable { @@ -109,9 +110,6 @@ public Duration getCycleTime() { @Override public void setNextMap(MapInfo map) {} - @Override - public void resetNextMap() {} - /** * Called when this map pool is going to be switched out * diff --git a/core/src/main/java/tc/oc/pgm/rotation/RandomMapPool.java b/core/src/main/java/tc/oc/pgm/rotation/pools/RandomMapPool.java similarity index 83% rename from core/src/main/java/tc/oc/pgm/rotation/RandomMapPool.java rename to core/src/main/java/tc/oc/pgm/rotation/pools/RandomMapPool.java index 14a0f5d3ec..efb14c5cb9 100644 --- a/core/src/main/java/tc/oc/pgm/rotation/RandomMapPool.java +++ b/core/src/main/java/tc/oc/pgm/rotation/pools/RandomMapPool.java @@ -1,7 +1,9 @@ -package tc.oc.pgm.rotation; +package tc.oc.pgm.rotation.pools; import org.bukkit.configuration.ConfigurationSection; import tc.oc.pgm.api.map.MapInfo; +import tc.oc.pgm.rotation.MapPoolManager; +import tc.oc.pgm.rotation.RandomMapOrder; /* RandomMapPool - A map pool which utilizes an instance of {@link RandomMapOrder} */ public class RandomMapPool extends MapPool { @@ -22,9 +24,4 @@ public MapInfo popNextMap() { public MapInfo getNextMap() { return order.getNextMap(); } - - @Override - public void resetNextMap() { - order.resetNextMap(); - } } diff --git a/core/src/main/java/tc/oc/pgm/rotation/Rotation.java b/core/src/main/java/tc/oc/pgm/rotation/pools/Rotation.java similarity index 96% rename from core/src/main/java/tc/oc/pgm/rotation/Rotation.java rename to core/src/main/java/tc/oc/pgm/rotation/pools/Rotation.java index cabd33323b..d234df23ae 100644 --- a/core/src/main/java/tc/oc/pgm/rotation/Rotation.java +++ b/core/src/main/java/tc/oc/pgm/rotation/pools/Rotation.java @@ -1,10 +1,11 @@ -package tc.oc.pgm.rotation; +package tc.oc.pgm.rotation.pools; import java.util.logging.Level; import javax.annotation.Nullable; import org.bukkit.configuration.ConfigurationSection; import tc.oc.pgm.api.PGM; import tc.oc.pgm.api.map.MapInfo; +import tc.oc.pgm.rotation.MapPoolManager; public class Rotation extends MapPool { diff --git a/core/src/main/java/tc/oc/pgm/rotation/VotingPool.java b/core/src/main/java/tc/oc/pgm/rotation/pools/VotingPool.java similarity index 51% rename from core/src/main/java/tc/oc/pgm/rotation/VotingPool.java rename to core/src/main/java/tc/oc/pgm/rotation/pools/VotingPool.java index 89565c0b15..f3ca447bd5 100644 --- a/core/src/main/java/tc/oc/pgm/rotation/VotingPool.java +++ b/core/src/main/java/tc/oc/pgm/rotation/pools/VotingPool.java @@ -1,48 +1,42 @@ -package tc.oc.pgm.rotation; +package tc.oc.pgm.rotation.pools; -import java.util.Collections; +import java.util.Collection; import java.util.HashMap; import java.util.Map; +import java.util.Set; +import java.util.UUID; import java.util.concurrent.TimeUnit; -import javax.annotation.Nullable; import org.bukkit.configuration.ConfigurationSection; import tc.oc.pgm.api.map.MapInfo; import tc.oc.pgm.api.match.Match; import tc.oc.pgm.api.match.MatchScope; import tc.oc.pgm.restart.RestartManager; +import tc.oc.pgm.rotation.MapPoolManager; +import tc.oc.pgm.rotation.vote.MapPoll; +import tc.oc.pgm.rotation.vote.MapVotePicker; public class VotingPool extends MapPool { - // Number of maps in the vote, unless not enough maps in pool - public static final int MAX_VOTE_OPTIONS = 5; - // Number of maps required for a custom vote (/vote) - public static final int MIN_CUSTOM_VOTE_OPTIONS = 2; - // If maps were single voted, it would avg to this default - public static final double DEFAULT_WEIGHT = 1d / MAX_VOTE_OPTIONS; + // Arbitrary default of 1 in 5 players liking each map + public static final double DEFAULT_SCORE = 0.2; + // How much score to add/remove on a map every cycle + public final double ADJUST_FACTOR; - // Amount of maps to display on vote - private final int VOTE_SIZE; - private final double ADJUST_FACTOR; + // The algorithm used to pick the maps for next vote. + public final MapVotePicker mapPicker; + + // The current rating of maps. Eventually should be persisted elsewhere. private final Map mapScores = new HashMap<>(); private MapPoll currentPoll; public VotingPool(MapPoolManager manager, ConfigurationSection section, String name) { - this(manager, section, name, null); - } - - public VotingPool( - MapPoolManager manager, - ConfigurationSection section, - String name, - @Nullable CustomVotingPoolOptions existingOptions) { super(manager, section, name); - VOTE_SIZE = Math.min(MAX_VOTE_OPTIONS, maps.size() - 1); - ADJUST_FACTOR = 1d / (maps.size() * MAX_VOTE_OPTIONS); - for (MapInfo map : maps) { - mapScores.put(map, DEFAULT_WEIGHT); - } + this.ADJUST_FACTOR = DEFAULT_SCORE / maps.size(); + + this.mapPicker = MapVotePicker.of(manager, section); + for (MapInfo map : maps) mapScores.put(map, DEFAULT_SCORE); } public MapPoll getCurrentPoll() { @@ -59,18 +53,25 @@ private void tickScores(MapInfo currentMap) { if (!mapScores.containsKey(currentMap)) return; mapScores.replaceAll( (mapScores, value) -> - value > DEFAULT_WEIGHT - ? Math.max(value - ADJUST_FACTOR, DEFAULT_WEIGHT) - : Math.min(value + ADJUST_FACTOR, DEFAULT_WEIGHT)); + value > DEFAULT_SCORE + ? Math.max(value - ADJUST_FACTOR, DEFAULT_SCORE) + : Math.min(value + ADJUST_FACTOR, DEFAULT_SCORE)); mapScores.put(currentMap, 0d); } + private void updateScores(Map> votes) { + double voters = votes.values().stream().flatMap(Collection::stream).distinct().count(); + if (voters == 0) return; // Literally no one voted + votes.forEach((m, v) -> mapScores.put(m, Math.max(v.size() / voters, Double.MIN_VALUE))); + } + @Override public MapInfo popNextMap() { if (currentPoll == null) return getRandom(); MapInfo map = currentPoll.finishVote(); - manager.getCustomVoteOptions().clear(); + updateScores(currentPoll.getVotes()); + manager.getVoteOptions().clear(); currentPoll = null; return map != null ? map : getRandom(); } @@ -82,7 +83,10 @@ public MapInfo getNextMap() { @Override public void setNextMap(MapInfo map) { - currentPoll = null; + if (map != null && currentPoll != null) { + currentPoll.cancel(); + currentPoll = null; + } } @Override @@ -103,22 +107,9 @@ public void matchEnded(Match match) { if (RestartManager.isQueued()) return; currentPoll = - getOptions().shouldOverride() - ? new MapPoll( - match, - getOptions().getCustomVoteMapWeighted(), - Collections.emptySet(), - Math.min(MAX_VOTE_OPTIONS, getOptions().getCustomVoteMaps().size())) - : new MapPoll(match, mapScores, getOptions().getCustomVoteMaps(), VOTE_SIZE); - - match.addListener(new VotingBookListener(this, match), MatchScope.LOADED); - match.getPlayers().forEach(viewer -> currentPoll.sendBook(viewer, false)); + new MapPoll(match, mapPicker.getMaps(manager.getVoteOptions(), mapScores)); }, 5, TimeUnit.SECONDS); } - - public CustomVotingPoolOptions getOptions() { - return manager.getCustomVoteOptions(); - } } diff --git a/core/src/main/java/tc/oc/pgm/rotation/MapPoll.java b/core/src/main/java/tc/oc/pgm/rotation/vote/MapPoll.java similarity index 72% rename from core/src/main/java/tc/oc/pgm/rotation/MapPoll.java rename to core/src/main/java/tc/oc/pgm/rotation/vote/MapPoll.java index db191ba943..610f8a5720 100644 --- a/core/src/main/java/tc/oc/pgm/rotation/MapPoll.java +++ b/core/src/main/java/tc/oc/pgm/rotation/vote/MapPoll.java @@ -1,4 +1,4 @@ -package tc.oc.pgm.rotation; +package tc.oc.pgm.rotation.vote; import static net.kyori.adventure.text.Component.empty; import static net.kyori.adventure.text.Component.newline; @@ -19,9 +19,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.NavigableMap; import java.util.Set; -import java.util.TreeMap; import java.util.UUID; import java.util.stream.Collectors; import net.kyori.adventure.inventory.Book; @@ -38,6 +36,7 @@ import tc.oc.pgm.api.map.MapInfo; import tc.oc.pgm.api.map.MapTag; import tc.oc.pgm.api.match.Match; +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; @@ -58,77 +57,22 @@ public class MapPoll { private static final Component VOTE_BOOK_TITLE = translatable("vote.title.map", NamedTextColor.GOLD, TextDecoration.BOLD); - // Workaround: ItemStacks cant have invisible metadata, so these are used as a replacement. + // Workaround: ItemStacks can't have invisible metadata, so these are used as a replacement. static final String VOTE_BOOK_METADATA = "vote_book"; static final ItemTag VOTE_BOOK_TAG = ItemTag.newString(VOTE_BOOK_METADATA); private final WeakReference match; - private final Map mapScores; - private final Set overrides; - private final int voteSize; - private final Map> votes = new HashMap<>(); + private final Map> votes; + private boolean running = true; - MapPoll(Match match, Map mapScores, Set overrides, int voteSize) { + public MapPoll(Match match, List maps) { this.match = new WeakReference<>(match); - this.mapScores = mapScores; - this.overrides = overrides; - this.voteSize = voteSize; + this.votes = new HashMap<>(); + maps.forEach(m -> votes.put(m, new HashSet<>())); - selectMaps(); - } - - private void selectMaps() { - // 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); - - // Add all override maps before selecting random - overrides.forEach(map -> votes.put(map, new HashSet<>())); - - for (int i = overrides.size(); i < voteSize; i++) { - NavigableMap subMap = - cumulativeScores.tailMap(Math.random() * maxWeight, true); - Map.Entry selected = subMap.pollFirstEntry(); - - if (selected == null) break; // No more maps to poll - votes.put(selected.getValue(), new HashSet<>()); // Add map to votes - if (votes.size() >= voteSize) break; // Skip replace logic after all maps have been selected - - // Remove map from pool, updating cumulative scores - double selectedWeight = getWeight(selected.getValue()); - maxWeight -= selectedWeight; - - NavigableMap temp = new TreeMap<>(); - cummulativeMap(selected.getKey() - selectedWeight, subMap.values(), temp); - - subMap.clear(); - cumulativeScores.putAll(temp); - } - } - - private double getWeight(MapInfo map) { - return getWeight(mapScores.get(map)); - } - - public static double getWeight(Double score) { - if (score == null || score <= 0) return 0; - return Math.max(Math.pow(score, 2), Double.MIN_VALUE); - } - - private double cummulativeMap( - double currWeight, Collection maps, Map result) { - for (MapInfo map : maps) { - double score = getWeight(map); - if (score > 0) result.put(currWeight += score, map); - } - return currWeight; + match.addListener(new VotingBookListener(this, match), MatchScope.LOADED); + match.getPlayers().forEach(viewer -> sendBook(viewer, false)); } public void announceWinner(MatchPlayer viewer, MapInfo winner) { @@ -259,7 +203,7 @@ private MapInfo getMostVotedMap() { * @param uuids The players who voted * @return The number of votes counted */ - private int countVotes(Set uuids) { + private int countVotes(Collection uuids) { return uuids.stream() .map(Bukkit::getPlayer) // Count disconnected players as 1, can't test for their perms @@ -267,25 +211,28 @@ private int countVotes(Set uuids) { .sum(); } + public boolean isRunning() { + return running; + } + /** * Picks a winner and ends the vote, updating map scores based on votes * * @return The picked map to play after the vote */ - MapInfo finishVote() { + public MapInfo finishVote() { + running = false; MapInfo picked = getMostVotedMap(); Match match = this.match.get(); - if (match != null) { - match.getPlayers().forEach(player -> announceWinner(player, picked)); - } - - updateScores(); + if (match != null) match.getPlayers().forEach(player -> announceWinner(player, picked)); 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))); + public void cancel() { + running = false; + } + + public Map> getVotes() { + return votes; } } diff --git a/core/src/main/java/tc/oc/pgm/rotation/vote/MapVotePicker.java b/core/src/main/java/tc/oc/pgm/rotation/vote/MapVotePicker.java new file mode 100644 index 0000000000..f12c0c8675 --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/rotation/vote/MapVotePicker.java @@ -0,0 +1,162 @@ +package tc.oc.pgm.rotation.vote; + +import com.google.common.collect.ImmutableMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.stream.Collectors; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.MemoryConfiguration; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import tc.oc.pgm.api.PGM; +import tc.oc.pgm.api.map.MapInfo; +import tc.oc.pgm.api.map.MapTag; +import tc.oc.pgm.rotation.MapPoolManager; +import tc.oc.pgm.util.math.Formula; + +/** + * Responsible for picking the set of maps that will be on the vote. It's able to apply any + * arbitrary rule to how the maps are picked from the available ones. + */ +public class MapVotePicker { + + // Number of maps in the vote, unless not enough maps in pool + public static final int MAX_VOTE_OPTIONS = 5; + public static final int MIN_CUSTOM_VOTE_OPTIONS = 2; + + // A 0 that prevents arbitrarily low values with tons of precision, which cause issues when mixed + // with larger numbers. + private static final double MINIMUM_WEIGHT = 0.00000001; + + private static final Formula DEFAULT_MODIFIER = c -> Math.pow(c.score, 2); + + private final MapPoolManager manager; + private final Formula modifier; + + public static MapVotePicker of(MapPoolManager manager, ConfigurationSection config) { + // Create dummy config to read defaults off of. + if (config == null) config = new MemoryConfiguration(); + + Formula formula = DEFAULT_MODIFIER; + try { + formula = Formula.of(config.getString("modifier"), Context.getKeys(), DEFAULT_MODIFIER); + } catch (IllegalArgumentException e) { + PGM.get() + .getLogger() + .log(Level.SEVERE, "Failed to load vote picker modifier formula, using fallback", e); + } + + return new MapVotePicker(manager, formula); + } + + public MapVotePicker(MapPoolManager manager, Formula modifier) { + this.manager = manager; + this.modifier = modifier; + } + + /** + * Get a list of maps to vote on, given voting options and map of scores + * + * @param options custom voting options currently available + * @param scores maps and their respective scores + * @return list of maps to include in the vote + */ + public List getMaps(VotePoolOptions options, Map scores) { + if (options.shouldOverride()) + return getMaps(new ArrayList<>(), options.getCustomVoteMapWeighted()); + + List maps = new ArrayList<>(options.getCustomVoteMaps()); + return getMaps(maps, scores); + } + + protected List getMaps(@Nullable List selected, Map scores) { + if (selected == null) selected = new ArrayList<>(); + + List unmodifiable = Collections.unmodifiableList(selected); + while (selected.size() < MAX_VOTE_OPTIONS) { + MapInfo map = getMap(unmodifiable, scores); + + if (map == null) break; // Ran out of maps! + selected.add(map); + } + + return selected; + } + + protected MapInfo getMap(List selected, Map mapScores) { + NavigableMap cumulativeScores = new TreeMap<>(); + double maxWeight = 0; + for (Map.Entry map : mapScores.entrySet()) { + double weight = getWeight(selected, map.getKey(), map.getValue()); + if (weight > MINIMUM_WEIGHT) cumulativeScores.put(maxWeight += weight, map.getKey()); + } + Map.Entry selectedMap = + cumulativeScores.higherEntry(Math.random() * maxWeight); + return selectedMap == null ? null : selectedMap.getValue(); + } + + /** + * Get the weight for a specific map, given it's score + * + * @param selected The list of selected maps so far + * @param map The map being considered + * @param score The score of the map, from player votes + * @return random weight for the map + */ + public double getWeight(@Nullable List selected, @NotNull MapInfo map, double score) { + if ((selected != null && selected.contains(map)) || score <= 0) return 0; + + Context context = + new Context( + score, + getRepeatedGamemodes(selected, map), + map.getMaxPlayers().stream().mapToInt(i -> i).sum(), + manager.getActivePlayers(null)); + + return Math.max(modifier.applyAsDouble(context), 0); + } + + private double getRepeatedGamemodes(List selected, MapInfo map) { + if (selected == null || selected.isEmpty()) return 0; + List gamemodes = + map.getTags().stream().filter(MapTag::isGamemode).collect(Collectors.toList()); + + return selected.stream().filter(s -> !Collections.disjoint(gamemodes, s.getTags())).count(); + } + + private static final class Context implements Supplier> { + private double score; + private double sameGamemode; + private double mapsize; + private double players; + + public Context(double score, double sameGamemode, double mapsize, double players) { + this.score = score; + this.sameGamemode = sameGamemode; + this.mapsize = mapsize; + this.players = players; + } + + private Context() {} + + public static Set getKeys() { + return new Context().get().keySet(); + } + + @Override + public Map get() { + return ImmutableMap.of( + "score", score, + "same_gamemode", sameGamemode, + "mapsize", mapsize, + "players", players); + } + } +} diff --git a/core/src/main/java/tc/oc/pgm/rotation/CustomVotingPoolOptions.java b/core/src/main/java/tc/oc/pgm/rotation/vote/VotePoolOptions.java similarity index 77% rename from core/src/main/java/tc/oc/pgm/rotation/CustomVotingPoolOptions.java rename to core/src/main/java/tc/oc/pgm/rotation/vote/VotePoolOptions.java index ebad42d467..6d256b49c0 100644 --- a/core/src/main/java/tc/oc/pgm/rotation/CustomVotingPoolOptions.java +++ b/core/src/main/java/tc/oc/pgm/rotation/vote/VotePoolOptions.java @@ -1,26 +1,26 @@ -package tc.oc.pgm.rotation; +package tc.oc.pgm.rotation.vote; import com.google.common.collect.Sets; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import tc.oc.pgm.api.map.MapInfo; +import tc.oc.pgm.rotation.pools.VotingPool; -public class CustomVotingPoolOptions { +public class VotePoolOptions { // Set of maps to be used in custom vote selection - private Set customVoteMaps; - + private final Set customVoteMaps; // Whether custom map selection should replace existing entries private boolean replace; - public CustomVotingPoolOptions() { + public VotePoolOptions() { this.customVoteMaps = Sets.newHashSet(); this.replace = true; } public boolean shouldOverride() { - return customVoteMaps.size() >= VotingPool.MIN_CUSTOM_VOTE_OPTIONS && !replace; + return customVoteMaps.size() >= MapVotePicker.MIN_CUSTOM_VOTE_OPTIONS && !replace; } public boolean isReplace() { @@ -33,7 +33,7 @@ public boolean toggleMode() { } public boolean addVote(MapInfo map) { - if (customVoteMaps.size() < VotingPool.MAX_VOTE_OPTIONS) { + if (customVoteMaps.size() < MapVotePicker.MAX_VOTE_OPTIONS) { this.customVoteMaps.add(map); return true; } @@ -58,6 +58,6 @@ public void clear() { public Map getCustomVoteMapWeighted() { return customVoteMaps.stream() - .collect(Collectors.toMap(map -> map, x -> VotingPool.DEFAULT_WEIGHT)); + .collect(Collectors.toMap(map -> map, x -> VotingPool.DEFAULT_SCORE)); } } diff --git a/core/src/main/java/tc/oc/pgm/rotation/VotingBookListener.java b/core/src/main/java/tc/oc/pgm/rotation/vote/VotingBookListener.java similarity index 63% rename from core/src/main/java/tc/oc/pgm/rotation/VotingBookListener.java rename to core/src/main/java/tc/oc/pgm/rotation/vote/VotingBookListener.java index 680f8ee367..197e497b9f 100644 --- a/core/src/main/java/tc/oc/pgm/rotation/VotingBookListener.java +++ b/core/src/main/java/tc/oc/pgm/rotation/vote/VotingBookListener.java @@ -1,7 +1,7 @@ -package tc.oc.pgm.rotation; +package tc.oc.pgm.rotation.vote; -import static tc.oc.pgm.rotation.MapPoll.VOTE_BOOK_METADATA; -import static tc.oc.pgm.rotation.MapPoll.VOTE_BOOK_TAG; +import static tc.oc.pgm.rotation.vote.MapPoll.VOTE_BOOK_METADATA; +import static tc.oc.pgm.rotation.vote.MapPoll.VOTE_BOOK_TAG; import org.bukkit.Material; import org.bukkit.event.EventHandler; @@ -16,24 +16,23 @@ @ListenerScope(MatchScope.LOADED) final class VotingBookListener implements Listener { - private final VotingPool votingPool; + private final MapPoll poll; private final Match match; - public VotingBookListener(VotingPool votingPool, Match match) { - this.votingPool = votingPool; + public VotingBookListener(MapPoll poll, Match match) { + this.poll = poll; this.match = match; } @EventHandler public void openVote(PlayerInteractEvent event) { MatchPlayer player = match.getPlayer(event.getPlayer()); - if (isRightClick(event.getAction()) - && event.getMaterial() == Material.ENCHANTED_BOOK - && player != null - && votingPool.getCurrentPoll() != null) { + if (player != null + && poll.isRunning() + && isRightClick(event.getAction()) + && event.getMaterial() == Material.ENCHANTED_BOOK) { String validator = VOTE_BOOK_TAG.get(event.getItem()); - if (validator != null && validator.equals(VOTE_BOOK_METADATA)) - votingPool.getCurrentPoll().sendBook(player, true); + if (validator != null && validator.equals(VOTE_BOOK_METADATA)) poll.sendBook(player, true); } } diff --git a/core/src/main/resources/map-pools.yml b/core/src/main/resources/map-pools.yml index 13b2864965..3c4744cdce 100644 --- a/core/src/main/resources/map-pools.yml +++ b/core/src/main/resources/map-pools.yml @@ -26,6 +26,38 @@ pools: # How long should should a map cycle last? cycle-time: "15s" + + # Voted pools support modifiers which come in the form of a formula (does not affect any other type of pool). + # + # This formula is parsed by exp4j library, it's quite flexible and supports many built-in functions. + # + # On top of that PGM implements: + # - bound(val, min, max) + # + # Available variables: + # - score: the maps' score, determined by percentage of players who voted for it, or a default + # - same_gamemode: amount of other maps of the same gamemode already picked for the vote + # - mapsize: the size for this map in total players (sum of all team counts) + # - players: amount of active players online (eg: obs count as half) + # + # WARNING: this is a complicated setting to modify on your own, it is not + # recommended to modify it on your own unless you know what you're doing. + # + # The provided formula can dynamically pick best-sized map for a current player count, some alternatives: + # - Make it so low scores are less-likely than higher scores (previous default) + # pow(score, 2) + # - Make it so repeated gamemodes on the same vote are 35% less likely each time, down to a minimum of 20% + # pow(score * bound(1 - (0.35 * same_gamemode), 0.2, 1), 2) + modifier: >- + pow( + score * + bound(1 - (0.2 * same_gamemode), 0.2, 1) * + bound(0.5 * ( + tanh(bound(-0.0025 * players + 0.5, 0.2, 1) * (mapsize - bound(players * 1.05, 0, 100))) - + tanh(bound(-0.0025 * players + 0.5, 0.2, 1) * (mapsize - bound(0.005 * players ^ 2 + 0.95 * players + 5, 10, 150))) + ), 0, 1) + , 2) + # A list of map names in this pool. # Uses map name given in map.xml, do not use folder name. # Names are case-sensitive. diff --git a/pom.xml b/pom.xml index aaaed46aeb..5312c21a86 100644 --- a/pom.xml +++ b/pom.xml @@ -168,6 +168,13 @@ org.eclipse.jgit 5.13.0.202109080827-r + + + + net.objecthunter + exp4j + 0.4.8 + diff --git a/util/src/main/java/tc/oc/pgm/util/math/Formula.java b/util/src/main/java/tc/oc/pgm/util/math/Formula.java new file mode 100644 index 0000000000..74b4c603b2 --- /dev/null +++ b/util/src/main/java/tc/oc/pgm/util/math/Formula.java @@ -0,0 +1,46 @@ +package tc.oc.pgm.util.math; + +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import java.util.function.ToDoubleFunction; +import net.objecthunter.exp4j.Expression; +import net.objecthunter.exp4j.ExpressionBuilder; +import net.objecthunter.exp4j.function.Function; + +public interface Formula extends ToDoubleFunction { + + Function BOUND = + new Function("bound", 3) { + @Override + public double apply(double... doubles) { + double val = doubles[0]; + double min = doubles[1]; + double max = doubles[2]; + return Math.max(min, Math.min(val, max)); + } + }; + + static >> Formula of( + String expression, Set variables, Formula fallback) + throws IllegalArgumentException { + if (expression == null) return fallback; + + Expression exp = new ExpressionBuilder(expression).variables(variables).function(BOUND).build(); + + return new ExpFormula<>(exp); + } + + class ExpFormula>> implements Formula { + private final Expression expression; + + private ExpFormula(Expression expression) { + this.expression = expression; + } + + @Override + public double applyAsDouble(T value) { + return expression.setVariables(value.get()).evaluate(); + } + } +}