split dice up a bit better, and it should be easier to implement future roll types like reroll-once and other things

This commit is contained in:
dtookey 2023-09-02 21:09:32 -04:00
parent 2ac759deb4
commit 3ce527b07f
7 changed files with 203 additions and 61 deletions

View File

@ -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<AttackResult>.sumBools(fn: (AttackResult) -> Boolean): Int {
return sumOf { boolToInt(fn(it)) }
}
class AttackSimulatorModel(override val sampleSize: Int, private val attack: Attack) : SimulationModel<Int>{
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<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

@ -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<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
@ -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
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)
}
RollType.Disadvantage->{
val range1 = (dieSize * nDice) - nDice
val range2 = (dieSize * nDice) - nDice
min(range1, range2) + nDice
return RollResult(nDice, dieSize * nDice, result)
}
else->{
val range = (dieSize * nDice) - nDice
r.nextInt(range) + nDice
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)
}
return result + evaluateModifiers(r)
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) }
}
}

View File

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

View File

@ -17,7 +17,7 @@ interface Modifier<T> {
* @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<T> {
* @param dice The [Dice] instance to use for generating bonus values.
*/
class DiceBonus(private val dice: Dice) : Modifier<Int> {
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<Int> {
* @param dice The [Dice] to use for generating penalty values.
*/
class DicePenalty(private val dice: Dice): Modifier<Int>{
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<Int>{
* @param bonus The fixed bonus amount to apply.
*/
class FlatModifier(private val bonus: Int) : Modifier<Int> {
override fun getBonus(r: Random): Int {
override fun getBonus(r: Random, crit: Boolean): Int {
return bonus
}
}

View File

@ -6,17 +6,17 @@ import java.util.*
import kotlin.collections.ArrayList
interface SimulationModel<T : Number> {
interface SimulationModel<T : Any> {
val sampleSize: Int
//has to be pure or else you're going to have a bad time
fun simulate(r: Random): T
}
interface Simulator<T : Number> {
interface Simulator<T : Any> {
companion object {
fun <T: Number> getInstance(nThreads: Int = Runtime.getRuntime().availableProcessors() / 2 ): Simulator<T> {
fun <T: Any> getInstance(nThreads: Int = Runtime.getRuntime().availableProcessors() / 2 ): Simulator<T> {
return concreteSimulator(nThreads)
}
}
@ -65,5 +65,5 @@ interface Simulator<T : Number> {
}
class concreteSimulator<T : Number>(override val nThreads: Int) :
class concreteSimulator<T : Any>(override val nThreads: Int) :
Simulator<T>

View File

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

View File

@ -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<Int>(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<Int>(Runtime.getRuntime().availableProcessors())
fun testAttack() {
val itt = 1_000_000
val simulator = Simulator.getInstance<AttackResult>(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<Int>{
class testSimulationModel(override val sampleSize: Int) : SimulationModel<Int> {
override fun simulate(r: Random): Int {
return r.nextInt(20)+1
return r.nextInt(20) + 1
}
}