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://dev.to/jakub_zalas/functional-event-sourcing-example-in-kotlin-3245