These are code examples for the “Functional event sourcing example in Kotlin” article:
All gists:
-
Functional event sourcing example in Kotlin
These are code examples for the “Functional event sourcing example in Kotlin” article:
All gists:
Functional event sourcing example 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) } } |
These are code examples for the “Functional event sourcing example in Kotlin” article:
https://zalas.pl/functional-event-sourcing-example-in-kotlin/