Skip to content

Instantly share code, notes, and snippets.

@jakzal
Last active November 30, 2023 14:17
Show Gist options
  • Save jakzal/1856418f8269c3d45948e681873b42df to your computer and use it in GitHub Desktop.
Save jakzal/1856418f8269c3d45948e681873b42df to your computer and use it in GitHub Desktop.
Object-oriented event sourcing
package mastermind.game
import arrow.core.Either
import arrow.core.NonEmptyList
import arrow.core.getOrElse
import mastermind.game.Feedback.Outcome.*
import mastermind.game.Feedback.Peg.BLACK
import mastermind.game.Feedback.Peg.WHITE
import mastermind.game.Game.NotStartedGame
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`() {
NotStartedGame.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))
game.execute(MakeGuess(gameId, Code("Purple", "Purple", "Purple", "Purple"))) 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))
game.execute(MakeGuess(gameId, guess)) 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))
game.execute(MakeGuess(gameId, secret)) 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 = game.execute(MakeGuess(gameId, secret))
val updatedGame = game.updated(update)
updatedGame.execute(MakeGuess(gameId, secret)) 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))),
)
game.execute(MakeGuess(gameId, wrongCode)) 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 = game.execute(MakeGuess(gameId, wrongCode))
val updatedGame = game.updated(update)
updatedGame.execute(MakeGuess(gameId, secret)) 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()
game.execute(MakeGuess(gameId, code)) 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))
game.execute(MakeGuess(gameId, code)) 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))
game.execute(MakeGuess(gameId, code)) 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")
game.execute(MakeGuess(gameId, guess)) shouldFailWith
InvalidPegInGuess(gameId, guess, availablePegs)
}
private fun gameOf(vararg events: GameEvent): Game = events.toList().applyTo(notStartedGame())
private fun Game.updated(update: Either<GameError, NonEmptyList<GameEvent>>): Game =
update
.map { events -> events.applyTo(this) }
.getOrElse { e -> throw RuntimeException("Expected a list of events but got `$e`.") }
private fun Iterable<GameEvent>.applyTo(game: Game): Game = fold(game, Game::applyEvent)
}
package mastermind.game
import arrow.core.*
import com.fasterxml.jackson.annotation.JsonIgnore
import mastermind.game.Feedback.Outcome.*
import mastermind.game.Feedback.Peg.BLACK
import mastermind.game.Feedback.Peg.WHITE
import mastermind.game.Game.NotStartedGame
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)
@get:JsonIgnore
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
}
}
sealed interface Game {
fun applyEvent(event: GameEvent): Game
fun execute(command: GameCommand): Either<GameError, NonEmptyList<GameEvent>>
data object NotStartedGame : Game {
override fun applyEvent(event: GameEvent): Game = when (event) {
is GameStarted -> StartedGame(event.secret, 0, event.totalAttempts, event.availablePegs)
else -> this
}
override fun execute(command: GameCommand): Either<GameError, NonEmptyList<GameEvent>> = when (command) {
is JoinGame -> nonEmptyListOf(
GameStarted(command.gameId, command.secret, command.totalAttempts, command.availablePegs)
).right()
else -> GameNotStarted(command.gameId).left()
}
}
data class StartedGame(
private val secret: Code,
private val attempts: Int,
private val totalAttempts: Int,
private val availablePegs: Set<Code.Peg>
) : Game {
private val secretLength: Int = secret.length
private val secretPegs: List<Code.Peg> = secret.pegs
override fun applyEvent(event: GameEvent): Game = when (event) {
is GameStarted -> this
is GuessMade -> this.copy(attempts = this.attempts + 1)
is GameWon -> WonGame
is GameLost -> LostGame
}
override fun execute(command: GameCommand): Either<GameError, NonEmptyList<GameEvent>> = when (command) {
is MakeGuess -> validGuess(command).map { guess ->
GuessMade(command.gameId, Guess(command.guess, feedbackOn(guess)))
}.withOutcome()
else -> TODO()
}
private fun validGuess(command: MakeGuess): Either<GameError, Code> {
if (isGuessTooShort(command.guess)) {
return GuessTooShort(command.gameId, command.guess, secretLength).left()
}
if (isGuessTooLong(command.guess)) {
return GuessTooLong(command.gameId, command.guess, secretLength).left()
}
if (!isGuessValid(command.guess)) {
return InvalidPegInGuess(command.gameId, command.guess, availablePegs).left()
}
return command.guess.right()
}
private fun isGuessTooShort(guess: Code): Boolean =
guess.length < secretLength
private fun isGuessTooLong(guess: Code): Boolean =
guess.length > secretLength
private fun isGuessValid(guess: Code): Boolean =
availablePegs.containsAll(guess.pegs)
private fun feedbackOn(guess: Code): Feedback =
feedbackPegsOn(guess)
.let { (exactHits, colourHits) ->
Feedback(outcomeFor(exactHits), exactHits + colourHits)
}
private fun feedbackPegsOn(guess: Code) =
exactHits(guess).map { BLACK } to colourHits(guess).map { WHITE }
private fun outcomeFor(exactHits: List<Feedback.Peg>) = when {
exactHits.size == this.secretLength -> WON
this.attempts + 1 == this.totalAttempts -> LOST
else -> IN_PROGRESS
}
private fun exactHits(guess: Code): List<Code.Peg> = this.secretPegs
.zip(guess.pegs)
.filter { (secretColour, guessColour) -> secretColour == guessColour }
.unzip()
.second
private fun 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
}
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()
}
}
}
data object WonGame : Game {
override fun applyEvent(event: GameEvent): Game = this
override fun execute(command: GameCommand): Either<GameError, NonEmptyList<GameEvent>> =
GameAlreadyWon(command.gameId).left()
}
data object LostGame : Game {
override fun applyEvent(event: GameEvent): Game = this
override fun execute(command: GameCommand): Either<GameError, NonEmptyList<GameEvent>> =
GameAlreadyLost(command.gameId).left()
}
}
// fun execute(
// command: GameCommand,
// game: Game = notStartedGame()
// ): Either<GameError, NonEmptyList<GameEvent>> =
// game.execute(command)
/**
* 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 = NotStartedGame
fun setOfPegs(vararg pegs: String): Set<Code.Peg> = pegs.map(Code::Peg).toSet()
val game = events.fold(notStartedGame(), Game::applyEvent)
val handler = handlerFor(
{ command, game -> game.execute(command) },
Game::applyEvent,
::notStartedGame
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment