wrote some tests to validate dice stuff

This commit is contained in:
dtookey 2023-09-02 21:52:39 -04:00
parent 3ce527b07f
commit b918960040
6 changed files with 180 additions and 50 deletions

View File

@ -42,37 +42,6 @@ interface Attack {
}
private fun ArrayList<AttackResult>.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<AttackResult>, 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<AttackResult> {
override fun simulate(r: Random): AttackResult {
return attack.calculateDamage(r)

View File

@ -0,0 +1,37 @@
package simulation
import java.util.ArrayList
private fun ArrayList<AttackResult>.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<AttackResult>, 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)
}
}
}
}

View File

@ -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
}
/**

View File

@ -28,7 +28,7 @@ interface Modifier<T> {
*
* @param dice The [Dice] instance to use for generating bonus values.
*/
class DiceBonus(private val dice: Dice) : Modifier<Int> {
class DiceBonusModifier(private val dice: Dice) : Modifier<Int> {
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<Int> {
*
* @param dice The [Dice] to use for generating penalty values.
*/
class DicePenalty(private val dice: Dice): Modifier<Int>{
class DicePenaltyModifier(private val dice: Dice): Modifier<Int>{
override fun getBonus(r: Random, crit: Boolean): Int {//can penalties ever crit?
return -dice.roll(r).result
}

View File

@ -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..<iterations) {
val res = dice.roll(r)
if (!observedMin && res.result == min) {
observedMin = true
}
if (!observedMax && res.result == max) {
observedMax = true
}
if (observedMin && observedMax) {
break
}
}
Assertions.assertTrue(observedMin)
Assertions.assertTrue(observedMax)
}
}
@Test
fun verifyNormalRollWithinExpectedBounds() {
val n = 2
val max = 100
val rollString = "${n}d${max}"
val tolerance = 0.05 //we expect more than a 25% improvement
val iterations = 100_000_000
val expectedAverageLowerBound = ((n + (n * max)) / 2) * (1-tolerance)
val expectedAverageUpperBound = ((n + (n * max)) / 2) * (1+tolerance)
val dice = Dice(rollString, RollType.Normal)
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)
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)
}
}

View File

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