Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save jakzal/3f0ee968fe8073ee81e328ecbd59fe8b to your computer and use it in GitHub Desktop.
Save jakzal/3f0ee968fe8073ee81e328ecbd59fe8b to your computer and use it in GitHub Desktop.
Mastermind game in Kotlin
package mastermind.game
import arrow.core.Either
import arrow.core.getOrElse
import mastermind.game.Feedback.Outcome.*
import mastermind.game.Feedback.Peg.BLACK
import mastermind.game.Feedback.Peg.WHITE
import mastermind.game.GameCommand.JoinGame
import mastermind.game.GameCommand.MakeGuess
import mastermind.game.GameError.GameFinishedError.GameAlreadyLost
import mastermind.game.GameError.GameFinishedError.GameAlreadyWon
import mastermind.game.GameError.GuessError.*
import mastermind.game.GameEvent.*
import mastermind.game.testkit.anyGameId
import mastermind.testkit.assertions.shouldFailWith
import mastermind.testkit.assertions.shouldSucceedWith
import mastermind.testkit.dynamictest.dynamicTestsFor
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestFactory
class GameExamples {
private val gameId = anyGameId()
private val secret = Code("Red", "Green", "Blue", "Yellow")
private val totalAttempts = 12
private val availablePegs = setOfPegs("Red", "Green", "Blue", "Yellow", "Purple", "Pink")
@Test
fun `it starts the game`() {
execute(JoinGame(gameId, secret, totalAttempts, availablePegs)) shouldSucceedWith listOf(
GameStarted(
gameId,
secret,
totalAttempts,
availablePegs
)
)
}
@Test
fun `it makes a guess`() {
val game = gameOf(GameStarted(gameId, secret, totalAttempts, availablePegs))
execute(MakeGuess(gameId, Code("Purple", "Purple", "Purple", "Purple")), game) shouldSucceedWith listOf(
GuessMade(
gameId,
Guess(
Code("Purple", "Purple", "Purple", "Purple"),
Feedback(IN_PROGRESS)
)
)
)
}
@TestFactory
fun `it gives feedback on the guess`() = guessExamples { (secret: Code, guess: Code, feedback: Feedback) ->
val game = gameOf(GameStarted(gameId, secret, totalAttempts, availablePegs))
execute(MakeGuess(gameId, guess), game) shouldSucceedWith listOf(GuessMade(gameId, Guess(guess, feedback)))
}
private fun guessExamples(block: (Triple<Code, Code, Feedback>) -> Unit) = mapOf(
"it gives a black peg for each code peg on the correct position" to Triple(
Code("Red", "Green", "Blue", "Yellow"),
Code("Red", "Purple", "Blue", "Purple"),
Feedback(IN_PROGRESS, BLACK, BLACK)
),
"it gives no black peg for code peg duplicated on a wrong position" to Triple(
Code("Red", "Green", "Blue", "Yellow"),
Code("Red", "Red", "Purple", "Purple"),
Feedback(IN_PROGRESS, BLACK)
),
"it gives a white peg for code peg that is part of the code but is placed on a wrong position" to Triple(
Code("Red", "Green", "Blue", "Yellow"),
Code("Purple", "Red", "Purple", "Purple"),
Feedback(IN_PROGRESS, WHITE)
),
"it gives no white peg for code peg duplicated on a wrong position" to Triple(
Code("Red", "Green", "Blue", "Yellow"),
Code("Purple", "Red", "Red", "Purple"),
Feedback(IN_PROGRESS, WHITE)
),
"it gives a white peg for each code peg on a wrong position" to Triple(
Code("Red", "Green", "Blue", "Red"),
Code("Purple", "Red", "Red", "Purple"),
Feedback(IN_PROGRESS, WHITE, WHITE)
)
).dynamicTestsFor(block)
@Test
fun `the game is won if the secret is guessed`() {
val game = gameOf(GameStarted(gameId, secret, totalAttempts, availablePegs))
execute(MakeGuess(gameId, secret), game) shouldSucceedWith listOf(
GuessMade(
gameId, Guess(
secret, Feedback(
WON, BLACK, BLACK, BLACK, BLACK
)
)
),
GameWon(gameId)
)
}
@Test
fun `the game can no longer be played once it's won`() {
val game = gameOf(GameStarted(gameId, secret, totalAttempts, availablePegs))
val update = execute(MakeGuess(gameId, secret), game)
val updatedGame = game.updated(update)
execute(MakeGuess(gameId, secret), updatedGame) shouldFailWith
GameAlreadyWon(gameId)
}
@Test
fun `the game is lost if the secret is not guessed within the number of attempts`() {
val secret = Code("Red", "Green", "Blue", "Yellow")
val wrongCode = Code("Purple", "Purple", "Purple", "Purple")
val game = gameOf(
GameStarted(gameId, secret, 3, availablePegs),
GuessMade(gameId, Guess(wrongCode, Feedback(IN_PROGRESS))),
GuessMade(gameId, Guess(wrongCode, Feedback(IN_PROGRESS))),
)
execute(MakeGuess(gameId, wrongCode), game) shouldSucceedWith listOf(
GuessMade(gameId, Guess(wrongCode, Feedback(LOST))),
GameLost(gameId)
)
}
@Test
fun `the game can no longer be played once it's lost`() {
val secret = Code("Red", "Green", "Blue", "Yellow")
val wrongCode = Code("Purple", "Purple", "Purple", "Purple")
val game = gameOf(GameStarted(gameId, secret, 1, availablePegs))
val update = execute(MakeGuess(gameId, wrongCode), game)
val updatedGame = game.updated(update)
execute(MakeGuess(gameId, secret), updatedGame) shouldFailWith
GameAlreadyLost(gameId)
}
@Test
fun `the game cannot be played if it was not started`() {
val code = Code("Red", "Purple", "Red", "Purple")
val game = notStartedGame()
execute(MakeGuess(gameId, code), game) shouldFailWith GameNotStarted(gameId)
}
@Test
fun `the guess length cannot be shorter than the secret`() {
val secret = Code("Red", "Green", "Blue", "Yellow")
val code = Code("Purple", "Purple", "Purple")
val game = gameOf(GameStarted(gameId, secret, 12, availablePegs))
execute(MakeGuess(gameId, code), game) shouldFailWith GuessTooShort(gameId, code, secret.length)
}
@Test
fun `the guess length cannot be longer than the secret`() {
val secret = Code("Red", "Green", "Blue", "Yellow")
val code = Code("Purple", "Purple", "Purple", "Purple", "Purple")
val game = gameOf(GameStarted(gameId, secret, 12, availablePegs))
execute(MakeGuess(gameId, code), game) shouldFailWith GuessTooLong(gameId, code, secret.length)
}
@Test
fun `it rejects pegs that the game was not started with`() {
val secret = Code("Red", "Green", "Blue", "Blue")
val availablePegs = setOfPegs("Red", "Green", "Blue")
val game = gameOf(GameStarted(gameId, secret, 12, availablePegs))
val guess = Code("Red", "Green", "Blue", "Yellow")
execute(MakeGuess(gameId, guess), game) shouldFailWith
InvalidPegInGuess(gameId, guess, availablePegs)
}
private fun gameOf(vararg events: GameEvent): Game = listOf(*events)
private fun Game.updated(update: Either<GameError, Game>): Game = this + update.getOrElse { emptyList() }
}
package mastermind.game
import arrow.core.*
import arrow.core.raise.either
import mastermind.game.Feedback.Outcome.*
import mastermind.game.Feedback.Peg.BLACK
import mastermind.game.Feedback.Peg.WHITE
import mastermind.game.GameCommand.JoinGame
import mastermind.game.GameCommand.MakeGuess
import mastermind.game.GameError.GameFinishedError.GameAlreadyLost
import mastermind.game.GameError.GameFinishedError.GameAlreadyWon
import mastermind.game.GameError.GuessError.*
import mastermind.game.GameEvent.*
import kotlin.collections.unzip
sealed interface GameCommand {
val gameId: GameId
data class JoinGame(
override val gameId: GameId,
val secret: Code,
val totalAttempts: Int,
val availablePegs: Set<Code.Peg>
) : GameCommand
data class MakeGuess(override val gameId: GameId, val guess: Code) : GameCommand
}
sealed interface GameEvent {
val gameId: GameId
data class GameStarted(
override val gameId: GameId,
val secret: Code,
val totalAttempts: Int,
val availablePegs: Set<Code.Peg>
) : GameEvent
data class GuessMade(override val gameId: GameId, val guess: Guess) : GameEvent
data class GameWon(override val gameId: GameId) : GameEvent
data class GameLost(override val gameId: GameId) : GameEvent
}
@JvmInline
value class GameId(val value: String)
data class Code(val pegs: List<Peg>) {
constructor(vararg pegs: Peg) : this(pegs.toList())
constructor(vararg pegs: String) : this(pegs.map(::Peg))
data class Peg(val name: String)
val length: Int get() = pegs.size
}
data class Guess(val code: Code, val feedback: Feedback)
data class Feedback(val outcome: Outcome, val pegs: List<Peg>) {
constructor(outcome: Outcome, vararg pegs: Peg) : this(outcome, pegs.toList())
enum class Peg {
BLACK, WHITE;
fun formattedName(): String = name.lowercase().replaceFirstChar(Char::uppercase)
}
enum class Outcome {
IN_PROGRESS, WON, LOST
}
}
sealed interface GameError {
val gameId: GameId
sealed interface GameFinishedError : GameError {
data class GameAlreadyWon(override val gameId: GameId) : GameFinishedError
data class GameAlreadyLost(override val gameId: GameId) : GameFinishedError
}
sealed interface GuessError : GameError {
data class GameNotStarted(override val gameId: GameId) : GuessError
data class GuessTooShort(override val gameId: GameId, val guess: Code, val requiredLength: Int) : GuessError
data class GuessTooLong(override val gameId: GameId, val guess: Code, val requiredLength: Int) : GuessError
data class InvalidPegInGuess(override val gameId: GameId, val guess: Code, val availablePegs: Set<Code.Peg>) :
GuessError
}
}
typealias Game = List<GameEvent>
private val Game.secret: Code?
get() = filterIsInstance<GameStarted>().firstOrNull()?.secret
private val Game.secretLength: Int
get() = secret?.length ?: 0
private val Game.secretPegs: List<Code.Peg>
get() = secret?.pegs ?: emptyList()
private val Game.attempts: Int
get() = filterIsInstance<GuessMade>().size
private val Game.totalAttempts: Int
get() = filterIsInstance<GameStarted>().firstOrNull()?.totalAttempts ?: 0
private val Game.availablePegs: Set<Code.Peg>
get() = filterIsInstance<GameStarted>().firstOrNull()?.availablePegs ?: emptySet()
private fun Game.isWon(): Boolean =
filterIsInstance<GameWon>().isNotEmpty()
private fun Game.isLost(): Boolean =
filterIsInstance<GameLost>().isNotEmpty()
private fun Game.isStarted(): Boolean =
filterIsInstance<GameStarted>().isNotEmpty()
private fun Game.isGuessTooShort(guess: Code): Boolean =
guess.length < this.secretLength
private fun Game.isGuessTooLong(guess: Code): Boolean =
guess.length > this.secretLength
private fun Game.isGuessValid(guess: Code): Boolean =
availablePegs.containsAll(guess.pegs)
fun execute(
command: GameCommand,
game: Game = notStartedGame()
): Either<GameError, NonEmptyList<GameEvent>> =
when (command) {
is JoinGame -> joinGame(command)
is MakeGuess -> makeGuess(command, game).withOutcome()
}
private fun joinGame(command: JoinGame) = either<Nothing, NonEmptyList<GameStarted>> {
nonEmptyListOf(GameStarted(command.gameId, command.secret, command.totalAttempts, command.availablePegs))
}
private fun makeGuess(command: MakeGuess, game: Game) =
startedNotFinishedGame(command, game).flatMap { startedGame ->
validGuess(command, startedGame).map { guess ->
GuessMade(command.gameId, Guess(command.guess, startedGame.feedbackOn(guess)))
}
}
private fun startedNotFinishedGame(command: MakeGuess, game: Game): Either<GameError, Game> {
if (!game.isStarted()) {
return GameNotStarted(command.gameId).left()
}
if (game.isWon()) {
return GameAlreadyWon(command.gameId).left()
}
if (game.isLost()) {
return GameAlreadyLost(command.gameId).left()
}
return game.right()
}
private fun validGuess(command: MakeGuess, game: Game): Either<GameError, Code> {
if (game.isGuessTooShort(command.guess)) {
return GuessTooShort(command.gameId, command.guess, game.secretLength).left()
}
if (game.isGuessTooLong(command.guess)) {
return GuessTooLong(command.gameId, command.guess, game.secretLength).left()
}
if (!game.isGuessValid(command.guess)) {
return InvalidPegInGuess(command.gameId, command.guess, game.availablePegs).left()
}
return command.guess.right()
}
private fun Either<GameError, GuessMade>.withOutcome(): Either<GameError, NonEmptyList<GameEvent>> =
map { event ->
nonEmptyListOf<GameEvent>(event) +
when (event.guess.feedback.outcome) {
WON -> listOf(GameWon(event.gameId))
LOST -> listOf(GameLost(event.gameId))
else -> emptyList()
}
}
private fun Game.feedbackOn(guess: Code): Feedback =
feedbackPegsOn(guess)
.let { (exactHits, colourHits) ->
Feedback(outcomeFor(exactHits), exactHits + colourHits)
}
private fun Game.feedbackPegsOn(guess: Code) =
exactHits(guess).map { BLACK } to colourHits(guess).map { WHITE }
private fun Game.outcomeFor(exactHits: List<Feedback.Peg>) = when {
exactHits.size == this.secretLength -> WON
this.attempts + 1 == this.totalAttempts -> LOST
else -> IN_PROGRESS
}
private fun Game.exactHits(guess: Code): List<Code.Peg> = this.secretPegs
.zip(guess.pegs)
.filter { (secretColour, guessColour) -> secretColour == guessColour }
.unzip()
.second
private fun Game.colourHits(guess: Code): List<Code.Peg> = this.secretPegs
.zip(guess.pegs)
.filter { (secretColour, guessColour) -> secretColour != guessColour }
.unzip()
.let { (secret, guess) ->
guess.fold(secret to emptyList<Code.Peg>()) { (secretPegs, colourHits), guessPeg ->
secretPegs.remove(guessPeg)?.let { it to colourHits + guessPeg } ?: (secretPegs to colourHits)
}.second
}
/**
* Removes an element from the list and returns the new list, or null if the element wasn't found.
*/
private fun <T> List<T>.remove(item: T): List<T>? = indexOf(item).let { index ->
if (index != -1) filterIndexed { i, _ -> i != index }
else null
}
fun notStartedGame(): Game = emptyList()
fun setOfPegs(vararg pegs: String): Set<Code.Peg> = pegs.map(Code::Peg).toSet()
package mastermind.game.testkit
import mastermind.game.GameId
import mastermind.game.generateGameId
fun anyGameId(): GameId = generateGameId()
fun generateGameId(): GameId {
return GameId(UUID.randomUUID().toString())
}
package mastermind.testkit.assertions
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import org.junit.jupiter.api.Assertions.*
infix fun <A, B> Either<A, B>.shouldSucceedWith(expected: B) =
assertEquals(expected.right(), this, "${expected.right()} is $this")
infix fun <A, B> Either<A, B>.shouldFailWith(expected: A) =
assertEquals(expected.left(), this, "${expected.left()} is $this")
package mastermind.testkit.dynamictest
import org.junit.jupiter.api.DynamicTest
fun <T : Any> Map<String, T>.dynamicTestsFor(block: (T) -> Unit) =
map { (message, example: T) -> DynamicTest.dynamicTest(message) { block(example) } }
@jakzal
Copy link
Author

jakzal commented Nov 4, 2023

These are code examples for the “Functional event sourcing example in Kotlin” article:

https://dev.to/jakub_zalas/functional-event-sourcing-example-in-kotlin-3245

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment