fix probability being unbounded

This commit is contained in:
nossr50 2024-04-06 12:44:56 -07:00
parent aecf17a2a2
commit b6e512b09e
8 changed files with 194 additions and 91 deletions

View File

@ -1,3 +1,10 @@
Version 2.2.005
Fixed a bug where certain skills such as Dodge/Arrow Deflect had no skill cap and would continue improving forever
Reduced messages on startup for SQL DB
(API) Constructor for ProbabilityImpl now takes a raw value between 0 and 1 instead of an inflated percentage
(API) Added some convenience methods to Probability, and ProbabilityUtil classes
(Codebase) Added more unit tests revolving around Probability/RNG
Version 2.2.004 Version 2.2.004
Fixed bug where values from Experience_Formula.Skill_Multiplier were not functioning Fixed bug where values from Experience_Formula.Skill_Multiplier were not functioning

View File

@ -1037,13 +1037,13 @@ public final class SQLDatabaseManager implements DatabaseManager {
try (Connection connection = getConnection(PoolIdentifier.MISC)) { try (Connection connection = getConnection(PoolIdentifier.MISC)) {
if (!columnExists(connection, mcMMO.p.getGeneralConfig().getMySQLDatabaseName(), tablePrefix+tableName, columnName)) { if (!columnExists(connection, mcMMO.p.getGeneralConfig().getMySQLDatabaseName(), tablePrefix+tableName, columnName)) {
try (Statement createStatement = connection.createStatement()) { try (Statement createStatement = connection.createStatement()) {
logger.info("[SQLDB Check] Adding column '" + columnName + "' to table '" + tablePrefix + tableName + "'..."); // logger.info("[SQLDB Check] Adding column '" + columnName + "' to table '" + tablePrefix + tableName + "'...");
String startingLevel = "'" + mcMMO.p.getAdvancedConfig().getStartingLevel() + "'"; String startingLevel = "'" + mcMMO.p.getAdvancedConfig().getStartingLevel() + "'";
createStatement.executeUpdate("ALTER TABLE `" + tablePrefix + tableName + "` " createStatement.executeUpdate("ALTER TABLE `" + tablePrefix + tableName + "` "
+ "ADD COLUMN `" + columnName + "` int(" + columnSize + ") unsigned NOT NULL DEFAULT " + startingLevel); + "ADD COLUMN `" + columnName + "` int(" + columnSize + ") unsigned NOT NULL DEFAULT " + startingLevel);
} }
} else { } else {
logger.info("[SQLDB Check] Column '" + columnName + "' already exists in table '" + tablePrefix + tableName + "', looks good!"); // logger.info("[SQLDB Check] Column '" + columnName + "' already exists in table '" + tablePrefix + tableName + "', looks good!");
} }
} catch (SQLException e) { } catch (SQLException e) {
e.printStackTrace(); // Consider more robust logging e.printStackTrace(); // Consider more robust logging
@ -1052,7 +1052,7 @@ public final class SQLDatabaseManager implements DatabaseManager {
} }
private boolean columnExists(Connection connection, String database, String tableName, String columnName) throws SQLException { private boolean columnExists(Connection connection, String database, String tableName, String columnName) throws SQLException {
logger.info("[SQLDB Check] Checking if column '" + columnName + "' exists in table '" + tableName + "'"); // logger.info("[SQLDB Check] Checking if column '" + columnName + "' exists in table '" + tableName + "'");
try (Statement createStatement = connection.createStatement()) { try (Statement createStatement = connection.createStatement()) {
String sql = "SELECT `COLUMN_NAME`\n" + String sql = "SELECT `COLUMN_NAME`\n" +
"FROM `INFORMATION_SCHEMA`.`COLUMNS`\n" + "FROM `INFORMATION_SCHEMA`.`COLUMNS`\n" +

View File

@ -1,10 +1,21 @@
package com.gmail.nossr50.util.random; package com.gmail.nossr50.util.random;
import com.gmail.nossr50.api.exceptions.ValueOutOfBoundsException;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ThreadLocalRandom;
public interface Probability { public interface Probability {
/**
* A Probability that always fails.
*/
Probability ALWAYS_FAILS = () -> 0;
/**
* A Probability that always succeeds.
*/
Probability ALWAYS_SUCCEEDS = () -> 1;
/** /**
* The value of this Probability * The value of this Probability
* Should return a result between 0 and 1 (inclusive) * Should return a result between 0 and 1 (inclusive)
@ -17,19 +28,40 @@ public interface Probability {
double getValue(); double getValue();
/** /**
* Create a new Probability with the given value * Create a new Probability of a percentage.
* A value of 100 would represent 100% chance of success * This method takes a percentage and creates a Probability of equivalent odds.
* A value of 50 would represent 50% chance of success *
* A value of 0 would represent 0% chance of success * A value of 100 would represent 100% chance of success,
* A value of 1 would represent 1% chance of success * A value of 50 would represent 50% chance of success,
* A value of 0.5 would represent 0.5% chance of success * A value of 0 would represent 0% chance of success,
* A value of 0.01 would represent 0.01% chance of success * A value of 1 would represent 1% chance of success,
* A value of 0.5 would represent 0.5% chance of success,
* A value of 0.01 would represent 0.01% chance of success.
* *
* @param percentage the value of the probability * @param percentage the value of the probability
* @return a new Probability with the given value * @return a new Probability with the given value
*/ */
static @NotNull Probability ofPercent(double percentage) { static @NotNull Probability ofPercent(double percentage) {
return new ProbabilityImpl(percentage); if (percentage < 0) {
throw new ValueOutOfBoundsException("Value should never be negative for Probability! This suggests a coding mistake, contact the devs!");
}
// Convert to a 0-1 floating point representation
double probabilityValue = percentage / 100.0D;
return new ProbabilityImpl(probabilityValue);
}
/**
* Create a new Probability of a value.
* This method takes a value between 0 and 1 and creates a Probability of equivalent odds.
* A value of 1 or greater represents something that will always succeed.
* A value of around 0.5 represents something that succeeds around half the time.
* A value of 0 represents something that will always fail.
* @param value the value of the probability
* @return a new Probability with the given value
*/
static @NotNull Probability ofValue(double value) {
return new ProbabilityImpl(value);
} }
/** /**

View File

@ -8,31 +8,22 @@ public class ProbabilityImpl implements Probability {
private final double probabilityValue; private final double probabilityValue;
/** /**
* Create a probability with a static value * Create a probability from a static value.
* A value of 0 represents a 0% chance of success,
* A value of 1 represents a 100% chance of success.
* A value of 0.5 represents a 50% chance of success.
* A value of 0.01 represents a 1% chance of success.
* And so on.
* *
* @param percentage the percentage value of the probability * @param value the value of the probability between 0 and 100
*/ */
ProbabilityImpl(double percentage) throws ValueOutOfBoundsException { public ProbabilityImpl(double value) throws ValueOutOfBoundsException {
if (percentage < 0) { if (value < 0) {
throw new ValueOutOfBoundsException("Value should never be negative for Probability! This suggests a coding mistake, contact the devs!"); throw new ValueOutOfBoundsException("Value should never be negative for Probability!" +
" This suggests a coding mistake, contact the devs!");
} }
// Convert to a 0-1 floating point representation probabilityValue = value;
probabilityValue = percentage / 100.0D;
}
ProbabilityImpl(double xPos, double xCeiling, double probabilityCeiling) throws ValueOutOfBoundsException {
if(probabilityCeiling > 100) {
throw new ValueOutOfBoundsException("Probability Ceiling should never be above 100!");
} else if (probabilityCeiling < 0) {
throw new ValueOutOfBoundsException("Probability Ceiling should never be below 0!");
}
//Get the percent success, this will be from 0-100
double probabilityPercent = (probabilityCeiling * (xPos / xCeiling));
//Convert to a 0-1 floating point representation
this.probabilityValue = probabilityPercent / 100.0D;
} }
@Override @Override

View File

@ -79,24 +79,24 @@ public class ProbabilityUtil {
switch (getProbabilityType(subSkillType)) { switch (getProbabilityType(subSkillType)) {
case DYNAMIC_CONFIGURABLE: case DYNAMIC_CONFIGURABLE:
double probabilityCeiling; double probabilityCeiling;
double xCeiling; double skillLevel;
double xPos; double maxBonusLevel; // If a skill level is equal to the cap, it has the full probability
if (player != null) { if (player != null) {
McMMOPlayer mmoPlayer = UserManager.getPlayer(player); McMMOPlayer mmoPlayer = UserManager.getPlayer(player);
if (mmoPlayer == null) { if (mmoPlayer == null) {
return Probability.ofPercent(0); return Probability.ofPercent(0);
} }
xPos = mmoPlayer.getSkillLevel(subSkillType.getParentSkill()); skillLevel = mmoPlayer.getSkillLevel(subSkillType.getParentSkill());
} else { } else {
xPos = 0; skillLevel = 0;
} }
//Probability ceiling is configurable in this type //Probability ceiling is configurable in this type
probabilityCeiling = mcMMO.p.getAdvancedConfig().getMaximumProbability(subSkillType); probabilityCeiling = mcMMO.p.getAdvancedConfig().getMaximumProbability(subSkillType);
//The xCeiling is configurable in this type //The xCeiling is configurable in this type
xCeiling = mcMMO.p.getAdvancedConfig().getMaxBonusLevel(subSkillType); maxBonusLevel = mcMMO.p.getAdvancedConfig().getMaxBonusLevel(subSkillType);
return new ProbabilityImpl(xPos, xCeiling, probabilityCeiling); return calculateCurrentSkillProbability(skillLevel, 0, probabilityCeiling, maxBonusLevel);
case STATIC_CONFIGURABLE: case STATIC_CONFIGURABLE:
try { try {
return getStaticRandomChance(subSkillType); return getStaticRandomChance(subSkillType);
@ -127,22 +127,7 @@ public class ProbabilityUtil {
* @return true if the Skill RNG succeeds, false if it fails * @return true if the Skill RNG succeeds, false if it fails
*/ */
public static boolean isSkillRNGSuccessful(@NotNull SubSkillType subSkillType, @NotNull Player player) { public static boolean isSkillRNGSuccessful(@NotNull SubSkillType subSkillType, @NotNull Player player) {
//Process probability final Probability probability = getSkillProbability(subSkillType, player);
Probability probability = getSubSkillProbability(subSkillType, player);
//Send out event
SubSkillEvent subSkillEvent = EventUtils.callSubSkillEvent(player, subSkillType);
if(subSkillEvent.isCancelled()) {
return false; //Event got cancelled so this doesn't succeed
}
//Result modifier
double resultModifier = subSkillEvent.getResultModifier();
//Mutate probability
if(resultModifier != 1.0D)
probability = Probability.ofPercent(probability.getValue() * resultModifier);
//Luck //Luck
boolean isLucky = Permissions.lucky(player, subSkillType.getParentSkill()); boolean isLucky = Permissions.lucky(player, subSkillType.getParentSkill());
@ -154,12 +139,42 @@ public class ProbabilityUtil {
} }
} }
/**
* Returns the {@link Probability} for a specific {@link SubSkillType} for a specific {@link Player}.
* This does not take into account perks such as lucky for the player.
* This is affected by other plugins who can listen to the {@link SubSkillEvent} and cancel it or mutate it.
*
* @param subSkillType the target subskill
* @param player the target player
* @return the probability for this skill
*/
public static Probability getSkillProbability(@NotNull SubSkillType subSkillType, @NotNull Player player) {
//Process probability
Probability probability = getSubSkillProbability(subSkillType, player);
//Send out event
SubSkillEvent subSkillEvent = EventUtils.callSubSkillEvent(player, subSkillType);
if(subSkillEvent.isCancelled()) {
return Probability.ALWAYS_FAILS;
}
//Result modifier
double resultModifier = subSkillEvent.getResultModifier();
//Mutate probability
if(resultModifier != 1.0D)
probability = Probability.ofPercent(probability.getValue() * resultModifier);
return probability;
}
/** /**
* This is one of several Skill RNG check methods * This is one of several Skill RNG check methods
* This helper method is specific to static value RNG, which can be influenced by a player's Luck * This helper method is specific to static value RNG, which can be influenced by a player's Luck
* *
* @param primarySkillType the related primary skill * @param primarySkillType the related primary skill
* @param player the target player, can be null (null players have the worst odds) * @param player the target player can be null (null players have the worst odds)
* @param probabilityPercentage the probability of this player succeeding in "percentage" format (0-100 inclusive) * @param probabilityPercentage the probability of this player succeeding in "percentage" format (0-100 inclusive)
* @return true if the RNG succeeds, false if it fails * @return true if the RNG succeeds, false if it fails
*/ */
@ -223,4 +238,31 @@ public class ProbabilityUtil {
return new String[]{percent.format(firstValue), percent.format(secondValue)}; return new String[]{percent.format(firstValue), percent.format(secondValue)};
} }
/**
* Helper function to calculate what probability a given skill has at a certain level
* @param skillLevel the skill level currently between the floor and the ceiling
* @param floor the minimum odds this skill can have
* @param ceiling the maximum odds this skill can have
* @param maxBonusLevel the maximum level this skill can have to reach the ceiling
*
* @return the probability of success for this skill at this level
*/
public static Probability calculateCurrentSkillProbability(double skillLevel, double floor,
double ceiling, double maxBonusLevel) {
// The odds of success are between the value of the floor and the value of the ceiling.
// If the skill has a maxBonusLevel of 500 on this skill, then at skill level 500 you would have the full odds,
// at skill level 250 it would be half odds.
if (skillLevel >= maxBonusLevel || maxBonusLevel <= 0) {
// Avoid divide by zero bugs
// Max benefit has been reached, should always succeed
return Probability.ofPercent(ceiling);
}
double odds = (skillLevel / maxBonusLevel) * 100D;
// make sure the odds aren't lower or higher than the floor or ceiling
return Probability.ofPercent(Math.min(Math.max(floor, odds), ceiling));
}
} }

View File

@ -14,19 +14,19 @@ class ProbabilityTest {
private static Stream<Arguments> provideProbabilitiesForWithinExpectations() { private static Stream<Arguments> provideProbabilitiesForWithinExpectations() {
return Stream.of( return Stream.of(
// static probability, % of time for success // static probability, % of time for success
Arguments.of(new ProbabilityImpl(5), 5), Arguments.of(new ProbabilityImpl(.05), 5),
Arguments.of(new ProbabilityImpl(10), 10), Arguments.of(new ProbabilityImpl(.10), 10),
Arguments.of(new ProbabilityImpl(15), 15), Arguments.of(new ProbabilityImpl(.15), 15),
Arguments.of(new ProbabilityImpl(20), 20), Arguments.of(new ProbabilityImpl(.20), 20),
Arguments.of(new ProbabilityImpl(25), 25), Arguments.of(new ProbabilityImpl(.25), 25),
Arguments.of(new ProbabilityImpl(50), 50), Arguments.of(new ProbabilityImpl(.50), 50),
Arguments.of(new ProbabilityImpl(75), 75), Arguments.of(new ProbabilityImpl(.75), 75),
Arguments.of(new ProbabilityImpl(90), 90), Arguments.of(new ProbabilityImpl(.90), 90),
Arguments.of(new ProbabilityImpl(99.9), 99.9), Arguments.of(new ProbabilityImpl(.999), 99.9),
Arguments.of(new ProbabilityImpl(0.05), 0.05), Arguments.of(new ProbabilityImpl(0.0005), 0.05),
Arguments.of(new ProbabilityImpl(0.1), 0.1), Arguments.of(new ProbabilityImpl(0.001), 0.1),
Arguments.of(new ProbabilityImpl(500), 100), Arguments.of(new ProbabilityImpl(50.0), 100),
Arguments.of(new ProbabilityImpl(1000), 100) Arguments.of(new ProbabilityImpl(100.0), 100)
); );
} }

View File

@ -0,0 +1,22 @@
package com.gmail.nossr50.util.random;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class ProbabilityTestUtils {
public static void assertProbabilityExpectations(double expectedWinPercent, Probability probability) {
double iterations = 2.0e7; //20 million
double winCount = 0;
for (int i = 0; i < iterations; i++) {
if(probability.evaluate()) {
winCount++;
}
}
double successPercent = (winCount / iterations) * 100;
System.out.println("Wins: " + winCount);
System.out.println("Fails: " + (iterations - winCount));
System.out.println("Percentage succeeded: " + successPercent + ", Expected: " + expectedWinPercent);
assertEquals(expectedWinPercent, successPercent, 0.025D);
System.out.println("Variance is within tolerance levels!");
}
}

View File

@ -1,39 +1,44 @@
package com.gmail.nossr50.util.random; package com.gmail.nossr50.util.random;
import com.gmail.nossr50.config.AdvancedConfig; import com.gmail.nossr50.MMOTestEnvironment;
import com.gmail.nossr50.datatypes.skills.SubSkillType; import com.gmail.nossr50.datatypes.skills.SubSkillType;
import com.gmail.nossr50.mcMMO; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.MethodSource;
import java.util.logging.Logger;
import java.util.stream.Stream; import java.util.stream.Stream;
import static com.gmail.nossr50.datatypes.skills.SubSkillType.*; import static com.gmail.nossr50.datatypes.skills.SubSkillType.*;
import static com.gmail.nossr50.util.random.ProbabilityTestUtils.assertProbabilityExpectations;
import static com.gmail.nossr50.util.random.ProbabilityUtil.calculateCurrentSkillProbability;
import static java.util.logging.Logger.getLogger;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class ProbabilityUtilTest { class ProbabilityUtilTest extends MMOTestEnvironment {
mcMMO mmoInstance; private static final Logger logger = getLogger(ProbabilityUtilTest.class.getName());
AdvancedConfig advancedConfig;
final static double impactChance = 11D; final static double impactChance = 11D;
final static double greaterImpactChance = 0.007D; final static double greaterImpactChance = 0.007D;
final static double fastFoodChance = 45.5D; final static double fastFoodChance = 45.5D;
@BeforeEach @BeforeEach
public void setupMocks() throws NoSuchFieldException, IllegalAccessException { public void setupMocks() {
this.mmoInstance = mock(mcMMO.class); mockBaseEnvironment(logger);
mcMMO.class.getField("p").set(null, mmoInstance);
this.advancedConfig = mock(AdvancedConfig.class);
when(mmoInstance.getAdvancedConfig()).thenReturn(advancedConfig);
when(advancedConfig.getImpactChance()).thenReturn(impactChance); when(advancedConfig.getImpactChance()).thenReturn(impactChance);
when(advancedConfig.getGreaterImpactChance()).thenReturn(greaterImpactChance); when(advancedConfig.getGreaterImpactChance()).thenReturn(greaterImpactChance);
when(advancedConfig.getFastFoodChance()).thenReturn(fastFoodChance); when(advancedConfig.getFastFoodChance()).thenReturn(fastFoodChance);
} }
@AfterEach
public void tearDown() {
cleanupBaseEnvironment();
}
private static Stream<Arguments> staticChanceSkills() { private static Stream<Arguments> staticChanceSkills() {
return Stream.of( return Stream.of(
// static probability, % of time for success // static probability, % of time for success
@ -45,22 +50,26 @@ class ProbabilityUtilTest {
@ParameterizedTest @ParameterizedTest
@MethodSource("staticChanceSkills") @MethodSource("staticChanceSkills")
void testStaticChanceSkills(SubSkillType subSkillType, double expectedWinPercent) throws InvalidStaticChance { void staticChanceSkillsShouldSucceedAsExpected(SubSkillType subSkillType, double expectedWinPercent)
throws InvalidStaticChance {
Probability staticRandomChance = ProbabilityUtil.getStaticRandomChance(subSkillType); Probability staticRandomChance = ProbabilityUtil.getStaticRandomChance(subSkillType);
assertProbabilityExpectations(expectedWinPercent, staticRandomChance); assertProbabilityExpectations(expectedWinPercent, staticRandomChance);
} }
private static void assertProbabilityExpectations(double expectedWinPercent, Probability probability) { @Test
double iterations = 2.0e7; public void isSkillRNGSuccessfulShouldBehaveAsExpected() {
double winCount = 0; // Given
for (int i = 0; i < iterations; i++) { when(advancedConfig.getMaximumProbability(UNARMED_ARROW_DEFLECT)).thenReturn(20D);
if(probability.evaluate()) { when(advancedConfig.getMaxBonusLevel(UNARMED_ARROW_DEFLECT)).thenReturn(0);
winCount++;
} final Probability probability = ProbabilityUtil.getSkillProbability(UNARMED_ARROW_DEFLECT, player);
assertEquals(0.2D, probability.getValue());
assertProbabilityExpectations(20, probability);
} }
double successPercent = (winCount / iterations) * 100; @Test
System.out.println(successPercent + ", " + expectedWinPercent); public void calculateCurrentSkillProbabilityShouldBeTwenty() {
assertEquals(expectedWinPercent, successPercent, 0.05D); final Probability probability = calculateCurrentSkillProbability(1000, 0, 20, 1000);
assertEquals(0.2D, probability.getValue());
} }
} }