From 8892f684e8fbd709f3966bb8a29ae3657155af06 Mon Sep 17 00:00:00 2001 From: dtookey Date: Sat, 2 Sep 2023 23:08:43 -0400 Subject: [PATCH] we have tested the *shit* out of dice... provided you don't fuck up the input on the constructors --- src/main/kotlin/simulation/Attack.kt | 9 +- src/main/kotlin/simulation/AttackDice.kt | 35 ++++++ src/main/kotlin/simulation/AttackResult.kt | 2 +- src/main/kotlin/simulation/Dice.kt | 57 ++++++---- src/main/kotlin/simulation/MeleeAttack.kt | 2 +- src/main/kotlin/simulation/Modifier.kt | 3 + src/test/kotlin/simulation/AttackDiceTest.kt | 57 ++++++++++ src/test/kotlin/simulation/DiceTest.kt | 103 ++++++++++++------ src/test/kotlin/simulation/MeleeAttackTest.kt | 27 +++++ src/test/kotlin/simulation/SimulatorTest.kt | 28 ----- 10 files changed, 227 insertions(+), 96 deletions(-) create mode 100644 src/main/kotlin/simulation/AttackDice.kt create mode 100644 src/test/kotlin/simulation/AttackDiceTest.kt create mode 100644 src/test/kotlin/simulation/MeleeAttackTest.kt diff --git a/src/main/kotlin/simulation/Attack.kt b/src/main/kotlin/simulation/Attack.kt index 0e52bd7..f7e144a 100644 --- a/src/main/kotlin/simulation/Attack.kt +++ b/src/main/kotlin/simulation/Attack.kt @@ -4,7 +4,7 @@ import java.util.* interface Attack { - val actionRoll: Dice + val actionRoll: AttackDice val passValue: Int fun calculateDamage(r: Random): AttackResult { @@ -14,7 +14,7 @@ interface Attack { return if (isHit(attackResult, attackBonus)) { - if (isCrit(attackResult)) { + if (actionRoll.isCrit(attackResult)) { onCriticalHit(r) } else { onNormalHit(r) @@ -24,11 +24,6 @@ interface Attack { } } - - private fun isCrit(roll: RollResult): Boolean { - return roll.max == roll.result - } - private fun isHit(roll: RollResult, attackBonus: Int): Boolean { //ties go to the roller return (roll.result + attackBonus) >= passValue diff --git a/src/main/kotlin/simulation/AttackDice.kt b/src/main/kotlin/simulation/AttackDice.kt new file mode 100644 index 0000000..8523219 --- /dev/null +++ b/src/main/kotlin/simulation/AttackDice.kt @@ -0,0 +1,35 @@ +package simulation + +class AttackDice( + override val rollString: String, + override val rollType: RollType = RollType.Normal, + override val modifiers: ArrayList> = ArrayList() +) : Dice { + override val nDice: Int + override val dieSize: Int + + private val critThreshold: Int + + init { + val cleanRollString = rollString.lowercase() + + if (cleanRollString.contains('c')) { + val critModifierParts = cleanRollString.split("c") + val parts = critModifierParts[0].split('d') + nDice = parts[0].toInt() + dieSize = parts[1].toInt() + critThreshold = critModifierParts[1].toInt() + } else { + val parts = cleanRollString.split('d') + nDice = parts[0].toInt() + dieSize = parts[1].toInt() + critThreshold = dieSize + } + } + + + fun isCrit(result: RollResult): Boolean { + return result.result >= critThreshold + } + +} \ No newline at end of file diff --git a/src/main/kotlin/simulation/AttackResult.kt b/src/main/kotlin/simulation/AttackResult.kt index 35f653b..04b1942 100644 --- a/src/main/kotlin/simulation/AttackResult.kt +++ b/src/main/kotlin/simulation/AttackResult.kt @@ -21,7 +21,7 @@ data class AttackResult(val attackWasHit: Boolean, val attackWasCrit: Boolean, v val critRate = (totalCriticals.toDouble() / sampleSize) * 100.0 val sumDamage = results.sumOf { it.resultingDamage.toLong() } - val avgDamage = (sumDamage.toDouble() / sampleSize) * 100.0 + val avgDamage = sumDamage.toDouble() / sampleSize val reportString = "Hit Rate: %.2f\tCrit Rate: %.2f\tAvg Dmg: %.2f".format(hitRate, critRate, avgDamage) diff --git a/src/main/kotlin/simulation/Dice.kt b/src/main/kotlin/simulation/Dice.kt index e053e52..e6291c7 100644 --- a/src/main/kotlin/simulation/Dice.kt +++ b/src/main/kotlin/simulation/Dice.kt @@ -2,6 +2,7 @@ package simulation import simulation.RollType.* import java.util.* +import kotlin.collections.ArrayList import kotlin.math.max import kotlin.math.min @@ -32,25 +33,18 @@ enum class RollType { data class RollResult(val min: Int, val max: Int, val result: Int) -/** - * Represents dice that can be rolled with different roll types and modifiers. - * - * @param rollString The dice roll notation, e.g. "2d6" - * @param rollType The roll type to use, which determines how the dice are rolled - * @param modifiers Optional modifier functions that add a bonus to the roll result - */ -class Dice( - rollString: String, - private val rollType: RollType = RollType.Normal, - private vararg val modifiers: Modifier -) { - private val nDice: Int - private val dieSize: Int +interface Dice { + val rollString: String + val rollType: RollType + val modifiers: List> + val nDice: Int + val dieSize: Int - init { - val parts = rollString.lowercase().split("d") - nDice = parts[0].toInt() - dieSize = parts[1].toInt() + + companion object { + fun makeDice(rollString: String, rollType: RollType = Normal, modifiers: List> = ArrayList()): Dice { + return SimpleDice(rollString, rollType, modifiers) + } } /** @@ -74,22 +68,23 @@ class Dice( return RollResult(nDice, dieSize * nDice, result) } + private fun advantageRoll(r: Random, nDice: Int, range: Int): Int { - val roll1 = r.nextInt(range+1) - val roll2 = r.nextInt(range+1) + val roll1 = r.nextInt(range + 1) + val roll2 = r.nextInt(range + 1) return max(roll1, roll2) + nDice } private fun disadvantageRoll(r: Random, nDice: Int, range: Int): Int { - val roll1 = r.nextInt(range+1) - val roll2 = r.nextInt(range+1) + val roll1 = r.nextInt(range + 1) + val roll2 = r.nextInt(range + 1) return min(roll1, roll2) + nDice } private fun normalRoll(r: Random, nDice: Int, range: Int): Int { - return r.nextInt(range+1) + nDice + return r.nextInt(range + 1) + nDice } /** @@ -101,4 +96,20 @@ class Dice( fun evaluateModifiers(r: Random, crit: Boolean = false): Int { return modifiers.sumOf { it.getBonus(r, crit) } } +} + +private class SimpleDice( + override val rollString: String, + override val rollType: RollType = Normal, + override val modifiers: List> = ArrayList() +) : Dice { + override val nDice: Int + override val dieSize: Int + + init { + val cleanRollString = rollString.lowercase() + val parts = cleanRollString.split('d') + nDice = parts[0].toInt() + dieSize = parts[1].toInt() + } } \ No newline at end of file diff --git a/src/main/kotlin/simulation/MeleeAttack.kt b/src/main/kotlin/simulation/MeleeAttack.kt index 3ed0ac6..9dde32b 100644 --- a/src/main/kotlin/simulation/MeleeAttack.kt +++ b/src/main/kotlin/simulation/MeleeAttack.kt @@ -10,7 +10,7 @@ import java.util.* * @param passValue The defense value the attack must exceed to hit. */ class SimpleMeleeAttack( - override val actionRoll: Dice, + override val actionRoll: AttackDice, val damageRoll: Dice, override val passValue: Int ) : Attack { diff --git a/src/main/kotlin/simulation/Modifier.kt b/src/main/kotlin/simulation/Modifier.kt index f7fb9a8..aa1af52 100644 --- a/src/main/kotlin/simulation/Modifier.kt +++ b/src/main/kotlin/simulation/Modifier.kt @@ -29,6 +29,9 @@ interface Modifier { * @param dice The [Dice] instance to use for generating bonus values. */ class DiceBonusModifier(private val dice: Dice) : Modifier { + + constructor(diceString: String):this(Dice.makeDice(diceString)) + override fun getBonus(r: Random, crit: Boolean): Int { return if (crit){ dice.roll(r).result + dice.roll(r).result diff --git a/src/test/kotlin/simulation/AttackDiceTest.kt b/src/test/kotlin/simulation/AttackDiceTest.kt new file mode 100644 index 0000000..64765f0 --- /dev/null +++ b/src/test/kotlin/simulation/AttackDiceTest.kt @@ -0,0 +1,57 @@ +package simulation + +import org.junit.jupiter.api.Assertions +import java.util.* +import kotlin.test.Test + +class AttackDiceTest { + + @Test + fun testAttackDiceImplementation() { + val r = Random() + // Test no crit below threshold + val dice = AttackDice("1d20") + val result = dice.roll(r) + if (result.result < 20) { + Assertions.assertFalse(dice.isCrit(result)) + } else { + Assertions.assertTrue(dice.isCrit(result)) + } + // Test no crit below threshold + val dice2 = AttackDice("1d20c19") + val result2 = dice2.roll(r) + if (result2.result < 10) { + Assertions.assertFalse(dice2.isCrit(result2)) + } else { + Assertions.assertTrue(dice2.isCrit(result2)) + } + + // Test crit threshold other than max + val dice3 = AttackDice("2d10c8") + val result3 = dice3.roll(r) + if (result3.result >= 8) { + Assertions.assertTrue(dice3.isCrit(result3)) + } else { + Assertions.assertFalse(dice3.isCrit(result3)) + } + } + + @Test + fun validateCritFunctionality() { + val trueCritResult = RollResult(1, 20, 20) + val fakeCritResult = RollResult(1, 20, 19) + + val defaultRoll = AttackDice("1d20") + val verboseDefaultCrit = AttackDice("1d20c20") + val normalModifiedCrit = AttackDice("1d20c19") + + Assertions.assertFalse(defaultRoll.isCrit(fakeCritResult)) + Assertions.assertFalse(verboseDefaultCrit.isCrit(fakeCritResult)) + Assertions.assertTrue(normalModifiedCrit.isCrit(fakeCritResult)) + + Assertions.assertTrue(defaultRoll.isCrit(trueCritResult)) + Assertions.assertTrue(verboseDefaultCrit.isCrit(trueCritResult)) + Assertions.assertTrue(normalModifiedCrit.isCrit(trueCritResult)) + } + +} diff --git a/src/test/kotlin/simulation/DiceTest.kt b/src/test/kotlin/simulation/DiceTest.kt index eb28076..cca3be4 100644 --- a/src/test/kotlin/simulation/DiceTest.kt +++ b/src/test/kotlin/simulation/DiceTest.kt @@ -6,11 +6,13 @@ import org.junit.jupiter.api.Test import java.util.* internal class DiceTests { + private val random = Random(1) + private val d20 = Dice.makeDice("1d20") + @Test fun roll_normal() { - val dice = Dice("2d6") - val random = Random(1) + val dice = Dice.makeDice("2d6") val result = dice.roll(random) assertEquals(2, result.min) @@ -20,8 +22,7 @@ internal class DiceTests { @Test fun roll_advantage() { - val dice = Dice("2d6", RollType.Advantage) - val random = Random(1) + val dice = Dice.makeDice("2d6", RollType.Advantage) val result = dice.roll(random) assertEquals(2, result.min) @@ -32,8 +33,7 @@ internal class DiceTests { @Test fun roll_disadvantage() { - val dice = Dice("2d6", RollType.Disadvantage) - val random = Random(1) + val dice = Dice.makeDice("2d6", RollType.Disadvantage) val result = dice.roll(random) assertEquals(2, result.min) @@ -45,23 +45,22 @@ internal class DiceTests { fun evaluate_modifiers() { val mod1 = FlatModifier(1) val mod2 = FlatModifier(2) - val dice = Dice("1d20", RollType.Normal, mod1, mod2) + val dice = Dice.makeDice("1d20", RollType.Normal, arrayListOf(mod1, mod2)) - val random = Random(1) val bonus = dice.evaluateModifiers(random) assertEquals(3, bonus) } @Test - fun verifyRollRange() { + fun verifyRollRangeForTypes() { val rollString = "2d6" val iterations = 10_000_000 RollType.entries.parallelStream() .forEach { - val dice = Dice(rollString, it) - val r = Random() + val dice = Dice.makeDice(rollString, it) + val r = Random(1) repeat(iterations) { val res = dice.roll(r) Assertions.assertTrue(res.min <= res.result) @@ -71,11 +70,19 @@ internal class DiceTests { } @Test - fun verifyRollBoundaries() { - val rollString = "2d6" + fun verifyBoundariesForTypes() { + verifyBoundariesForTypes(1, 20) + verifyBoundariesForTypes(2, 6) + verifyBoundariesForTypes(5, 8) + verifyBoundariesForTypes(1000, 6) + verifyBoundariesForTypes(6, 1000) + } + + + private fun verifyBoundariesForTypes(nDice: Int, dieSize: Int) { + val rollString = "${nDice}d${dieSize}" val iterations = 10_000_000 - val min = 2 - val max = 12 + val max = nDice * dieSize var observedMin = false var observedMax = false @@ -83,11 +90,11 @@ internal class DiceTests { RollType.entries.parallelStream() .forEach { - val dice = Dice(rollString, it) - val r = Random() + val dice = Dice.makeDice(rollString, it) + val r = Random(1) for (i in 0.. expectedAverageLowerBound) + Assertions.assertTrue(avg > expectedAverageUpperBound) } @Test @@ -157,25 +163,50 @@ internal class DiceTests { val n = 2 val max = 100 val rollString = "${n}d${max}" - val tolerance = 0.75 //we expect more than a 25% improvement + val tolerance = 0.75 //we expect more than a 25% penalty val iterations = 10_000_000 - val expectedAverageUpperBound = ((n + (n * max)) / 2) * tolerance + val expectedAverageLowerBound = ((n + (n * max)) / 2) * tolerance + + val dice = Dice.makeDice(rollString, RollType.Disadvantage) - val dice = Dice(rollString, RollType.Disadvantage) - val r = Random() var total = 0L repeat(iterations) { - total += dice.roll(r).result.toLong() + total += dice.roll(random).result.toLong() } val avg = total.toDouble() / iterations.toDouble() - //assert that the observed average is greater than the expected lower bound of the normal roll scaled by the + //assert that the observed average is less than the expected lower bound of the normal roll scaled by the //tolerance - Assertions.assertTrue(avg < expectedAverageUpperBound) + Assertions.assertTrue(avg < expectedAverageLowerBound) + } + + @Test + fun verifyDistribution() { + val size = 20 + val dice = Dice.makeDice("1d20") + val iterations = 10_000_000 + val tolerance = 0.05 //5% wiggle on distribution + + val m = HashMap() + + repeat(iterations) { + val rollResult = dice.roll(random) + val total = m.getOrDefault(rollResult.result, 0) + m[rollResult.result] = (total + 1) + } + + val expected = iterations.toDouble() / size.toDouble() + + m.forEach { (_, count) -> + val dCount = count.toDouble() + Assertions.assertTrue(dCount > (expected * (1 - tolerance))) + Assertions.assertTrue(dCount < (expected * (1 + tolerance))) + } + } } diff --git a/src/test/kotlin/simulation/MeleeAttackTest.kt b/src/test/kotlin/simulation/MeleeAttackTest.kt new file mode 100644 index 0000000..56ce5a9 --- /dev/null +++ b/src/test/kotlin/simulation/MeleeAttackTest.kt @@ -0,0 +1,27 @@ +package simulation + +import kotlin.test.Test + +class MeleeAttackTest { + + @Test + fun testAttack() { + val itt = 1_000_000 + val simulator = Simulator.getInstance(Runtime.getRuntime().availableProcessors()) + + val critAttack = SimpleMeleeAttack( + actionRoll = AttackDice("1d20c19"), + damageRoll = Dice.makeDice("1d8"), + 10 + ) + + + + val normalAttackModel = AttackSimulatorModel(itt, critAttack) + val normalResults = simulator.doSimulation(normalAttackModel) + + + AttackResult.printSimulationStatistics(normalResults, "Normal Attack") + + } +} \ No newline at end of file diff --git a/src/test/kotlin/simulation/SimulatorTest.kt b/src/test/kotlin/simulation/SimulatorTest.kt index 7f24fed..0b22fb6 100644 --- a/src/test/kotlin/simulation/SimulatorTest.kt +++ b/src/test/kotlin/simulation/SimulatorTest.kt @@ -14,34 +14,6 @@ class SimulatorTest { val finish = System.nanoTime() println("${results.size} simulations performed in ${finish - start}ns (${(finish - start) / results.size}ns/simulation)") } - - @Test - fun testAttack() { - val itt = 1_000_000 - val simulator = Simulator.getInstance(Runtime.getRuntime().availableProcessors()) - - val attack = SimpleMeleeAttack( - Dice("1d20", RollType.Normal, FlatModifier(5)), - Dice("2d6", RollType.Normal, FlatModifier(5)), - 15 - ) - - val attackWithAdvantageAndBless = SimpleMeleeAttack( - Dice("1d20", RollType.Advantage, FlatModifier(5), DiceBonusModifier(Dice("1d4"))), - Dice("2d6", RollType.Normal, FlatModifier(5)), - 15 - ) - - val normalAttackModel = AttackSimulatorModel(itt, attack) - val normalResults = simulator.doSimulation(normalAttackModel) - - val buffedAttackModel = AttackSimulatorModel(itt, attackWithAdvantageAndBless) - val buffedResults = simulator.doSimulation(buffedAttackModel) - - - AttackResult.printSimulationStatistics(normalResults, "Normal Attack") - AttackResult.printSimulationStatistics(buffedResults, "Buffed Attack") - } }