From 3ce527b07f180e563e279a2f5c0c0e123c4873c9 Mon Sep 17 00:00:00 2001 From: dtookey Date: Sat, 2 Sep 2023 21:09:32 -0400 Subject: [PATCH] split dice up a bit better, and it should be easier to implement future roll types like reroll-once and other things --- src/main/kotlin/simulation/Attack.kt | 75 ++++++++++++++++++--- src/main/kotlin/simulation/Dice.kt | 61 +++++++++++------ src/main/kotlin/simulation/MeleeAttack.kt | 28 ++++---- src/main/kotlin/simulation/Modifier.kt | 16 +++-- src/main/kotlin/simulation/Simulator.kt | 8 +-- src/test/kotlin/simulation/DiceTest.kt | 56 +++++++++++++++ src/test/kotlin/simulation/SimulatorTest.kt | 20 +++--- 7 files changed, 203 insertions(+), 61 deletions(-) create mode 100644 src/test/kotlin/simulation/DiceTest.kt diff --git a/src/main/kotlin/simulation/Attack.kt b/src/main/kotlin/simulation/Attack.kt index 02c1d7c..d712eb1 100644 --- a/src/main/kotlin/simulation/Attack.kt +++ b/src/main/kotlin/simulation/Attack.kt @@ -3,19 +3,78 @@ package simulation import java.util.* interface Attack { - fun attackerSuccessful(r: Random): Boolean - fun resultingDamage(r: Random, attackSuccessful: Boolean): Int + val actionRoll: Dice + val passValue: Int - fun getResultingDamage(r: Random): Int{ - val success = attackerSuccessful(r) - return resultingDamage(r, success) + fun calculateDamage(r: Random): AttackResult { + + val attackResult = actionRoll.roll(r) + val attackBonus = actionRoll.evaluateModifiers(r, false) + + + return if (isHit(attackResult, attackBonus)) { + if (isCrit(attackResult)) { + onCriticalHit(r) + } else { + onNormalHit(r) + } + } else { + onMiss(r) + } } + + + 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 + } + + fun onNormalHit(r: Random): AttackResult + + fun onCriticalHit(r: Random): AttackResult + + fun onMiss(r: Random): AttackResult + } +private fun ArrayList.sumBools(fn: (AttackResult) -> Boolean): Int { + return sumOf { boolToInt(fn(it)) } +} -class AttackSimulatorModel(override val sampleSize: Int, private val attack: Attack) : SimulationModel{ - override fun simulate(r: Random): Int { - return attack.getResultingDamage(r) +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/Dice.kt b/src/main/kotlin/simulation/Dice.kt index 36ddcdb..c8e8ee5 100644 --- a/src/main/kotlin/simulation/Dice.kt +++ b/src/main/kotlin/simulation/Dice.kt @@ -11,21 +11,26 @@ import kotlin.math.min * @property Normal Rolls the dice normally once. * @property Disadvantage Rolls the dice twice and takes the lower result. */ -enum class RollType{ +enum class RollType { /** * Rolls the dice twice and returns the higher of the two results. */ Advantage, + /** * Rolls the dice once normally. */ Normal, + /** * Rolls the dice twice and returns the lower of the two results. */ Disadvantage } +data class RollResult(val min: Int, val max: Int, val result: Int) + + /** * Represents dice that can be rolled with different roll types and modifiers. * @@ -33,7 +38,11 @@ enum class RollType{ * @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) { +class Dice( + rollString: String, + private val rollType: RollType = RollType.Normal, + private vararg val modifiers: Modifier +) { private val nDice: Int private val dieSize: Int @@ -54,24 +63,34 @@ class Dice(rollString: String, private val rollType: RollType = RollType.Normal, * @return The result of the dice roll with modifiers applied * @see RollType */ - fun roll(r: Random): Int { - val result = when(rollType){ - RollType.Advantage->{ - val range1 = (dieSize * nDice) - nDice - val range2 = (dieSize * nDice) - nDice - max(range1, range2) + nDice - } - RollType.Disadvantage->{ - val range1 = (dieSize * nDice) - nDice - val range2 = (dieSize * nDice) - nDice - min(range1, range2) + nDice - } - else->{ - val range = (dieSize * nDice) - nDice - r.nextInt(range) + nDice - } + fun roll(r: Random): RollResult { + val result = when (rollType) { + RollType.Advantage -> onAdvantage(r, nDice, dieSize) + RollType.Disadvantage -> onDisadvantage(r, nDice, dieSize) + else -> onNormalRoll(r, nDice, dieSize) } - return result + evaluateModifiers(r) + 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 + + return max(roll1, roll2) + } + + 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 + + return min(roll1, roll2) + } + + private fun onNormalRoll(r: Random, nDice: Int, dieSize: Int): Int { + val range = (dieSize * nDice) + return r.nextInt(range) + nDice } /** @@ -80,7 +99,7 @@ class Dice(rollString: String, private val rollType: RollType = RollType.Normal, * @param r The Random instance to pass to each modifier's getBonus() method * @return The summed bonus values from all modifiers */ - fun evaluateModifiers(r: Random): Int{ - return modifiers.sumOf{ it.getBonus(r) } + fun evaluateModifiers(r: Random, crit: Boolean = false): Int { + return modifiers.sumOf { it.getBonus(r, crit) } } } \ No newline at end of file diff --git a/src/main/kotlin/simulation/MeleeAttack.kt b/src/main/kotlin/simulation/MeleeAttack.kt index 8bcc01d..3ed0ac6 100644 --- a/src/main/kotlin/simulation/MeleeAttack.kt +++ b/src/main/kotlin/simulation/MeleeAttack.kt @@ -5,27 +5,29 @@ import java.util.* /** * Represents a simple melee attack in a simulation. * - * @param attackRoll The dice roll used to determine if an attack hits. + * @param actionRoll The dice roll used to determine if an attack hits. * @param damageRoll The dice roll used to determine damage if attack hits. - * @param defense The defense value the attack must exceed to hit. + * @param passValue The defense value the attack must exceed to hit. */ class SimpleMeleeAttack( - val attackRoll: Dice, + override val actionRoll: Dice, val damageRoll: Dice, - val defense: Int + override val passValue: Int ) : Attack { - override fun attackerSuccessful(r: Random): Boolean { - val attackTotal = attackRoll.roll(r) - return attackTotal >= defense + override fun onNormalHit(r: Random): AttackResult { + val damage = damageRoll.roll(r).result + damageRoll.evaluateModifiers(r, false) + return AttackResult(attackWasHit = true, attackWasCrit = false, damage) } - override fun resultingDamage(r: Random, attackSuccessful: Boolean): Int { - return if(attackSuccessful){ - damageRoll.roll(r) - }else{ - 0 - } + override fun onCriticalHit(r: Random): AttackResult { + val damage = + damageRoll.roll(r).result + damageRoll.roll(r).result + damageRoll.evaluateModifiers(r, true) + return AttackResult(attackWasHit = true, attackWasCrit = true, damage) + } + + override fun onMiss(r: Random): AttackResult { + return AttackResult(attackWasHit = false, attackWasCrit = false, 0) } } diff --git a/src/main/kotlin/simulation/Modifier.kt b/src/main/kotlin/simulation/Modifier.kt index 5df88db..4fd79d7 100644 --- a/src/main/kotlin/simulation/Modifier.kt +++ b/src/main/kotlin/simulation/Modifier.kt @@ -17,7 +17,7 @@ interface Modifier { * @param r Random instance to use for random number generation * @return a generated bonus integer */ - fun getBonus(r: Random): T + fun getBonus(r: Random, crit: Boolean): T } /** @@ -29,8 +29,12 @@ interface Modifier { * @param dice The [Dice] instance to use for generating bonus values. */ class DiceBonus(private val dice: Dice) : Modifier { - override fun getBonus(r: Random): Int { - return dice.roll(r) + override fun getBonus(r: Random, crit: Boolean): Int { + return if (crit){ + dice.roll(r).result + dice.roll(r).result + }else{ + dice.roll(r).result + } } } @@ -44,8 +48,8 @@ class DiceBonus(private val dice: Dice) : Modifier { * @param dice The [Dice] to use for generating penalty values. */ class DicePenalty(private val dice: Dice): Modifier{ - override fun getBonus(r: Random): Int { - return -dice.roll(r) + override fun getBonus(r: Random, crit: Boolean): Int {//can penalties ever crit? + return -dice.roll(r).result } } @@ -57,7 +61,7 @@ class DicePenalty(private val dice: Dice): Modifier{ * @param bonus The fixed bonus amount to apply. */ class FlatModifier(private val bonus: Int) : Modifier { - override fun getBonus(r: Random): Int { + override fun getBonus(r: Random, crit: Boolean): Int { return bonus } } diff --git a/src/main/kotlin/simulation/Simulator.kt b/src/main/kotlin/simulation/Simulator.kt index 43c069b..d88588d 100644 --- a/src/main/kotlin/simulation/Simulator.kt +++ b/src/main/kotlin/simulation/Simulator.kt @@ -6,17 +6,17 @@ import java.util.* import kotlin.collections.ArrayList -interface SimulationModel { +interface SimulationModel { val sampleSize: Int //has to be pure or else you're going to have a bad time fun simulate(r: Random): T } -interface Simulator { +interface Simulator { companion object { - fun getInstance(nThreads: Int = Runtime.getRuntime().availableProcessors() / 2 ): Simulator { + fun getInstance(nThreads: Int = Runtime.getRuntime().availableProcessors() / 2 ): Simulator { return concreteSimulator(nThreads) } } @@ -65,5 +65,5 @@ interface Simulator { } -class concreteSimulator(override val nThreads: Int) : +class concreteSimulator(override val nThreads: Int) : Simulator \ No newline at end of file diff --git a/src/test/kotlin/simulation/DiceTest.kt b/src/test/kotlin/simulation/DiceTest.kt new file mode 100644 index 0000000..2d02d2a --- /dev/null +++ b/src/test/kotlin/simulation/DiceTest.kt @@ -0,0 +1,56 @@ +package simulation + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.* + +internal class DiceTests { + + @Test + fun roll_normal() { + val dice = Dice("2d6") + val random = Random(1) + val result = dice.roll(random) + + assertEquals(2, result.min) + assertEquals(12, result.max) + Assertions.assertTrue(result.min <= result.result && result.result <= result.max) + } + + @Test + fun roll_advantage() { + val dice = Dice("2d6", RollType.Advantage) + val random = Random(1) + val result = dice.roll(random) + + assertEquals(2, result.min) + assertEquals(12, result.max) + Assertions.assertTrue(result.min <= result.result && result.result <= result.max) + + } + + @Test + fun roll_disadvantage() { + val dice = Dice("2d6", RollType.Disadvantage) + val random = Random(1) + val result = dice.roll(random) + + assertEquals(2, result.min) + assertEquals(12, result.max) + Assertions.assertTrue(result.min <= result.result && result.result <= result.max) + } + + @Test + fun evaluate_modifiers() { + val mod1 = FlatModifier(1) + val mod2 = FlatModifier(2) + val dice = Dice("1d20", RollType.Normal, mod1, mod2) + + val random = Random(1) + val bonus = dice.evaluateModifiers(random) + + assertEquals(3, bonus) + } + +} diff --git a/src/test/kotlin/simulation/SimulatorTest.kt b/src/test/kotlin/simulation/SimulatorTest.kt index db40b90..8d247df 100644 --- a/src/test/kotlin/simulation/SimulatorTest.kt +++ b/src/test/kotlin/simulation/SimulatorTest.kt @@ -5,20 +5,20 @@ import kotlin.test.Test class SimulatorTest { @Test - fun testStats(){ + fun testStats() { val itt = 10_000_000 val model = testSimulationModel(itt) val simulator = Simulator.getInstance(Runtime.getRuntime().availableProcessors()) val start = System.nanoTime() val results = simulator.doSimulation(model) val finish = System.nanoTime() - println("${results.size} simulations performed in ${finish - start}ns (${(finish-start)/results.size}ns/simulation)") + println("${results.size} simulations performed in ${finish - start}ns (${(finish - start) / results.size}ns/simulation)") } @Test - fun testAttack(){ - val itt = 10_000_000 - val simulator = Simulator.getInstance(Runtime.getRuntime().availableProcessors()) + fun testAttack() { + val itt = 1_000_000 + val simulator = Simulator.getInstance(Runtime.getRuntime().availableProcessors()) val attack = SimpleMeleeAttack( Dice("1d20", RollType.Normal, FlatModifier(5)), @@ -27,7 +27,7 @@ class SimulatorTest { ) val attackWithAdvantageAndBless = SimpleMeleeAttack( - Dice("1d20", RollType.Advantage,FlatModifier(5), DiceBonus(Dice("1d4"))), + Dice("1d20", RollType.Advantage, FlatModifier(5), DiceBonus(Dice("1d4"))), Dice("2d6", RollType.Normal, FlatModifier(5)), 15 ) @@ -38,14 +38,16 @@ class SimulatorTest { val buffedAttackModel = AttackSimulatorModel(itt, attackWithAdvantageAndBless) val buffedResults = simulator.doSimulation(buffedAttackModel) - println("Average normal damage: ${normalResults.average()}\nAverage buffed damage: ${buffedResults.average()}") + + AttackResult.printSimulationStatistics(normalResults, "Normal Attack") + AttackResult.printSimulationStatistics(buffedResults, "Buffed Attack") } } -class testSimulationModel(override val sampleSize: Int) : SimulationModel{ +class testSimulationModel(override val sampleSize: Int) : SimulationModel { override fun simulate(r: Random): Int { - return r.nextInt(20)+1 + return r.nextInt(20) + 1 } } \ No newline at end of file