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 e421535e4f..b69f800ce1 100644 --- a/core/src/main/java/tc/oc/pgm/command/MapPoolCommand.java +++ b/core/src/main/java/tc/oc/pgm/command/MapPoolCommand.java @@ -96,8 +96,7 @@ public static void pool( if (chance && votes != null) { double maxWeight = 0, currWeight; for (MapInfo map : votes.getMaps()) { - chances.put( - map, currWeight = votes.mapPicker.getWeight(null, null, votes.getMapScore(map))); + chances.put(map, currWeight = votes.mapPicker.getWeight(null, map, votes.getMapScore(map))); maxWeight += currWeight; } double finalMaxWeight = maxWeight; 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 41d2c3dd03..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; @@ -250,9 +252,19 @@ public void setNextMap(MapInfo map) { getOrder().setNextMap(map); } - public Optional getAppropriateDynamicPool(Match match) { + 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; - double activePlayers = match.getParticipants().size() + match.getObservers().size() * obsBias; + return match.getParticipants().size() + match.getObservers().size() * obsBias; + } + + public Optional getAppropriateDynamicPool(Match match) { + double activePlayers = getActivePlayers(match); return mapPools.keySet().stream() .filter(MapPool::isDynamic) .filter(pool -> activePlayers >= pool.getPlayers()) diff --git a/core/src/main/java/tc/oc/pgm/rotation/pools/VotingPool.java b/core/src/main/java/tc/oc/pgm/rotation/pools/VotingPool.java index 6bbbadbb46..c529d0041b 100644 --- a/core/src/main/java/tc/oc/pgm/rotation/pools/VotingPool.java +++ b/core/src/main/java/tc/oc/pgm/rotation/pools/VotingPool.java @@ -35,7 +35,7 @@ public VotingPool(MapPoolManager manager, ConfigurationSection section, String n this.ADJUST_FACTOR = DEFAULT_SCORE / maps.size(); - this.mapPicker = new MapVotePicker(section.getConfigurationSection("picker")); + this.mapPicker = MapVotePicker.of(manager, section); for (MapInfo map : maps) mapScores.put(map, DEFAULT_SCORE); } diff --git a/core/src/main/java/tc/oc/pgm/rotation/vote/Formula.java b/core/src/main/java/tc/oc/pgm/rotation/vote/Formula.java new file mode 100644 index 0000000000..4e2da37487 --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/rotation/vote/Formula.java @@ -0,0 +1,158 @@ +package tc.oc.pgm.rotation.vote; + +import java.util.function.ToDoubleFunction; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import tc.oc.pgm.api.PGM; + +/** Implementation of different formulas configurable & composable via config sections. */ +public interface Formula extends ToDoubleFunction { + + class Context { + double score; + double sameGamemode; + double mapsize; + double players; + + public Context(double score, double sameGamemode, double mapsize, double players) { + this.score = score; + this.sameGamemode = sameGamemode; + this.mapsize = mapsize; + this.players = players; + } + } + + Formula ZERO = constant(0); + Formula ONE = constant(1); + Formula POSITIVE_INFINITY = constant(Double.POSITIVE_INFINITY); + Formula NEGATIVE_INFINITY = constant(Double.NEGATIVE_INFINITY); + + static Formula ofRequired(ConfigurationSection parent, String child) + throws InvalidConfigurationException { + Formula result = of(parent, child, null); + if (result == null) + throw new InvalidConfigurationException( + "Child '" + child + "' in '" + parent.getCurrentPath() + " is required"); + return result; + } + + static Formula of(ConfigurationSection parent, String child, Formula fallback) + throws InvalidConfigurationException { + if (parent == null) return fallback; + Object obj = parent.get(child); + + if (obj == null) return fallback; + else if (obj instanceof Number) return constant(((Number) obj).doubleValue()); + else if (obj instanceof String) return variable((String) obj); + else if (obj instanceof ConfigurationSection) return of((ConfigurationSection) obj); + else + throw new InvalidConfigurationException( + "Child '" + + child + + "' in '" + + parent.getCurrentPath() + + " must be null, double, or config section"); + } + + static Formula of(ConfigurationSection config) throws InvalidConfigurationException { + String type = config.getString("type").toLowerCase(); + switch (type) { + case "constant": + return constant(config.getDouble("value")); + case "variable": + return variable(config.getString("value")); + case "sum": + return sum( + ofRequired(config, "a"), + ofRequired(config, "b"), + of(config, "c", ZERO), + of(config, "d", ZERO)); + case "sub": + return sub(ofRequired(config, "a"), ofRequired(config, "b")); + case "mul": + return mul( + ofRequired(config, "a"), + ofRequired(config, "b"), + of(config, "c", ONE), + of(config, "d", ONE)); + case "pow": + return pow(ofRequired(config, "value"), ofRequired(config, "exponent")); + case "bound": + return bound( + ofRequired(config, "value"), + of(config, "min", NEGATIVE_INFINITY), + of(config, "max", POSITIVE_INFINITY)); + case "linear": + return linear( + ofRequired(config, "value"), of(config, "slope", ZERO), of(config, "offset", ZERO)); + case "quadratic": + return quadratic( + ofRequired(config, "value"), + of(config, "arc", ZERO), + of(config, "slope", ZERO), + of(config, "offset", ZERO)); + case "tanh": + return tanh(ofRequired(config, "value")); + default: + PGM.get() + .getLogger() + .severe("Invalid formula type for " + config.getCurrentPath() + ": '" + type + "'"); + } + return null; + } + + static Formula constant(double d) { + return x -> d; + } + + static Formula variable(String str) throws InvalidConfigurationException { + switch (str) { + case "score": + return in -> in.score; + case "same_gamemode": + return in -> in.sameGamemode; + case "mapsize": + return in -> in.mapsize; + case "players": + return in -> in.players; + default: + throw new InvalidConfigurationException("Unknown variable type " + str); + } + } + + static Formula sum(Formula a, Formula b, Formula c, Formula d) { + return x -> a.applyAsDouble(x) + b.applyAsDouble(x) + c.applyAsDouble(x) + d.applyAsDouble(x); + } + + static Formula sub(Formula a, Formula b) { + return x -> a.applyAsDouble(x) - b.applyAsDouble(x); + } + + static Formula mul(Formula a, Formula b, Formula c, Formula d) { + return x -> a.applyAsDouble(x) * b.applyAsDouble(x) * c.applyAsDouble(x) * d.applyAsDouble(x); + } + + static Formula pow(Formula value, Formula exponent) { + return x -> Math.pow(value.applyAsDouble(x), exponent.applyAsDouble(x)); + } + + static Formula bound(Formula value, Formula min, Formula max) { + return x -> + Math.min(max.applyAsDouble(x), Math.max(min.applyAsDouble(x), value.applyAsDouble(x))); + } + + static Formula linear(Formula val, Formula a, Formula b) { + return x -> a.applyAsDouble(x) * val.applyAsDouble(x) + b.applyAsDouble(x); + } + + static Formula quadratic(Formula val, Formula a, Formula b, Formula c) { + return x -> { + double v = val.applyAsDouble(x); + return a.applyAsDouble(x) * v * v + b.applyAsDouble(x) * v + c.applyAsDouble(x); + }; + } + + static Formula tanh(Formula val) { + return x -> Math.tanh(val.applyAsDouble(x)); + } +} 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 index 4e8845b24f..173f2478f3 100644 --- a/core/src/main/java/tc/oc/pgm/rotation/vote/MapVotePicker.java +++ b/core/src/main/java/tc/oc/pgm/rotation/vote/MapVotePicker.java @@ -6,12 +6,17 @@ import java.util.Map; import java.util.NavigableMap; import java.util.TreeMap; +import java.util.logging.Level; import java.util.stream.Collectors; import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; 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; /** * Responsible for picking the set of maps that will be on the vote. It's able to apply any @@ -23,16 +28,28 @@ public class MapVotePicker { public static final int MAX_VOTE_OPTIONS = 5; public static final int MIN_CUSTOM_VOTE_OPTIONS = 2; - private final double gamemodeMultiplier; - private final double weightPower; + private static final Formula DEFAULT_MODIFIER = Formula.pow(c -> c.score, Formula.constant(2)); - public MapVotePicker(ConfigurationSection config) { + 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(); - this.gamemodeMultiplier = config.getDouble("repeated-gamemode-multiplier", 1.0d); - this.weightPower = config.getDouble("weight-power", 2.0d); - // TODO: define format for online-playercount bias + Formula formula = DEFAULT_MODIFIER; + try { + formula = Formula.of(config, "modifier", DEFAULT_MODIFIER); + } catch (InvalidConfigurationException e) { + PGM.get().getLogger().log(Level.SEVERE, "Failed to load vote picker formula", e); + } + + return new MapVotePicker(manager, formula); + } + + public MapVotePicker(MapPoolManager manager, Formula modifier) { + this.manager = manager; + this.modifier = modifier; } /** @@ -84,28 +101,26 @@ protected MapInfo getMap(List selected, Map mapScores) * @param score The score of the map, from player votes * @return random weight for the map */ - public double getWeight(@Nullable List selected, @Nullable MapInfo map, double score) { - if (selected == null || map == null || selected.contains(map) || score <= 0) return 0; - - double weight = score; + public double getWeight(@Nullable List selected, @NotNull MapInfo map, double score) { + if ((selected != null && selected.contains(map)) || score <= 0) return 0; - // Remove score if same gamemode is already in the vote - if (gamemodeMultiplier != 1.0 && !selected.isEmpty()) { - List gamemodes = - map.getTags().stream().filter(MapTag::isGamemode).collect(Collectors.toList()); - - for (MapInfo otherMap : selected) { - if (!Collections.disjoint(gamemodes, otherMap.getTags())) weight *= gamemodeMultiplier; - } - } - - // TODO: apply weight based on playercount - - // Apply curve to bump up high weights and kill lower weights - weight = Math.pow(weight, weightPower); + Formula.Context context = + new Formula.Context( + score, + getRepeatedGamemodes(selected, map), + map.getMaxPlayers().stream().mapToInt(i -> i).sum(), + manager.getActivePlayers(null)); // Use MIN_VALUE so that weight isn't exactly 0. // That allows for the map to be used if nothing else exists. - return Math.max(weight, Double.MIN_VALUE); + return Math.max(modifier.applyAsDouble(context), Double.MIN_VALUE); + } + + 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(); } } diff --git a/core/src/main/resources/map-pools.yml b/core/src/main/resources/map-pools.yml index 13b2864965..1ac183a498 100644 --- a/core/src/main/resources/map-pools.yml +++ b/core/src/main/resources/map-pools.yml @@ -26,6 +26,112 @@ pools: # How long should should a map cycle last? cycle-time: "15s" + + # Voted pools support modifiers which come in the form of formulas (does not affect any other type of pool). + # + # The parameters for all formulas can be either: + # - a constant, eg: 1.5 + # - a variable, eg: score + # - a formula, eg: + # type: bound + # min: 5 ... + # + # Available formulas and their params, if ended in ?, they're optional: + # - sum: a, b, c?, d? -> a + b + c + d + # - sub: a, b -> a - b + # - mul: a, b, c?, d? -> a * b * c * d + # - pow: a, b -> a ^ b + # - bound: value, min, max -> value, but limited to be between min and max + # - linear: value, slope? offset? -> slope * value + offset + # - quadratic: value, arc?, slope? offset? -> arc * value ^ 2 + slope * value + offset + # - tanh: value -> applies tanh + # + # 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) + modifier: + # weight = weighted_score ^ 2 + type: pow + exponent: 2 + value: + # weighted_score = score * gamemode_bias * playercount_bias + type: mul + a: score # The maps score itself + b: + # gamemode_bias = 1 - (0.2 * same_gamemode) + type: bound + min: 0.2 + max: 1 + value: + type: sub + a: 1.0 + b: + type: mul + a: 0.2 + b: same_gamemode + c: + # playercount_bias = bound(min=0, max=1, (lower_bound - upper_bound) * 0.5) + type: "bound" + min: 0.0 + max: 1 + value: + type: mul + a: 0.5 + b: + type: sub + a: # lower_bound = tanh(lower_steep(players) * (mapsize - lower_value(players)) + type: tanh + value: + type: mul + a: # lower_steep(players) = bound(0.2, 1, -0.0025 * players + 0.5) + type: bound + min: 0.2 + max: 1 + value: + type: linear + value: players + slope: -0.0025 + offset: 0.5 + b: # mapsize - lower_value(players) + type: sub + a: mapsize + b: # lower_value(players) = bound(0, 100, players * 1.05) + type: bound + min: 0 + max: 100 + value: + type: linear + value: players + slope: 1.05 + b: # upper_bound = tanh(upper_steep(players) * (mapsize - upper_value(players)) + type: tanh + value: + type: mul + a: # upper_steep(players) = bound(0.2, 1, -0.0025 * players + 0.5) + type: bound + min: 0.2 + max: 1 + value: + type: linear + value: players + slope: -0.0025 + offset: 0.5 + b: + type: sub + a: mapsize + b: # upper_value(players) = bound(10, 150, 0.005 * players ^ 2 + 0.95 * players + 5) + type: bound + min: 10 + max: 150 + value: + type: quadratic + value: players + arc: 0.005 + slope: 0.95 + offset: 5 + # A list of map names in this pool. # Uses map name given in map.xml, do not use folder name. # Names are case-sensitive.