wrote some tests to validate dice stuff
This commit is contained in:
parent
3ce527b07f
commit
b918960040
@ -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> {
|
class AttackSimulatorModel(override val sampleSize: Int, private val attack: Attack) : SimulationModel<AttackResult> {
|
||||||
override fun simulate(r: Random): AttackResult {
|
override fun simulate(r: Random): AttackResult {
|
||||||
return attack.calculateDamage(r)
|
return attack.calculateDamage(r)
|
||||||
|
|||||||
37
src/main/kotlin/simulation/AttackResult.kt
Normal file
37
src/main/kotlin/simulation/AttackResult.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
package simulation
|
package simulation
|
||||||
|
|
||||||
|
import simulation.RollType.*
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@ -64,33 +65,31 @@ class Dice(
|
|||||||
* @see RollType
|
* @see RollType
|
||||||
*/
|
*/
|
||||||
fun roll(r: Random): RollResult {
|
fun roll(r: Random): RollResult {
|
||||||
|
val range = (dieSize * nDice) - nDice
|
||||||
val result = when (rollType) {
|
val result = when (rollType) {
|
||||||
RollType.Advantage -> onAdvantage(r, nDice, dieSize)
|
Advantage -> advantageRoll(r, nDice, range)
|
||||||
RollType.Disadvantage -> onDisadvantage(r, nDice, dieSize)
|
Disadvantage -> disadvantageRoll(r, nDice, range)
|
||||||
else -> onNormalRoll(r, nDice, dieSize)
|
else -> normalRoll(r, nDice, range)
|
||||||
}
|
}
|
||||||
return RollResult(nDice, dieSize * nDice, result)
|
return RollResult(nDice, dieSize * nDice, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onAdvantage(r: Random, nDice: Int, dieSize: Int): Int {
|
private fun advantageRoll(r: Random, nDice: Int, range: Int): Int {
|
||||||
val range = (dieSize * nDice)
|
val roll1 = r.nextInt(range+1)
|
||||||
val roll1 = r.nextInt(range) + nDice
|
val roll2 = r.nextInt(range+1)
|
||||||
val roll2 = r.nextInt(range) + nDice
|
|
||||||
|
|
||||||
return max(roll1, roll2)
|
return max(roll1, roll2) + nDice
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onDisadvantage(r: Random, nDice: Int, dieSize: Int): Int {
|
private fun disadvantageRoll(r: Random, nDice: Int, range: Int): Int {
|
||||||
val range = (dieSize * nDice)
|
val roll1 = r.nextInt(range+1)
|
||||||
val roll1 = r.nextInt(range) + nDice
|
val roll2 = r.nextInt(range+1)
|
||||||
val roll2 = r.nextInt(range) + nDice
|
|
||||||
|
|
||||||
return min(roll1, roll2)
|
return min(roll1, roll2) + nDice
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onNormalRoll(r: Random, nDice: Int, dieSize: Int): Int {
|
private fun normalRoll(r: Random, nDice: Int, range: Int): Int {
|
||||||
val range = (dieSize * nDice)
|
return r.nextInt(range+1) + nDice
|
||||||
return r.nextInt(range) + nDice
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -28,7 +28,7 @@ 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 DiceBonus(private val dice: Dice) : Modifier<Int> {
|
class DiceBonusModifier(private val dice: Dice) : Modifier<Int> {
|
||||||
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
|
||||||
@ -47,7 +47,7 @@ class DiceBonus(private val dice: Dice) : Modifier<Int> {
|
|||||||
*
|
*
|
||||||
* @param dice The [Dice] to use for generating penalty values.
|
* @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?
|
override fun getBonus(r: Random, crit: Boolean): Int {//can penalties ever crit?
|
||||||
return -dice.roll(r).result
|
return -dice.roll(r).result
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,4 +53,129 @@ internal class DiceTests {
|
|||||||
assertEquals(3, bonus)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@ class SimulatorTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val attackWithAdvantageAndBless = SimpleMeleeAttack(
|
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)),
|
Dice("2d6", RollType.Normal, FlatModifier(5)),
|
||||||
15
|
15
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user