diff --git a/README.md b/README.md index e1c7c927d8..381ea94c52 100644 --- a/README.md +++ b/README.md @@ -1 +1,37 @@ -# kotlin-blackjack \ No newline at end of file +# kotlin-blackjack + +## 기능 요구사항 +블랙잭 게임을 변형한 프로그램을 구현한다. 블랙잭 게임은 딜러와 플레이어 중 카드의 합이 21 또는 21에 가장 가까운 숫자를 가지는 쪽이 이기는 게임이다. + + - 카드의 숫자 계산은 카드 숫자를 기본으로 하며, 예외로 Ace는 1 또는 11로 계산할 수 있으며, King, Queen, Jack은 각각 10으로 계산한다. + - 게임을 시작하면 플레이어는 두 장의 카드를 지급 받으며, 두 장의 카드 숫자를 합쳐 21을 초과하지 않으면서 21에 가깝게 만들면 이긴다. 21을 넘지 않을 경우 원한다면 얼마든지 카드를 계속 뽑을 수 있다. + +## 프로그래밍 요구 사항 + - 모든 기능을 TDD로 구현해 단위 테스트가 존재해야 한다. 단, UI(System.out, System.in) 로직은 제외 + - indent(인덴트, 들여쓰기) depth를 2를 넘지 않도록 구현한다. 1까지만 허용한다. + - 모든 엔티티를 작게 유지한다. + - 함수(또는 메서드)의 길이가 15라인을 넘어가지 않도록 구현한다. + - 기능을 구현하기 전에 README.md 파일에 구현할 기능 목록을 정리해 추가한다. + - git의 commit 단위는 앞 단계에서 README.md 파일에 정리한 기능 목록 단위로 추가한다. + +## 구현할 기능 목록 +### 점수 + - [x] 숫자 카드는 카드의 숫자를 점수로 계산한다 + - [x] Ace 카드는 1 또는 11로 점수 계산 가능하다 + - [x] King, Queen, Jack은 10으로 계산한다 + +### 시작 + - [x] 입력한 이름을 기준으로 게임에 플레이어가 정해진다 (플레이어는 유일한 이름을 가진다) + +### 카드 분배 + - [x] 플레이어는 최초에 카드 2장을 분배 받는다 + - [x] 카드 분배는 최초 한 번 이루어진다 + +### 카드 추가 + - [x] 보유한 카드의 점수 합이 블랙잭 기준치(21) 미만이면 추가로 카드를 받을 수 있는 상태이다 + +### 결과 + - [x] 만들 수 있는 최종 점수가 모두 블랙잭 기준치(21)를 넘는다면 버스트이다과 + - [x] 버스트인 경우, 최종 점수는 모든 카드를 더해서 만들 수 있는 가장 작은 수이다 + - [x] 버스트가 아닌 경우, 최종 점수는 모든 카드를 더해서 블랙잭 기준치(21) 이하에서 만들 수 있는 가장 큰 수이다 + - [x] 최종 점수가 블랙잭 기준치(21)이고 가진 카드가 2장이면 블랙잭이다 diff --git a/src/main/kotlin/.gitkeep b/src/main/kotlin/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/main/kotlin/blackjack/Main.kt b/src/main/kotlin/blackjack/Main.kt new file mode 100644 index 0000000000..c5a5ecb8bb --- /dev/null +++ b/src/main/kotlin/blackjack/Main.kt @@ -0,0 +1,10 @@ +package blackjack + +import blackjack.controller.BlackjackController + +fun main() { + val controller = BlackjackController() + val game = controller.startGame() + repeat(game.players.values.size) { controller.progressPlayerPhase(game) } + controller.progressEndPhase(game) +} diff --git a/src/main/kotlin/blackjack/controller/BlackjackController.kt b/src/main/kotlin/blackjack/controller/BlackjackController.kt new file mode 100644 index 0000000000..1874d73924 --- /dev/null +++ b/src/main/kotlin/blackjack/controller/BlackjackController.kt @@ -0,0 +1,38 @@ +package blackjack.controller + +import blackjack.domain.Game +import blackjack.domain.Phase +import blackjack.view.InputView +import blackjack.view.OutputView + +class BlackjackController { + fun startGame(): Game { + val names = InputView.getNames() + val game = Game(names) + game.players.values.forEach { + OutputView.printGameInitialization(game) + OutputView.printPlayerAndCard(it) + } + return game + } + + fun progressPlayerPhase(game: Game) { + val phase = game.checkAndGetPhase(Phase.PlayerPhase::class) + while (!phase.isFinish()) { + when (InputView.getHitOrStay(phase.player.name)) { + "y" -> phase.player.hit(game.deck) + "n" -> phase.player.stay() + else -> throw IllegalArgumentException("only 'y' or 'n' can be entered") + } + OutputView.printPlayerAndCard(phase.player) + } + game.moveToNextPhase() + } + + fun progressEndPhase(game: Game) { + val phase = game.checkAndGetPhase(Phase.EndPhase::class) + phase.players.values.forEach { + OutputView.printPlayerAndCardAndScore(it) + } + } +} diff --git a/src/main/kotlin/blackjack/domain/Card.kt b/src/main/kotlin/blackjack/domain/Card.kt new file mode 100644 index 0000000000..a64c086dab --- /dev/null +++ b/src/main/kotlin/blackjack/domain/Card.kt @@ -0,0 +1,57 @@ +package blackjack.domain + +data class Card(val suit: Suit, val rank: Rank) { + val possibleScore: PossibleScore = PossibleScore.getPossibleScore(rank) + + fun getPossibleScoreSums(score: Score): List = possibleScore.getPossibleScoreSums(score) +} + +enum class Suit { + HEART, + DIAMOND, + SPADE, + CLUB; +} + +enum class Rank(val number: Int) { + ACE(1), + TWO(2), + THREE(3), + FOUR(4), + FIVE(5), + SIX(6), + SEVEN(7), + EIGHT(8), + NINE(9), + TEN(10), + JACK(11), + QUEEN(12), + KING(13); +} + +@JvmInline +value class PossibleScore(private val values: Set) { + + fun getPossibleScoreSums(score: Score): List = values.map { score + it } + + companion object { + fun getPossibleScore(rank: Rank) = when (rank) { + Rank.ACE -> listOf(1, 11) + Rank.TWO -> listOf(2) + Rank.THREE -> listOf(3) + Rank.FOUR -> listOf(4) + Rank.FIVE -> listOf(5) + Rank.SIX -> listOf(6) + Rank.SEVEN -> listOf(7) + Rank.EIGHT -> listOf(8) + Rank.NINE -> listOf(9) + Rank.TEN -> listOf(10) + Rank.JACK -> listOf(10) + Rank.QUEEN -> listOf(10) + Rank.KING -> listOf(10) + } + .map(Score::from) + .toSet() + .let(::PossibleScore) + } +} diff --git a/src/main/kotlin/blackjack/domain/Deck.kt b/src/main/kotlin/blackjack/domain/Deck.kt new file mode 100644 index 0000000000..fec6e35ab1 --- /dev/null +++ b/src/main/kotlin/blackjack/domain/Deck.kt @@ -0,0 +1,22 @@ +package blackjack.domain + +@JvmInline +value class Deck(private val cards: MutableSet) { + fun draw(): Card { + check(cards.isNotEmpty()) { "no cards left" } + val card = cards.random() + cards.remove(card) + return card + } + + companion object { + private val preparedDeck: Set = + Suit.values().flatMap { suit -> + Rank.values().map { rank -> + Card(suit = suit, rank = rank) + } + }.toSet() + + fun getDeck(): Deck = preparedDeck.toMutableSet().let(::Deck) + } +} diff --git a/src/main/kotlin/blackjack/domain/Game.kt b/src/main/kotlin/blackjack/domain/Game.kt new file mode 100644 index 0000000000..36b1a1aad7 --- /dev/null +++ b/src/main/kotlin/blackjack/domain/Game.kt @@ -0,0 +1,32 @@ +package blackjack.domain + +import kotlin.reflect.KClass + +class Game(val players: Players, val deck: Deck = Deck.getDeck()) { + + private val phases: List + private var phaseIndex: Int = 0 + private val phase + get() = phases[phaseIndex] + + constructor(names: List) : this(names.map(::Player).let(::Players)) + + init { + players.values.forEach { + it.initialize(deck) + } + phases = players.values.map(Phase::PlayerPhase) + players.let(Phase::EndPhase) + } + + fun moveToNextPhase() { + check(phase.isFinish()) { "current phase is not over" } + check(phaseIndex < phases.size) { "there is no more next phase" } + phaseIndex++ + } + + fun checkAndGetPhase(phaseType: KClass): T { + check(phaseType.isInstance(phase)) { "not ${phaseType::class.java.name}" } + return phase as T + } + +} diff --git a/src/main/kotlin/blackjack/domain/Hand.kt b/src/main/kotlin/blackjack/domain/Hand.kt new file mode 100644 index 0000000000..cf535c503e --- /dev/null +++ b/src/main/kotlin/blackjack/domain/Hand.kt @@ -0,0 +1,34 @@ +package blackjack.domain + +@JvmInline +value class Hand(val cards: MutableList = mutableListOf()) { + + fun add(card: Card) = cards.add(card) + + fun getCardCount() = cards.size + + fun getBestScore(): Score { + val scoreSums = mutableSetOf() + calculateScoreSums(cards.toList(), 0, Score.from(0), scoreSums) + return when (scoreSums.minBy { it.value }.value <= Rule.BLACKJACK_SCORE) { + true -> { + scoreSums + .filter { it.value <= Rule.BLACKJACK_SCORE } + .maxBy { it.value } + } + false -> { + scoreSums.minBy { it.value } + } + } + } + + private fun calculateScoreSums(cards: List, index: Int, sum: Score, sums: MutableSet) { + if (index == cards.size) { + sums.add(sum) + return + } + cards[index].getPossibleScoreSums(sum).forEach { + calculateScoreSums(cards, index + 1, it, sums) + } + } +} diff --git a/src/main/kotlin/blackjack/domain/Phase.kt b/src/main/kotlin/blackjack/domain/Phase.kt new file mode 100644 index 0000000000..86f76b4835 --- /dev/null +++ b/src/main/kotlin/blackjack/domain/Phase.kt @@ -0,0 +1,13 @@ +package blackjack.domain + +sealed interface Phase { + fun isFinish(): Boolean + + class PlayerPhase(val player: Player) : Phase { + override fun isFinish(): Boolean = player.resolved() + } + + class EndPhase(val players: Players) : Phase { + override fun isFinish(): Boolean = true + } +} diff --git a/src/main/kotlin/blackjack/domain/Player.kt b/src/main/kotlin/blackjack/domain/Player.kt new file mode 100644 index 0000000000..e9b78a3886 --- /dev/null +++ b/src/main/kotlin/blackjack/domain/Player.kt @@ -0,0 +1,67 @@ +package blackjack.domain + +data class Player(val name: String, val hand: Hand = Hand()) { + var state: PlayerState = PlayerState.READY + private set + + val resultScore: Score by lazy { decideScore() } + + fun initialize(deck: Deck) { + check(state == PlayerState.READY) { "can only 'init' if the 'PlayerState' is 'READY'" } + repeat(Rule.INIT_CARD_COUNT) { hand.add(deck.draw()) } + state = PlayerState.UNDER + } + + fun hit(deck: Deck) { + check(active()) { "can only 'hit' if the 'PlayerState' is 'UNDER'" } + hand.add(deck.draw()) + updateState() + } + + fun stay() { + check(active()) { "can only 'stay' if the 'PlayerState' is 'UNDER'" } + state = PlayerState.STAND + } + + fun updateState() { + val score = hand.getBestScore() + val count = hand.getCardCount() + state = when { + score.value < Rule.BLACKJACK_SCORE -> PlayerState.UNDER + score.value > Rule.BLACKJACK_SCORE -> PlayerState.BUST + else -> when (count == Rule.BLACKJACK_CARD_COUNT) { + true -> PlayerState.BLACKJACK + false -> PlayerState.STAND + } + } + } + + fun ready() = state == PlayerState.READY + + fun active() = state == PlayerState.UNDER + + fun resolved() = state == PlayerState.STAND || state == PlayerState.BLACKJACK || state == PlayerState.BUST + + private fun decideScore(): Score { + check(resolved()) { "can't decide score until the action is over" } + return hand.getBestScore() + } +} + +@JvmInline +value class Players(val values: List) { + + constructor(vararg names: String) : this(names.map { Player(it) }) + + init { + require(values.size == values.map { it.name }.toSet().size) { "duplicate name has been used" } + } +} + +enum class PlayerState { + READY, + UNDER, + STAND, + BLACKJACK, + BUST; +} diff --git a/src/main/kotlin/blackjack/domain/Rule.kt b/src/main/kotlin/blackjack/domain/Rule.kt new file mode 100644 index 0000000000..e851ba65b9 --- /dev/null +++ b/src/main/kotlin/blackjack/domain/Rule.kt @@ -0,0 +1,7 @@ +package blackjack.domain + +object Rule { + const val BLACKJACK_SCORE = 21 + const val BLACKJACK_CARD_COUNT = 2 + const val INIT_CARD_COUNT = 2 +} diff --git a/src/main/kotlin/blackjack/domain/Score.kt b/src/main/kotlin/blackjack/domain/Score.kt new file mode 100644 index 0000000000..b256467c88 --- /dev/null +++ b/src/main/kotlin/blackjack/domain/Score.kt @@ -0,0 +1,16 @@ +package blackjack.domain + + +@JvmInline +value class Score private constructor(val value: Int) { + + operator fun plus(score: Score) = from(value + score.value) + + companion object { + private const val MIN_SCORE = 0 + private const val MAX_SCORE = 100 + private val preparedScores = (MIN_SCORE..MAX_SCORE).associateWith(::Score) + + fun from(value: Int): Score = preparedScores[value] ?: Score(value) + } +} diff --git a/src/main/kotlin/blackjack/view/InputView.kt b/src/main/kotlin/blackjack/view/InputView.kt new file mode 100644 index 0000000000..dea410a33f --- /dev/null +++ b/src/main/kotlin/blackjack/view/InputView.kt @@ -0,0 +1,13 @@ +package blackjack.view + +object InputView { + fun getNames(): List { + println("게임에 참여할 사람의 이름을 입력하세요.(쉼표 기준으로 분리)") + return readln().split(",") + } + + fun getHitOrStay(name: String): String { + println("${name}은(는) 한장의 카드를 더 받겠습니까?(예는 y, 아니오는 n)") + return readln() + } +} diff --git a/src/main/kotlin/blackjack/view/OutputView.kt b/src/main/kotlin/blackjack/view/OutputView.kt new file mode 100644 index 0000000000..70c8fda6ad --- /dev/null +++ b/src/main/kotlin/blackjack/view/OutputView.kt @@ -0,0 +1,50 @@ +package blackjack.view + +import blackjack.domain.Game +import blackjack.domain.Hand +import blackjack.domain.Player +import blackjack.domain.Rank +import blackjack.domain.Suit + +object OutputView { + fun printGameInitialization(game: Game) { + println("${game.players.values.joinToString(separator = ", ") { it.name }}에게 2장씩 나누었습니다.") + game.players.values.forEach { + printPlayerAndCard(it) + } + } + + fun printPlayerAndCard(player: Player) { + println("${player.name}카드: ${player.hand.makeForm()}") + } + + fun printPlayerAndCardAndScore(player: Player) { + println("${player.name}카드: ${player.hand.makeForm()} - 결과: ${player.resultScore.value}") + } + + private fun Hand.makeForm() = + this.cards.joinToString(separator = ", ") { "${it.rank.makeForm()}${it.suit.makeForm()}" } + + private fun Rank.makeForm() = when (this) { + Rank.ACE -> "A" + Rank.TWO -> "2" + Rank.THREE -> "3" + Rank.FOUR -> "4" + Rank.FIVE -> "5" + Rank.SIX -> "6" + Rank.SEVEN -> "7" + Rank.EIGHT -> "8" + Rank.NINE -> "9" + Rank.TEN -> "10" + Rank.JACK -> "J" + Rank.QUEEN -> "Q" + Rank.KING -> "K" + } + + private fun Suit.makeForm() = when (this) { + Suit.CLUB -> "클로버" + Suit.DIAMOND -> "다이아몬드" + Suit.HEART -> "하트" + Suit.SPADE -> "스페이드" + } +} diff --git a/src/test/kotlin/.gitkeep b/src/test/kotlin/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/test/kotlin/blackjack/domain/CardTest.kt b/src/test/kotlin/blackjack/domain/CardTest.kt new file mode 100644 index 0000000000..13271ac101 --- /dev/null +++ b/src/test/kotlin/blackjack/domain/CardTest.kt @@ -0,0 +1,57 @@ +package blackjack.domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +class CardTest { + @ParameterizedTest + @MethodSource("provideAceCards") + fun `Ace 카드는 1 또는 11로 점수 계산 가능하다`(card: Card) { + val expectedPossibleScore = PossibleScore(setOf(Score.from(1), Score.from(11))) + assertEquals(expectedPossibleScore, card.possibleScore) + } + + @ParameterizedTest + @MethodSource("provideNumberCards") + fun `숫자 카드는 카드의 숫자를 점수로 계산한다`(card: Card) { + val expectedPossibleScore = PossibleScore(setOf(Score.from(card.rank.number))) + assertEquals(expectedPossibleScore, card.possibleScore) + } + + @ParameterizedTest + @MethodSource("provideCourtCards") + fun `King, Queen, Jack은 10으로 계산한다`(card: Card) { + val expectedPossibleScore = PossibleScore(setOf(Score.from(10))) + assertEquals(expectedPossibleScore, card.possibleScore) + } + + companion object { + @JvmStatic + fun provideAceCards() = makeCards( + Suit.values().toList(), + listOf(Rank.ACE) + ).map { Arguments.of(it) } + + @JvmStatic + fun provideNumberCards() = makeCards( + Suit.values().toList(), + listOf(Rank.TWO, Rank.THREE, Rank.FOUR, Rank.FIVE, Rank.SIX, Rank.SEVEN, Rank.EIGHT, Rank.NINE, Rank.TEN) + ).map { Arguments.of(it) } + + @JvmStatic + fun provideCourtCards() = makeCards( + Suit.values().toList(), + listOf(Rank.JACK, Rank.QUEEN, Rank.KING) + ).map { Arguments.of(it) } + + private fun makeCards(suits: List, ranks: List): List { + return suits.flatMap { suit -> + ranks.map { rank -> + Card(suit, rank) + } + } + } + } +} diff --git a/src/test/kotlin/blackjack/domain/HandTest.kt b/src/test/kotlin/blackjack/domain/HandTest.kt new file mode 100644 index 0000000000..a2a4083a32 --- /dev/null +++ b/src/test/kotlin/blackjack/domain/HandTest.kt @@ -0,0 +1,63 @@ +package blackjack.domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +class HandTest { + + @ParameterizedTest + @MethodSource("provideBustScore") + fun `버스트인 경우, 최종 점수는 모든 카드를 더해서 만들 수 있는 가장 작은 수이다`(cards: MutableList, expectedScoreValue: Int) { + val hand = cards.let(::Hand) + val expectedScore = Score.from(expectedScoreValue) + assertEquals(expectedScore, hand.getBestScore()) + } + + @ParameterizedTest + @MethodSource("provideBestScore") + fun `버스트가 아닌 경우, 최종 점수는 모든 카드를 더해서 블랙잭 기준치(21) 이하에서 만들 수 있는 가장 큰 수이다`( + cards: MutableList, + expectedScoreValue: Int + ) { + val hand = cards.let(::Hand) + val expectedScore = Score.from(expectedScoreValue) + assertEquals(expectedScore, hand.getBestScore()) + } + + companion object { + @JvmStatic + fun provideBustScore() = listOf( + Arguments.of( + mutableListOf( + Card(Suit.HEART, Rank.JACK), + Card(Suit.HEART, Rank.QUEEN), + Card(Suit.HEART, Rank.ACE), + Card(Suit.CLUB, Rank.TWO) + ), + 23 + ), + Arguments.of( + mutableListOf(Card(Suit.HEART, Rank.JACK), Card(Suit.HEART, Rank.QUEEN), Card(Suit.CLUB, Rank.TWO)), + 22 + ), + ) + + @JvmStatic + fun provideBestScore() = listOf( + Arguments.of( + mutableListOf(Card(Suit.CLUB, Rank.ACE), Card(Suit.CLUB, Rank.NINE)), + 20 + ), + Arguments.of( + mutableListOf(Card(Suit.CLUB, Rank.ACE), Card(Suit.CLUB, Rank.ACE)), + 12 + ), + Arguments.of( + mutableListOf(Card(Suit.CLUB, Rank.ACE), Card(Suit.CLUB, Rank.JACK)), + 21 + ), + ) + } +} diff --git a/src/test/kotlin/blackjack/domain/PlayerTest.kt b/src/test/kotlin/blackjack/domain/PlayerTest.kt new file mode 100644 index 0000000000..d63ade41dd --- /dev/null +++ b/src/test/kotlin/blackjack/domain/PlayerTest.kt @@ -0,0 +1,69 @@ +package blackjack.domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class PlayerTest { + @ParameterizedTest + @ValueSource(strings = ["a,a", "a,b,a", "a,b,b"]) + fun `플레이어는 유일한 이름을 가진다`(namesInput: String) { + val names = namesInput.split(",").toTypedArray() + assertThrows { Players(*names) } + } + + @Test + fun `플레이어는 최초에 카드 2장을 분배 받는다`() { + val player: Player = Player("test") + val deck: Deck = Deck.getDeck() + player.init(deck) + assertEquals(Rule.INIT_CARD_COUNT, player.hand.getCardCount()) + } + + @Test + fun `카드 분배는 최초 한 번만 이루어진다`() { + val player: Player = Player("test") + val deck: Deck = Deck.getDeck() + player.init(deck) + assertThrows { player.init(deck) } + } + + @Test + fun `보유한 카드의 점수 합이 블랙잭 기준치(21) 미만이면 추가로 카드를 받을 수 있는 상태이다`() { + val cards: MutableList = mutableListOf( + Card(Suit.HEART, Rank.TEN), + Card(Suit.CLUB, Rank.TEN) + ) + val hand: Hand = Hand(cards) + val player: Player = Player("test", hand) + player.updateState() + assertEquals(PlayerState.UNDER, player.state) + } + + @Test + fun `만들 수 있는 최종 점수가 모두 블랙잭 기준치(21)를 넘는다면 버스트이다`() { + val cards: MutableList = mutableListOf( + Card(Suit.HEART, Rank.TEN), + Card(Suit.CLUB, Rank.TEN), + Card(Suit.CLUB, Rank.TWO), + ) + val hand: Hand = Hand(cards) + val player: Player = Player("test", hand) + player.updateState() + assertEquals(PlayerState.BUST, player.state) + } + + @Test + fun `최종 점수가 블랙잭 기준치(21)이고 가진 카드가 2장이면 블랙잭이다`() { + val cards: MutableList = mutableListOf( + Card(Suit.HEART, Rank.TEN), + Card(Suit.CLUB, Rank.ACE), + ) + val hand: Hand = Hand(cards) + val player: Player = Player("test", hand) + player.updateState() + assertEquals(PlayerState.BLACKJACK, player.state) + } +} diff --git a/src/test/kotlin/study/DslTest.kt b/src/test/kotlin/study/DslTest.kt index d9cf4cc01f..3231707af8 100644 --- a/src/test/kotlin/study/DslTest.kt +++ b/src/test/kotlin/study/DslTest.kt @@ -5,21 +5,6 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.params.provider.CsvSource import org.junit.jupiter.params.provider.ValueSource -/** - * introduce { - * name("홍길동") - * company("활빈당") - * skills { - * soft("A passion for problem solving") - * soft("Good communication skills") - * hard("Kotiln") - * } - * languages { - * "Korean" level 5 - * "English" level 3 - * } - * } - */ class DslTest { // @ParameterizedTest @ValueSource(strings = ["홍길동", "허균"])