diff --git a/src/main/kotlin/simulation/Attack.kt b/src/main/kotlin/simulation/Attack.kt index d712eb1..0e52bd7 100644 --- a/src/main/kotlin/simulation/Attack.kt +++ b/src/main/kotlin/simulation/Attack.kt @@ -42,37 +42,6 @@ interface Attack { } -private fun ArrayList.sumBools(fn: (AttackResult) -> Boolean): Int { - return sumOf { boolToInt(fn(it)) } -} - -private fun boolToInt(b: Boolean): Int = if (b) 1 else 0 - -data class AttackResult(val attackWasHit: Boolean, val attackWasCrit: Boolean, val resultingDamage: Int) { - companion object { - fun printSimulationStatistics(results: ArrayList, label: String = "") { - val totalHits = results.sumBools { it.attackWasHit } - val hitRate = (totalHits.toDouble() / results.size.toDouble()) * 100.0 - - val totalCriticals = results.sumBools { it.attackWasCrit } - val critRate = (totalCriticals.toDouble() / results.size.toDouble()) * 100.0 - - val sumDamage = results.sumOf { it.resultingDamage } - val avgDamage = (sumDamage.toDouble() / results.size.toDouble()) * 100.0 - - val reportString = "Hit Rate: %.2f\tCrit Rate: %.2f\tAvg Dmg: %.2f".format(hitRate, critRate, avgDamage) - - if(label.isNotBlank()){ - println("[$label] $reportString") - }else{ - println(reportString) - } - } - - } - -} - class AttackSimulatorModel(override val sampleSize: Int, private val attack: Attack) : SimulationModel { override fun simulate(r: Random): AttackResult { return attack.calculateDamage(r) diff --git a/src/main/kotlin/simulation/AttackResult.kt b/src/main/kotlin/simulation/AttackResult.kt new file mode 100644 index 0000000..35f653b --- /dev/null +++ b/src/main/kotlin/simulation/AttackResult.kt @@ -0,0 +1,37 @@ +package simulation + +import java.util.ArrayList + + +private fun ArrayList.sumBools(fn: (AttackResult) -> Boolean): Int { + return sumOf { boolToInt(fn(it)) } +} + +private fun boolToInt(b: Boolean): Int = if (b) 1 else 0 + +data class AttackResult(val attackWasHit: Boolean, val attackWasCrit: Boolean, val resultingDamage: Int) { + companion object { + fun printSimulationStatistics(results: ArrayList, label: String = "") { + val sampleSize = results.size.toDouble() + + val totalHits = results.sumBools { it.attackWasHit } + val hitRate = (totalHits.toDouble() / sampleSize) * 100.0 + + val totalCriticals = results.sumBools { it.attackWasCrit } + val critRate = (totalCriticals.toDouble() / sampleSize) * 100.0 + + val sumDamage = results.sumOf { it.resultingDamage.toLong() } + val avgDamage = (sumDamage.toDouble() / sampleSize) * 100.0 + + val reportString = "Hit Rate: %.2f\tCrit Rate: %.2f\tAvg Dmg: %.2f".format(hitRate, critRate, avgDamage) + + if(label.isNotBlank()){ + println("[$label] $reportString") + }else{ + println(reportString) + } + } + + } + +} diff --git a/src/main/kotlin/simulation/Dice.kt b/src/main/kotlin/simulation/Dice.kt index c8e8ee5..e053e52 100644 --- a/src/main/kotlin/simulation/Dice.kt +++ b/src/main/kotlin/simulation/Dice.kt @@ -1,5 +1,6 @@ package simulation +import simulation.RollType.* import java.util.* import kotlin.math.max import kotlin.math.min @@ -64,33 +65,31 @@ class Dice( * @see RollType */ fun roll(r: Random): RollResult { + val range = (dieSize * nDice) - nDice val result = when (rollType) { - RollType.Advantage -> onAdvantage(r, nDice, dieSize) - RollType.Disadvantage -> onDisadvantage(r, nDice, dieSize) - else -> onNormalRoll(r, nDice, dieSize) + Advantage -> advantageRoll(r, nDice, range) + Disadvantage -> disadvantageRoll(r, nDice, range) + else -> normalRoll(r, nDice, range) } return RollResult(nDice, dieSize * nDice, result) } - private fun onAdvantage(r: Random, nDice: Int, dieSize: Int): Int { - val range = (dieSize * nDice) - val roll1 = r.nextInt(range) + nDice - val roll2 = r.nextInt(range) + nDice + private fun advantageRoll(r: Random, nDice: Int, range: Int): Int { + val roll1 = r.nextInt(range+1) + val roll2 = r.nextInt(range+1) - return max(roll1, roll2) + return max(roll1, roll2) + nDice } - private fun onDisadvantage(r: Random, nDice: Int, dieSize: Int): Int { - val range = (dieSize * nDice) - val roll1 = r.nextInt(range) + nDice - val roll2 = r.nextInt(range) + nDice + private fun disadvantageRoll(r: Random, nDice: Int, range: Int): Int { + val roll1 = r.nextInt(range+1) + val roll2 = r.nextInt(range+1) - return min(roll1, roll2) + return min(roll1, roll2) + nDice } - private fun onNormalRoll(r: Random, nDice: Int, dieSize: Int): Int { - val range = (dieSize * nDice) - return r.nextInt(range) + nDice + private fun normalRoll(r: Random, nDice: Int, range: Int): Int { + return r.nextInt(range+1) + nDice } /** diff --git a/src/main/kotlin/simulation/Modifier.kt b/src/main/kotlin/simulation/Modifier.kt index 4fd79d7..f7fb9a8 100644 --- a/src/main/kotlin/simulation/Modifier.kt +++ b/src/main/kotlin/simulation/Modifier.kt @@ -28,7 +28,7 @@ interface Modifier { * * @param dice The [Dice] instance to use for generating bonus values. */ -class DiceBonus(private val dice: Dice) : Modifier { +class DiceBonusModifier(private val dice: Dice) : Modifier { override fun getBonus(r: Random, crit: Boolean): Int { return if (crit){ dice.roll(r).result + dice.roll(r).result @@ -47,7 +47,7 @@ class DiceBonus(private val dice: Dice) : Modifier { * * @param dice The [Dice] to use for generating penalty values. */ -class DicePenalty(private val dice: Dice): Modifier{ +class DicePenaltyModifier(private val dice: Dice): Modifier{ override fun getBonus(r: Random, crit: Boolean): Int {//can penalties ever crit? return -dice.roll(r).result } diff --git a/src/test/kotlin/simulation/DiceTest.kt b/src/test/kotlin/simulation/DiceTest.kt index 2d02d2a..eb28076 100644 --- a/src/test/kotlin/simulation/DiceTest.kt +++ b/src/test/kotlin/simulation/DiceTest.kt @@ -53,4 +53,129 @@ internal class DiceTests { assertEquals(3, bonus) } + @Test + fun verifyRollRange() { + val rollString = "2d6" + val iterations = 10_000_000 + + RollType.entries.parallelStream() + .forEach { + val dice = Dice(rollString, it) + val r = Random() + repeat(iterations) { + val res = dice.roll(r) + Assertions.assertTrue(res.min <= res.result) + Assertions.assertTrue(res.result <= res.max) + } + } + } + + @Test + fun verifyRollBoundaries() { + val rollString = "2d6" + val iterations = 10_000_000 + val min = 2 + val max = 12 + + var observedMin = false + var observedMax = false + + + RollType.entries.parallelStream() + .forEach { + val dice = Dice(rollString, it) + val r = Random() + for (i in 0.. expectedAverageLowerBound) + Assertions.assertTrue(avg < expectedAverageUpperBound) + } + + @Test + fun verifyAdvantageSkew() { + val n = 2 + val max = 100 + val rollString = "${n}d${max}" + val tolerance = 1.25 //we expect more than a 25% improvement + val iterations = 10_000_000 + + val expectedAverageLowerBound = ((n + (n * max)) / 2) * tolerance + + val dice = Dice(rollString, RollType.Advantage) + val r = Random() + var total = 0L + repeat(iterations) { + total += dice.roll(r).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 + //tolerance + Assertions.assertTrue(avg > expectedAverageLowerBound) + } + + @Test + fun verifyDisadvantageSkew() { + val n = 2 + val max = 100 + val rollString = "${n}d${max}" + val tolerance = 0.75 //we expect more than a 25% improvement + val iterations = 10_000_000 + + val expectedAverageUpperBound = ((n + (n * max)) / 2) * tolerance + + val dice = Dice(rollString, RollType.Disadvantage) + val r = Random() + + var total = 0L + + repeat(iterations) { + total += dice.roll(r).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 + //tolerance + Assertions.assertTrue(avg < expectedAverageUpperBound) + } + } diff --git a/src/test/kotlin/simulation/SimulatorTest.kt b/src/test/kotlin/simulation/SimulatorTest.kt index 8d247df..7f24fed 100644 --- a/src/test/kotlin/simulation/SimulatorTest.kt +++ b/src/test/kotlin/simulation/SimulatorTest.kt @@ -27,7 +27,7 @@ class SimulatorTest { ) val attackWithAdvantageAndBless = SimpleMeleeAttack( - Dice("1d20", RollType.Advantage, FlatModifier(5), DiceBonus(Dice("1d4"))), + Dice("1d20", RollType.Advantage, FlatModifier(5), DiceBonusModifier(Dice("1d4"))), Dice("2d6", RollType.Normal, FlatModifier(5)), 15 )