we have tested the *shit* out of dice... provided you don't fuck up the input on the constructors

This commit is contained in:
dtookey 2023-09-02 23:08:43 -04:00
parent b918960040
commit 8892f684e8
10 changed files with 227 additions and 96 deletions

View File

@ -4,7 +4,7 @@ import java.util.*
interface Attack { interface Attack {
val actionRoll: Dice val actionRoll: AttackDice
val passValue: Int val passValue: Int
fun calculateDamage(r: Random): AttackResult { fun calculateDamage(r: Random): AttackResult {
@ -14,7 +14,7 @@ interface Attack {
return if (isHit(attackResult, attackBonus)) { return if (isHit(attackResult, attackBonus)) {
if (isCrit(attackResult)) { if (actionRoll.isCrit(attackResult)) {
onCriticalHit(r) onCriticalHit(r)
} else { } else {
onNormalHit(r) 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 { private fun isHit(roll: RollResult, attackBonus: Int): Boolean {
//ties go to the roller //ties go to the roller
return (roll.result + attackBonus) >= passValue return (roll.result + attackBonus) >= passValue

View File

@ -0,0 +1,35 @@
package simulation
class AttackDice(
override val rollString: String,
override val rollType: RollType = RollType.Normal,
override val modifiers: ArrayList<Modifier<Int>> = 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
}
}

View File

@ -21,7 +21,7 @@ data class AttackResult(val attackWasHit: Boolean, val attackWasCrit: Boolean, v
val critRate = (totalCriticals.toDouble() / sampleSize) * 100.0 val critRate = (totalCriticals.toDouble() / sampleSize) * 100.0
val sumDamage = results.sumOf { it.resultingDamage.toLong() } 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) val reportString = "Hit Rate: %.2f\tCrit Rate: %.2f\tAvg Dmg: %.2f".format(hitRate, critRate, avgDamage)

View File

@ -2,6 +2,7 @@ package simulation
import simulation.RollType.* import simulation.RollType.*
import java.util.* import java.util.*
import kotlin.collections.ArrayList
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@ -32,25 +33,18 @@ enum class RollType {
data class RollResult(val min: Int, val max: Int, val result: Int) data class RollResult(val min: Int, val max: Int, val result: Int)
/** interface Dice {
* Represents dice that can be rolled with different roll types and modifiers. val rollString: String
* val rollType: RollType
* @param rollString The dice roll notation, e.g. "2d6" val modifiers: List<Modifier<Int>>
* @param rollType The roll type to use, which determines how the dice are rolled val nDice: Int
* @param modifiers Optional modifier functions that add a bonus to the roll result val dieSize: Int
*/
class Dice(
rollString: String,
private val rollType: RollType = RollType.Normal,
private vararg val modifiers: Modifier<Int>
) {
private val nDice: Int
private val dieSize: Int
init {
val parts = rollString.lowercase().split("d") companion object {
nDice = parts[0].toInt() fun makeDice(rollString: String, rollType: RollType = Normal, modifiers: List<Modifier<Int>> = ArrayList()): Dice {
dieSize = parts[1].toInt() return SimpleDice(rollString, rollType, modifiers)
}
} }
/** /**
@ -74,22 +68,23 @@ class Dice(
return RollResult(nDice, dieSize * nDice, result) return RollResult(nDice, dieSize * nDice, result)
} }
private fun advantageRoll(r: Random, nDice: Int, range: Int): Int { private fun advantageRoll(r: Random, nDice: Int, range: Int): Int {
val roll1 = r.nextInt(range+1) val roll1 = r.nextInt(range + 1)
val roll2 = r.nextInt(range+1) val roll2 = r.nextInt(range + 1)
return max(roll1, roll2) + nDice return max(roll1, roll2) + nDice
} }
private fun disadvantageRoll(r: Random, nDice: Int, range: Int): Int { private fun disadvantageRoll(r: Random, nDice: Int, range: Int): Int {
val roll1 = r.nextInt(range+1) val roll1 = r.nextInt(range + 1)
val roll2 = r.nextInt(range+1) val roll2 = r.nextInt(range + 1)
return min(roll1, roll2) + nDice return min(roll1, roll2) + nDice
} }
private fun normalRoll(r: Random, nDice: Int, range: Int): Int { private fun normalRoll(r: Random, nDice: Int, range: Int): Int {
return r.nextInt(range+1) + nDice return r.nextInt(range + 1) + nDice
} }
/** /**
@ -102,3 +97,19 @@ class Dice(
return modifiers.sumOf { it.getBonus(r, crit) } return modifiers.sumOf { it.getBonus(r, crit) }
} }
} }
private class SimpleDice(
override val rollString: String,
override val rollType: RollType = Normal,
override val modifiers: List<Modifier<Int>> = 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()
}
}

View File

@ -10,7 +10,7 @@ import java.util.*
* @param passValue The defense value the attack must exceed to hit. * @param passValue The defense value the attack must exceed to hit.
*/ */
class SimpleMeleeAttack( class SimpleMeleeAttack(
override val actionRoll: Dice, override val actionRoll: AttackDice,
val damageRoll: Dice, val damageRoll: Dice,
override val passValue: Int override val passValue: Int
) : Attack { ) : Attack {

View File

@ -29,6 +29,9 @@ interface Modifier<T> {
* @param dice The [Dice] instance to use for generating bonus values. * @param dice The [Dice] instance to use for generating bonus values.
*/ */
class DiceBonusModifier(private val dice: Dice) : Modifier<Int> { class DiceBonusModifier(private val dice: Dice) : Modifier<Int> {
constructor(diceString: String):this(Dice.makeDice(diceString))
override fun getBonus(r: Random, crit: Boolean): Int { override fun getBonus(r: Random, crit: Boolean): Int {
return if (crit){ return if (crit){
dice.roll(r).result + dice.roll(r).result dice.roll(r).result + dice.roll(r).result

View File

@ -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))
}
}

View File

@ -6,11 +6,13 @@ import org.junit.jupiter.api.Test
import java.util.* import java.util.*
internal class DiceTests { internal class DiceTests {
private val random = Random(1)
private val d20 = Dice.makeDice("1d20")
@Test @Test
fun roll_normal() { fun roll_normal() {
val dice = Dice("2d6") val dice = Dice.makeDice("2d6")
val random = Random(1)
val result = dice.roll(random) val result = dice.roll(random)
assertEquals(2, result.min) assertEquals(2, result.min)
@ -20,8 +22,7 @@ internal class DiceTests {
@Test @Test
fun roll_advantage() { fun roll_advantage() {
val dice = Dice("2d6", RollType.Advantage) val dice = Dice.makeDice("2d6", RollType.Advantage)
val random = Random(1)
val result = dice.roll(random) val result = dice.roll(random)
assertEquals(2, result.min) assertEquals(2, result.min)
@ -32,8 +33,7 @@ internal class DiceTests {
@Test @Test
fun roll_disadvantage() { fun roll_disadvantage() {
val dice = Dice("2d6", RollType.Disadvantage) val dice = Dice.makeDice("2d6", RollType.Disadvantage)
val random = Random(1)
val result = dice.roll(random) val result = dice.roll(random)
assertEquals(2, result.min) assertEquals(2, result.min)
@ -45,23 +45,22 @@ internal class DiceTests {
fun evaluate_modifiers() { fun evaluate_modifiers() {
val mod1 = FlatModifier(1) val mod1 = FlatModifier(1)
val mod2 = FlatModifier(2) 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) val bonus = dice.evaluateModifiers(random)
assertEquals(3, bonus) assertEquals(3, bonus)
} }
@Test @Test
fun verifyRollRange() { fun verifyRollRangeForTypes() {
val rollString = "2d6" val rollString = "2d6"
val iterations = 10_000_000 val iterations = 10_000_000
RollType.entries.parallelStream() RollType.entries.parallelStream()
.forEach { .forEach {
val dice = Dice(rollString, it) val dice = Dice.makeDice(rollString, it)
val r = Random() val r = Random(1)
repeat(iterations) { repeat(iterations) {
val res = dice.roll(r) val res = dice.roll(r)
Assertions.assertTrue(res.min <= res.result) Assertions.assertTrue(res.min <= res.result)
@ -71,11 +70,19 @@ internal class DiceTests {
} }
@Test @Test
fun verifyRollBoundaries() { fun verifyBoundariesForTypes() {
val rollString = "2d6" 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 iterations = 10_000_000
val min = 2 val max = nDice * dieSize
val max = 12
var observedMin = false var observedMin = false
var observedMax = false var observedMax = false
@ -83,11 +90,11 @@ internal class DiceTests {
RollType.entries.parallelStream() RollType.entries.parallelStream()
.forEach { .forEach {
val dice = Dice(rollString, it) val dice = Dice.makeDice(rollString, it)
val r = Random() val r = Random(1)
for (i in 0..<iterations) { for (i in 0..<iterations) {
val res = dice.roll(r) val res = dice.roll(r)
if (!observedMin && res.result == min) { if (!observedMin && res.result == nDice) {
observedMin = true observedMin = true
} }
if (!observedMax && res.result == max) { if (!observedMax && res.result == max) {
@ -110,14 +117,13 @@ internal class DiceTests {
val tolerance = 0.05 //we expect more than a 25% improvement val tolerance = 0.05 //we expect more than a 25% improvement
val iterations = 100_000_000 val iterations = 100_000_000
val expectedAverageLowerBound = ((n + (n * max)) / 2) * (1-tolerance) val expectedAverageLowerBound = ((n + (n * max)) / 2) * (1 - tolerance)
val expectedAverageUpperBound = ((n + (n * max)) / 2) * (1+tolerance) val expectedAverageUpperBound = ((n + (n * max)) / 2) * (1 + tolerance)
val dice = Dice(rollString, RollType.Normal) val dice = Dice.makeDice(rollString, RollType.Normal)
val r = Random()
var total = 0L var total = 0L
repeat(iterations) { repeat(iterations) {
total += dice.roll(r).result.toLong() total += dice.roll(random).result.toLong()
} }
val avg = total.toDouble() / iterations.toDouble() val avg = total.toDouble() / iterations.toDouble()
@ -136,20 +142,20 @@ internal class DiceTests {
val tolerance = 1.25 //we expect more than a 25% improvement val tolerance = 1.25 //we expect more than a 25% improvement
val iterations = 10_000_000 val iterations = 10_000_000
val expectedAverageLowerBound = ((n + (n * max)) / 2) * tolerance val expectedAverageUpperBound = ((n + (n * max)) / 2) * tolerance
val dice = Dice.makeDice(rollString, RollType.Advantage)
val dice = Dice(rollString, RollType.Advantage)
val r = Random()
var total = 0L var total = 0L
repeat(iterations) { repeat(iterations) {
total += dice.roll(r).result.toLong() total += dice.roll(random).result.toLong()
} }
val avg = total.toDouble() / iterations.toDouble() 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 greater than the expected upper bound of the normal roll scaled by the
//tolerance //tolerance
Assertions.assertTrue(avg > expectedAverageLowerBound) Assertions.assertTrue(avg > expectedAverageUpperBound)
} }
@Test @Test
@ -157,25 +163,50 @@ internal class DiceTests {
val n = 2 val n = 2
val max = 100 val max = 100
val rollString = "${n}d${max}" 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 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 var total = 0L
repeat(iterations) { repeat(iterations) {
total += dice.roll(r).result.toLong() total += dice.roll(random).result.toLong()
} }
val avg = total.toDouble() / iterations.toDouble() 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 //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<Int, Int>()
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)))
}
} }
} }

View File

@ -0,0 +1,27 @@
package simulation
import kotlin.test.Test
class MeleeAttackTest {
@Test
fun testAttack() {
val itt = 1_000_000
val simulator = Simulator.getInstance<AttackResult>(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")
}
}

View File

@ -14,34 +14,6 @@ class SimulatorTest {
val finish = System.nanoTime() 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 = 1_000_000
val simulator = Simulator.getInstance<AttackResult>(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")
}
} }