diff --git a/core/src/main/java/tc/oc/pgm/api/Datastore.java b/core/src/main/java/tc/oc/pgm/api/Datastore.java index b7f0bf31d3..a5865ea888 100644 --- a/core/src/main/java/tc/oc/pgm/api/Datastore.java +++ b/core/src/main/java/tc/oc/pgm/api/Datastore.java @@ -1,9 +1,13 @@ package tc.oc.pgm.api; +import java.util.Collection; +import java.util.Map; import java.util.UUID; import tc.oc.pgm.api.map.MapActivity; +import tc.oc.pgm.api.map.MapData; import tc.oc.pgm.api.player.Username; import tc.oc.pgm.api.setting.Settings; +import tc.oc.pgm.rotation.pools.VotingPool; import tc.oc.pgm.util.skin.Skin; /** A fast, persistent datastore that provides synchronous responses. */ @@ -49,6 +53,28 @@ public interface Datastore { */ MapActivity getMapActivity(String poolName); + /** + * Get the data related to a map + * + * @param mapId the map id or slug + * @param score default score if no map data exists + * @return A {@link MapData} + */ + MapData getMapData(String mapId, double score); + + /** + * Get the data related to several maps in bulk + * + * @param mapIds The map ids wanted + * @param score default score if no map data exists + * @return A map containing at least all maps in {@param mapIds}, potentially more + */ + Map getMapData(Collection mapIds, double score); + + void tickMapScores(VotingPool.VoteConstants constants); + + void refreshMapData(); + /** Cleans up any resources or connections. */ void close(); } diff --git a/core/src/main/java/tc/oc/pgm/api/map/MapData.java b/core/src/main/java/tc/oc/pgm/api/map/MapData.java new file mode 100644 index 0000000000..5801d0387a --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/api/map/MapData.java @@ -0,0 +1,20 @@ +package tc.oc.pgm.api.map; + +import java.time.Duration; +import java.time.Instant; +import tc.oc.pgm.api.match.Match; + +public interface MapData { + + String getId(); + + Instant lastPlayed(); + + Duration lastDuration(); + + double score(); + + void setScore(double score, boolean update); + + void saveMatch(Match match, double score); +} diff --git a/core/src/main/java/tc/oc/pgm/db/CacheDatastore.java b/core/src/main/java/tc/oc/pgm/db/CacheDatastore.java index b3d86b92ce..5920bbc480 100644 --- a/core/src/main/java/tc/oc/pgm/db/CacheDatastore.java +++ b/core/src/main/java/tc/oc/pgm/db/CacheDatastore.java @@ -3,11 +3,15 @@ import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; +import java.util.Collection; +import java.util.Map; import java.util.UUID; import tc.oc.pgm.api.Datastore; import tc.oc.pgm.api.map.MapActivity; +import tc.oc.pgm.api.map.MapData; import tc.oc.pgm.api.player.Username; import tc.oc.pgm.api.setting.Settings; +import tc.oc.pgm.rotation.pools.VotingPool; import tc.oc.pgm.util.skin.Skin; @SuppressWarnings({"UnstableApiUsage"}) @@ -21,43 +25,32 @@ public class CacheDatastore implements Datastore { public CacheDatastore(Datastore datastore) { this.datastore = datastore; - this.usernames = - CacheBuilder.newBuilder() - .softValues() - .build( - new CacheLoader() { - @Override - public Username load(UUID id) { - return datastore.getUsername(id); - } - }); - this.settings = - CacheBuilder.newBuilder() - .build( - new CacheLoader() { - @Override - public Settings load(UUID id) { - return datastore.getSettings(id); - } - }); - this.skins = - CacheBuilder.newBuilder() - .build( - new CacheLoader() { - @Override - public Skin load(UUID id) { - return datastore.getSkin(id); - } - }); - this.activities = - CacheBuilder.newBuilder() - .build( - new CacheLoader() { - @Override - public MapActivity load(String name) { - return datastore.getMapActivity(name); - } - }); + this.usernames = CacheBuilder.newBuilder() + .softValues() + .build(new CacheLoader() { + @Override + public Username load(UUID id) { + return datastore.getUsername(id); + } + }); + this.settings = CacheBuilder.newBuilder().build(new CacheLoader() { + @Override + public Settings load(UUID id) { + return datastore.getSettings(id); + } + }); + this.skins = CacheBuilder.newBuilder().build(new CacheLoader() { + @Override + public Skin load(UUID id) { + return datastore.getSkin(id); + } + }); + this.activities = CacheBuilder.newBuilder().build(new CacheLoader() { + @Override + public MapActivity load(String name) { + return datastore.getMapActivity(name); + } + }); } @Override @@ -85,6 +78,26 @@ public MapActivity getMapActivity(String poolName) { return activities.getUnchecked(poolName); } + @Override + public MapData getMapData(String mapId, double score) { + return datastore.getMapData(mapId, score); + } + + @Override + public Map getMapData(Collection mapIds, double score) { + return datastore.getMapData(mapIds, score); + } + + @Override + public void tickMapScores(VotingPool.VoteConstants constants) { + datastore.tickMapScores(constants); + } + + @Override + public void refreshMapData() { + datastore.refreshMapData(); + } + @Override public void close() { datastore.close(); diff --git a/core/src/main/java/tc/oc/pgm/db/MapDataImpl.java b/core/src/main/java/tc/oc/pgm/db/MapDataImpl.java new file mode 100644 index 0000000000..1fe7af5a9d --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/db/MapDataImpl.java @@ -0,0 +1,63 @@ +package tc.oc.pgm.db; + +import java.time.Duration; +import java.time.Instant; +import tc.oc.pgm.api.map.MapData; + +abstract class MapDataImpl implements MapData { + protected final String id; + protected Instant lastPlayed = Instant.EPOCH; + protected Duration lastDuration = Duration.ZERO; + protected double score; + + public MapDataImpl(String id, double defaultScore) { + this.id = id; + this.score = defaultScore; + } + + @Override + public String getId() { + return id; + } + + @Override + public Instant lastPlayed() { + return lastPlayed; + } + + @Override + public Duration lastDuration() { + return lastDuration; + } + + @Override + public double score() { + return score; + } + + @Override + public void setScore(double score, boolean update) { + this.score = score; + } + + @Override + public int hashCode() { + return id.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MapData mapData)) return false; + return getId().equals(mapData.getId()); + } + + @Override + public String toString() { + return "MapDataImpl{" + "id='" + + id + '\'' + ", lastPlayed=" + + lastPlayed + ", lastDuration=" + + lastDuration + ", score=" + + score + '}'; + } +} diff --git a/core/src/main/java/tc/oc/pgm/db/SQLDatastore.java b/core/src/main/java/tc/oc/pgm/db/SQLDatastore.java index 6ce7f02cc1..407c5332e7 100644 --- a/core/src/main/java/tc/oc/pgm/db/SQLDatastore.java +++ b/core/src/main/java/tc/oc/pgm/db/SQLDatastore.java @@ -8,17 +8,26 @@ import java.sql.SQLException; import java.time.Duration; import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; import net.kyori.adventure.text.Component; import org.bukkit.Bukkit; import org.jetbrains.annotations.Nullable; import tc.oc.pgm.api.Datastore; +import tc.oc.pgm.api.PGM; import tc.oc.pgm.api.map.MapActivity; +import tc.oc.pgm.api.map.MapData; +import tc.oc.pgm.api.match.Match; import tc.oc.pgm.api.player.Username; import tc.oc.pgm.api.setting.SettingKey; import tc.oc.pgm.api.setting.SettingValue; import tc.oc.pgm.api.setting.Settings; +import tc.oc.pgm.rotation.pools.VotingPool; import tc.oc.pgm.util.concurrent.ThreadSafeConnection; import tc.oc.pgm.util.named.NameStyle; import tc.oc.pgm.util.skin.Skin; @@ -28,6 +37,9 @@ public class SQLDatastore extends ThreadSafeConnection implements Datastore { + private final Map maps = new ConcurrentHashMap<>(); + private boolean bulkLoaded = false; + public SQLDatastore(String uri, int maxConnections) throws SQLException { super(() -> TextParser.parseSqlConnection(uri), maxConnections); @@ -38,6 +50,12 @@ public SQLDatastore(String uri, int maxConnections) throws SQLException { submitQuery( () -> "CREATE TABLE IF NOT EXISTS pools (name VARCHAR(255) PRIMARY KEY, next_map VARCHAR(255), last_active BOOLEAN)"); + submitQuery( + () -> + "CREATE TABLE IF NOT EXISTS maps (id VARCHAR(255) PRIMARY KEY, last_played LONG, last_duration LONG, score DOUBLE)"); + + // Refresh maps every couple hours + PGM.get().getExecutor().scheduleAtFixedRate(this::refreshMapData, 2, 2, TimeUnit.HOURS); } private class SQLUsername implements Username { @@ -263,4 +281,124 @@ public void query(PreparedStatement statement) throws SQLException { } } } + + @Override + public MapData getMapData(String mapId, double score) { + tryBulkLoadMaps(false); + return maps.computeIfAbsent(mapId, id -> new SQLMapData(mapId, score)); + } + + @Override + public Map getMapData(Collection mapIds, double score) { + tryBulkLoadMaps(false); + for (String id : mapIds) maps.computeIfAbsent(id, k -> new SQLMapData(id, score)); + return Collections.unmodifiableMap(maps); + } + + private void tryBulkLoadMaps(boolean force) { + if (bulkLoaded && !force) return; + bulkLoaded = true; + class BulkMapSelectQuery implements Query { + @Override + public String getFormat() { + return "SELECT * FROM maps"; + } + + @Override + public void query(PreparedStatement statement) throws SQLException { + try (final ResultSet result = statement.executeQuery()) { + while (result.next()) { + String id = result.getString(1); + var map = maps.computeIfAbsent(id, k -> new SQLMapData(id, -1)); + map.lastPlayed = Instant.ofEpochMilli(result.getLong(1)); + map.lastDuration = Duration.ofMillis(result.getLong(2)); + map.score = result.getDouble(3); + } + } + } + } + submitQuery(new BulkMapSelectQuery()); + } + + private class SQLMapData extends MapDataImpl { + public SQLMapData(String id, double defaultScore) { + super(id, defaultScore); + } + + @Override + public void setScore(double score, boolean update) { + super.setScore(score, update); + if (update) submitQuery(new ScoreUpdateQuery()); + } + + @Override + public void saveMatch(Match match, double score) { + this.score = score; + this.lastPlayed = Instant.now(); + this.lastDuration = match.getDuration(); + this.score = score; + submitQuery(new UpdateQuery()); + } + + private class ScoreUpdateQuery implements Query { + + @Override + public String getFormat() { + return "UPDATE maps SET score = ? WHERE id = ?"; + } + + @Override + public void query(PreparedStatement statement) throws SQLException { + statement.setDouble(1, score); + statement.setString(2, getId()); + + statement.executeUpdate(); + } + } + + private class UpdateQuery implements Query { + + @Override + public String getFormat() { + return "REPLACE INTO maps VALUES (?, ?, ?, ?)"; + } + + @Override + public void query(PreparedStatement statement) throws SQLException { + statement.setString(1, getId()); + statement.setLong(2, lastPlayed().toEpochMilli()); + statement.setLong(3, lastDuration().toMillis()); + statement.setDouble(4, score()); + + statement.executeUpdate(); + } + } + } + + @Override + public void tickMapScores(VotingPool.VoteConstants constants) { + record BulkMapUpdateQuery(VotingPool.VoteConstants constants) implements Query { + @Override + public String getFormat() { + return "UPDATE maps SET score = IF(score > ?, score - ?, score + ?)"; + } + + @Override + public void query(PreparedStatement statement) throws SQLException { + statement.setDouble(1, constants.defaultScore()); + statement.setDouble(2, constants.scoreDecay()); + statement.setDouble(3, constants.scoreRise()); + + statement.executeUpdate(); + } + } + submitQuery(new BulkMapUpdateQuery(constants)); + } + + @Override + public void refreshMapData() { + // Server may not be using voted pools, no need to refresh + if (maps.isEmpty()) return; + tryBulkLoadMaps(true); + } } 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 8700f85bb0..dd409c1f3c 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 @@ -3,15 +3,17 @@ import java.time.Duration; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.function.ToDoubleFunction; +import java.util.stream.Collectors; import net.objecthunter.exp4j.ExpressionContext; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.MemoryConfiguration; +import tc.oc.pgm.api.PGM; import tc.oc.pgm.api.map.MapInfo; import tc.oc.pgm.api.match.Match; import tc.oc.pgm.api.match.MatchScope; @@ -45,11 +47,23 @@ public VotingPool( super(type, name, manager, section, parser); this.constants = new VoteConstants(section, maps.size()); this.mapPicker = MapVotePicker.of(manager, constants, section); - Map ms = new HashMap<>(); - maps.forEach(m -> ms.put(m, new VoteData(parser.getWeight(m), constants.defaultScore()))); - this.mapScores = Collections.unmodifiableMap(ms); + this.mapScores = buildMapScores(parser::getWeight); } + private Map buildMapScores(ToDoubleFunction weight) { + var ids = maps.stream().map(MapInfo::getId).toList(); + var persisted = PGM.get().getDatastore().getMapData(ids, constants.defaultScore()); + return Collections.unmodifiableMap(maps.stream() + .collect(Collectors.toMap( + m -> m, + m -> VoteData.of( + weight.applyAsDouble(m), + constants.defaultScore(), + persisted.get(m.getId()), + constants.persistScores())))); + } + + // Constructor for 3rd parties just wanting to create a pool with a set list of maps public VotingPool( MapPoolType type, MapPoolManager manager, @@ -62,9 +76,7 @@ public VotingPool( super(type, name, manager, enabled, players, dynamic, cycleTime, maps); this.constants = new VoteConstants(new MemoryConfiguration(), maps.size()); this.mapPicker = MapVotePicker.of(manager, constants, null); - Map ms = new HashMap<>(); - maps.forEach(m -> ms.put(m, new VoteData(1, constants.defaultScore()))); - this.mapScores = Collections.unmodifiableMap(ms); + this.mapScores = buildMapScores(m -> 1); } public MapPoll getCurrentPoll() { @@ -77,18 +89,20 @@ public double getMapScore(MapInfo map) { public VoteData getVoteData(MapInfo map) { VoteData data = mapScores.get(map); - if (data == null) data = new VoteData(1, constants.defaultScore()); - return data; + if (data != null) return data; + // Map isn't part of the pool, ensure it's not added to mapScores + return VoteData.of(1, constants.defaultScore(), map, false); } /** Ticks scores for all maps, making them go slowly towards DEFAULT_WEIGHT. */ private void tickScores(Match match) { // If the current map isn't from this pool, ignore ticking - if (!mapScores.containsKey(match.getMap())) return; - mapScores.forEach((mapScores, value) -> value.tickScore(constants)); - mapScores - .get(match.getMap()) - .setScore(constants.scoreAfterPlay().applyAsDouble(new Context(match.getDuration()))); + if (mapScores.containsKey(match.getMap())) { + mapScores.forEach((mapScores, value) -> value.tickScore(constants)); + if (constants.persistScores()) PGM.get().getDatastore().tickMapScores(constants); + } + + getVoteData(match.getMap()).onMatchEnd(match, constants); } private void updateScores(Map> votes) { @@ -148,31 +162,44 @@ public void matchEnded(Match match) { public record VoteConstants( int voteOptions, + boolean persistScores, double defaultScore, double scoreDecay, double scoreRise, double scoreAfterVoteMin, double scoreAfterVoteMax, double scoreMinToVote, - Formula scoreAfterPlay) { + Formula scoreAfterPlay, + int minCooldown, + int minutesPerDay) { private VoteConstants(ConfigurationSection section, int mapAmount) { this( section.getInt("vote-options", MapVotePicker.MAX_VOTE_OPTIONS), // Show 5 maps + section.getBoolean("score.persist", false), section.getDouble("score.default", DEFAULT_SCORE), // Start at 20% each section.getDouble("score.decay", DEFAULT_SCORE / mapAmount), // Proportional to # of maps section.getDouble("score.rise", DEFAULT_SCORE / mapAmount), // Proportional to # of maps section.getDouble("score.min-after-vote", 0.01), // min = 1%, never fully discard the map section.getDouble("score.max-after-vote", 1), // max = 100% section.getDouble("score.min-for-vote", 0.01), // To even be voted, need at least 1% - Formula.of(section.getString("score.after-playing"), Context.variables(), c -> 0)); + Formula.of(section.getString("score.after-playing"), Context.variables(), c -> 0) + .map(m -> new Context(m.getDuration())), + section.getInt("cooldown.min-length", 30), + section.getInt("cooldown.minutes-per-day", 30)); } public double afterVoteScore(double score) { return Math.max(Math.min(score, scoreAfterVoteMax), scoreAfterVoteMin); } + + public double tickScore(double score) { + return score > defaultScore() + ? Math.max(score - scoreDecay(), defaultScore()) + : Math.min(score + scoreRise(), defaultScore()); + } } - private static final class Context extends ExpressionContext.Impl { + public static final class Context extends ExpressionContext.Impl { public Context(Duration length) { super(Map.of("play_minutes", length.toMillis() / 60_000d), null); } 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 1a40c6c691..1cf050e2b1 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 @@ -120,7 +120,8 @@ protected MapInfo getMap(List selected, Map mapScore */ public double getWeight(@Nullable List selected, @NotNull MapInfo map, VoteData data) { if ((selected != null && selected.contains(map)) - || data.getScore() <= constants.scoreMinToVote()) return 0; + || data.getScore() <= constants.scoreMinToVote() + || data.isOnCooldown(constants)) return 0; var context = new MapVoteContext( data.getScore(), diff --git a/core/src/main/java/tc/oc/pgm/rotation/vote/VoteData.java b/core/src/main/java/tc/oc/pgm/rotation/vote/VoteData.java index 9868cb8234..aec99087b4 100644 --- a/core/src/main/java/tc/oc/pgm/rotation/vote/VoteData.java +++ b/core/src/main/java/tc/oc/pgm/rotation/vote/VoteData.java @@ -1,23 +1,39 @@ package tc.oc.pgm.rotation.vote; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import tc.oc.pgm.api.PGM; +import tc.oc.pgm.api.map.MapData; +import tc.oc.pgm.api.map.MapInfo; +import tc.oc.pgm.api.match.Match; import tc.oc.pgm.rotation.pools.VotingPool; public class VoteData { - private final double weight; - private double score; + private static final long SECONDS_PER_DAY = Duration.of(1, ChronoUnit.DAYS).toSeconds(); - public VoteData(double weight, double score) { - this.score = score; + protected final double weight; + protected final MapData mapData; + + public VoteData(double weight, MapData mapData) { this.weight = weight; + this.mapData = mapData; + } + + public static VoteData of(double weight, double score, MapData data, boolean persist) { + return persist ? new VoteData(weight, data) : new Local(weight, score, data); + } + + public static VoteData of(double weight, double score, MapInfo map, boolean persist) { + return of(weight, score, PGM.get().getDatastore().getMapData(map.getId(), score), persist); } public void setScore(double score) { - this.score = score; + mapData.setScore(score, true); } - public VoteData withScore(double score) { - setScore(score); - return this; + public void onMatchEnd(Match match, VotingPool.VoteConstants constants) { + mapData.saveMatch(match, constants.scoreAfterPlay().apply(match)); } public double getWeight() { @@ -25,12 +41,53 @@ public double getWeight() { } public double getScore() { - return score; + return mapData.score(); + } + + public boolean isOnCooldown(VotingPool.VoteConstants constants) { + long duration = mapData.lastDuration().toMinutes(); + if (constants.minCooldown() == -1 || constants.minCooldown() > duration) return false; + long cooldownSeconds = duration * SECONDS_PER_DAY / constants.minutesPerDay(); + + return Instant.now().isAfter(mapData.lastPlayed().plusSeconds(cooldownSeconds)); } public void tickScore(VotingPool.VoteConstants constants) { - this.score = score > constants.defaultScore() - ? Math.max(score - constants.scoreDecay(), constants.defaultScore()) - : Math.min(score + constants.scoreRise(), constants.defaultScore()); + mapData.setScore(constants.tickScore(getScore()), false); + } + + static class Local extends VoteData { + private double score; + + public Local(double weight, double score, MapData mapData) { + super(weight, mapData); + this.score = score; + } + + @Override + public double getScore() { + return score; + } + + @Override + public void setScore(double score) { + this.score = score; + } + + @Override + public void tickScore(VotingPool.VoteConstants constants) { + this.score = constants.tickScore(score); + } + + @Override + public void onMatchEnd(Match match, VotingPool.VoteConstants c) { + this.score = c.scoreAfterPlay().apply(match); + + // When score isn't persisted, we can be storing a ton of maps all with 0 score. + // If this data starts being used later, we'll run into no maps available! + // For this reason, store (non-negative) scores as a low value instead + mapData.saveMatch( + match, score < 0 ? score : Math.max(score, c.scoreMinToVote()) + c.scoreRise()); + } } } diff --git a/core/src/main/java/tc/oc/pgm/rotation/vote/VotePoolOptions.java b/core/src/main/java/tc/oc/pgm/rotation/vote/VotePoolOptions.java index 800dc23171..dcb5065bfc 100644 --- a/core/src/main/java/tc/oc/pgm/rotation/vote/VotePoolOptions.java +++ b/core/src/main/java/tc/oc/pgm/rotation/vote/VotePoolOptions.java @@ -71,6 +71,7 @@ public Set getCustomVoteMaps() { public Map getCustomVoteMapsWeighted() { return customVoteMaps.keySet().stream() - .collect(Collectors.toMap(map -> map, score -> new VoteData(1, VotingPool.DEFAULT_SCORE))); + .collect(Collectors.toMap( + map -> map, map -> VoteData.of(1, VotingPool.DEFAULT_SCORE, map, false))); } } diff --git a/core/src/main/resources/map-pools.yml b/core/src/main/resources/map-pools.yml index 0033dfddba..6dc6464fcf 100644 --- a/core/src/main/resources/map-pools.yml +++ b/core/src/main/resources/map-pools.yml @@ -34,6 +34,8 @@ pools: vote-options: 5 # Voted pool only: modify how score behaves score: + # If score should be persisted across server restarts + persist: true # How much score should maps start with by default. default: 0.2 # How much score decreases/increases after each match. Null to be proportional to amount of maps. @@ -48,6 +50,14 @@ pools: # Supports a formula with play_minutes after-playing: 0 + # Voted pool only: make maps be excluded from votes if they recently played for long + cooldown: + # How long in minutes until a match is considered long. -1 to disable the mechanic + min-length: 30 + # How many minutes of the same map per day is acceptable. + # Eg: if set to 30, a match lasting 45min will put the map in cooldown for 1d 12h + minutes-per-day: 30 + # 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. 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 index ef182c4514..5de3f8cfc0 100644 --- a/util/src/main/java/tc/oc/pgm/util/math/Formula.java +++ b/util/src/main/java/tc/oc/pgm/util/math/Formula.java @@ -53,6 +53,10 @@ public double apply(double... doubles) { return new ExpFormula<>(exp, context); } + default Formula map(java.util.function.Function mapper) { + return v -> apply(mapper.apply(v)); + } + /** Shorthand for {@link #applyAsDouble} */ default double apply(T value) { return applyAsDouble(value);