Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds unit tests to assure randomness #770

Merged
merged 4 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,12 @@ final class GameEngine[StartUpData, GameModel, ViewModel](

audioPlayer.addAudioAssets(accumulatedAssetCollection.sounds)

val time = if (firstRun) 0 else gameLoopInstance.runningTimeReference
val randomSeed = (if (firstRun) 0 else gameLoopInstance.runningTimeReference) + gameLoopInstance.initialSeed

if (firstRun)
platform = new Platform(parentElement, gameConfig, globalEventStream, dynamicText)

initialise(accumulatedAssetCollection)(Dice.fromSeed(time.toLong)) match {
initialise(accumulatedAssetCollection)(Dice.fromSeed(randomSeed.toLong)) match {
case oe @ Outcome.Error(error, _) =>
IndigoLogger.error(
if (firstRun) "Error during first initialisation - Halting."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import indigo.shared.time.Millis
import indigo.shared.time.Seconds

import scala.collection.mutable
import scala.scalajs.js.Date
import scala.scalajs.js.JSConverters._

final class GameLoop[StartUpData, GameModel, ViewModel](
Expand All @@ -36,6 +37,8 @@ final class GameLoop[StartUpData, GameModel, ViewModel](
renderer: => Renderer
):

val initialSeed = new Date().valueOf()

@SuppressWarnings(Array("scalafix:DisableSyntax.var"))
private var _gameModelState: GameModel = initialModel
@SuppressWarnings(Array("scalafix:DisableSyntax.var"))
Expand Down Expand Up @@ -131,7 +134,7 @@ final class GameLoop[StartUpData, GameModel, ViewModel](
gameTime,
events,
_inputState,
Dice.fromSeconds(gameTime.running),
Dice.fromSeconds(gameTime.running + initialSeed),
boundaryLocator,
renderer
)
Expand Down
50 changes: 48 additions & 2 deletions indigo/indigo/src/main/scala/indigo/shared/dice/Dice.scala
Original file line number Diff line number Diff line change
Expand Up @@ -168,35 +168,81 @@ object Dice:

val r: Random = new Random(seed)

/** Roll an Int from 1 to the number of sides on the dice (inclusive)
*
* @return
*/
def roll: Int =
r.nextInt(sanitise(sides)) + 1
roll(sides)

/** Roll an Int from 1 to the specified number of sides (inclusive)
*
* @param sides
* @return
*/
def roll(sides: Int): Int =
r.nextInt(sanitise(sides)) + 1

/** Roll an Int from 0 to the number of sides on the dice (exclusive)
*
* @return
*/
def rollFromZero: Int =
roll - 1
rollFromZero(sides)

/** Roll an Int from 0 to the specified number of sides (exclusive)
*
* @param sides
* @return
*/
def rollFromZero(sides: Int): Int =
roll(sides) - 1

/** Roll an Int from the range provided (inclusive)
*
* @param from
* @param to
* @return
*/
def rollRange(from: Int, to: Int): Int =
val f = Math.min(from, to)
val t = Math.max(from, to)
roll(t - f + 1) + f - 1

/** Produces a random Float from 0.0 to 1.0
*
* @return
*/
def rollFloat: Float =
r.nextFloat()

/** Produces a random Double from 0.0 to 1.0
*
* @return
*/
def rollDouble: Double =
r.nextDouble()

/** Produces a random alphanumeric string of the specified length
*
* @param length
* @return
*/
def rollAlphaNumeric(length: Int): String =
r.alphanumeric.take(length).mkString

/** Produces a random alphanumeric string 16 characters long
*
* @return
*/
def rollAlphaNumeric: String =
rollAlphaNumeric(16)

/** Shuffles a list of values into a random order
*
* @param items
* @return
*/
def shuffle[A](items: List[A]): List[A] =
r.shuffle(items)
}
Expand Down
84 changes: 84 additions & 0 deletions indigo/indigo/src/test/scala/indigo/shared/dice/DiceTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package indigo.shared.dice

import indigo.shared.collections.NonEmptyList

import scala.collection.immutable.SortedMap

@SuppressWarnings(Array("scalafix:DisableSyntax.throw"))
class DiceTests extends munit.FunSuite {

Expand All @@ -10,6 +12,8 @@ class DiceTests extends munit.FunSuite {
def checkDice(roll: Int, to: Int): Boolean =
roll >= 1 && roll <= to

def almostEquals(d: Double, d2: Double, p: Double) = (d - d2).abs <= p

test("diceSidesN") {
val roll: Int = Dice.diceSidesN(1, 0).roll(10)

Expand Down Expand Up @@ -57,4 +61,84 @@ class DiceTests extends munit.FunSuite {
assertEquals(actual, expected)
}

test("all dice rolls have an approximately uniform distribution") {
val diceSides = 63
val numRuns = 200_000_000
val expectedDistribution = 1.0 / diceSides
val generatedNums =
Array
.range(0, numRuns)
.foldLeft(SortedMap[Int, Int]()) { (acc, _) =>
val roll = Dice.diceSidesN(diceSides, scala.util.Random.nextLong()).roll
acc.updated(roll, acc.getOrElse(roll, 0) + 1)
}

// Ensure that we have the right numbers generated (they should all have been created)
assertEquals(generatedNums.size, diceSides)
assertEquals(generatedNums.head._1, 1)
assertEquals(generatedNums.last._1, diceSides)

// Check the even distribution of the generated numbers
generatedNums.foreach { case (num, count) =>
val distribution = count.toDouble / numRuns
assert(
almostEquals(distribution, expectedDistribution, 0.01),
s"""The distribution for $num was $distribution, but expected $expectedDistribution"""
)
}
}

test("all dice rolls in rollRange have an approximately uniform distribution") {
val diceSides = 63
val halfSides = Math.floor(diceSides / 2.0).toInt
val numRuns = 200_000_000
val expectedDistribution = 1.0 / halfSides
val generatedNums =
Array
.range(0, numRuns)
.foldLeft(SortedMap[Int, Int]()) { (acc, _) =>
val roll = Dice.diceSidesN(diceSides, scala.util.Random.nextLong()).rollRange(halfSides, diceSides)
acc.updated(roll, acc.getOrElse(roll, 0) + 1)
}

// Ensure that we have the right numbers generated (only numbers from just before half way through the number of sides should have een created)
assertEquals(generatedNums.size, (diceSides - halfSides) + 1)
assertEquals(generatedNums.head._1, halfSides)
assertEquals(generatedNums.last._1, diceSides)

// Check the even distribution of the generated numbers
generatedNums.foreach { case (num, count) =>
val distribution = count.toDouble / numRuns
assert(
almostEquals(distribution, expectedDistribution, 0.01),
s"""The distribution for $num was $distribution, but expected $expectedDistribution"""
)
}
}

test("all dice rolls in rollRange(1, 4) have an approximately uniform distribution") {
val numRuns = 200_000_000
val expectedDistribution = 0.25
val generatedNums =
Array
.range(0, numRuns)
.foldLeft(SortedMap[Int, Int]()) { (acc, _) =>
val roll = Dice.diceSidesN(4, scala.util.Random.nextLong()).rollRange(1, 4)
acc.updated(roll, acc.getOrElse(roll, 0) + 1)
}

// Ensure that we have the right numbers generated (they should all have been created)
assertEquals(generatedNums.size, 4)
assertEquals(generatedNums.head._1, 1)
assertEquals(generatedNums.last._1, 4)

// Check the even distribution of the generated numbers
generatedNums.foreach { case (num, count) =>
val distribution = count.toDouble / numRuns
assert(
almostEquals(distribution, expectedDistribution, 0.01),
s"""The distribution for $num was $distribution, but expected $expectedDistribution"""
)
}
}
}
Loading