diff --git a/docs/BLACKJACK.md b/docs/BLACKJACK.md index 76812cc101..86db4d9faa 100644 --- a/docs/BLACKJACK.md +++ b/docs/BLACKJACK.md @@ -53,6 +53,44 @@ jason카드: 7클로버, K스페이드 - 결과: 17 ### 3. 게임 규칙 및 결과 판정 -- [ ] 21점 초과 판정 로직: 플레이어 또는 딜러의 손패 합계가 21을 초과하면 게임에서 패배한다. -- [ ] 승리 조건 계산: 플레이어와 딜러 중 21에 가장 근접한 쪽이 승리한다. -- [ ] 게임 결과 출력: 최종 승자와 각 플레이어의 최종 손패 및 점수를 출력한다. \ No newline at end of file +- [x] 21점 초과 판정 로직: 플레이어 또는 딜러의 손패 합계가 21을 초과하면 게임에서 패배한다. +- [x] 승리 조건 계산: 플레이어와 딜러 중 21에 가장 근접한 쪽이 승리한다. +- [x] 게임 결과 출력: 최종 승자와 각 플레이어의 최종 손패 및 점수를 출력한다. + +## 딜러가 추가된 사전 구현 계획 + +- [x] 딜러는 처음에 받은 2장의 합계가 16이하이면 반드시 1장의 카드를 추가로 받아야 하고, 17점 이상이면 추가로 받을 수 없다. + - [x] 만일 딜러가 <Ace, 6>을 갖고 있다면? + - [x] 딜러는 다양한 전략을 사용할 수 있도록 한다. + +### 도메인 모델들이 결정되었다면 블랙잭 게임 구현 계획 +도메인 모듈이 뷰에게 의존하지 않도록 구현할 것이다. + +- [x] **게임 초기화 상태 확인** + - [x] blackjackGame이 게임을 초기화해야 하는 상태인지 확인 + - [x] 필요한 경우, blackjackGame에 필요한 정보를 사용자로부터 받아 전달 + +- [x] **플레이어의 턴 상태 확인 (첫 번째)** + - [x] blackjackGame이 플레이어들의 턴인 상태인지 확인 + - [x] 현재 blackjackGame의 플레이어 중 누구의 턴인지 확인 + - [x] 해당 플레이어가 카드를 뽑을 것인지 말 것인지를 사용자로부터 받아 전달 + +- [x] **플레이어의 턴 상태 재확인 (두 번째)** + - [x] 다시 blackjackGame이 플레이어들의 턴인 상태인지 확인 + - [x] 다시 현재 플레이어 중 누구의 턴인지 확인 + - [x] 해당 플레이어가 카드를 뽑을 것인지 말 것인지를 사용자로부터 받아 전달 + +- [x] **마지막 플레이어의 턴과 딜러의 턴 상태 전환** + - [x] 마지막 플레이어의 턴에서 더 이상 카드를 뽑을 수 없거나, 뽑지 않겠다는 결정이 내려진 경우 확인 + - [x] blackjackGame의 상태를 딜러의 턴 상태로 전환 + +- [x] **딜러의 턴 상태 확인** + - [x] blackjackGame이 딜러의 턴인 상태인지 확인 + - [x] 딜러가 카드를 뽑을지 안 뽑을지 결정하고 사용자에게 알림 + - [x] blackjackGame을 `End` 상태로 전환 + +- [x] **End 상태 확인** + - [x] blackjackGame이 End 상태인지 확인 + - [x] blackjackGame에게 BlackjackResult를 요청하고 사용자에게 보여줌 + +- [x] **게임 종료** diff --git a/domain/src/main/kotlin/action/BlackJackAction.kt b/domain/src/main/kotlin/action/BlackJackAction.kt new file mode 100644 index 0000000000..7fc6b271d6 --- /dev/null +++ b/domain/src/main/kotlin/action/BlackJackAction.kt @@ -0,0 +1,6 @@ +package action + +enum class BlackJackAction { + HIT, + STAND, +} diff --git a/domain/src/main/kotlin/blackjack/BlackjackParticipant.kt b/domain/src/main/kotlin/blackjack/BlackjackParticipant.kt new file mode 100644 index 0000000000..a5e46cb21c --- /dev/null +++ b/domain/src/main/kotlin/blackjack/BlackjackParticipant.kt @@ -0,0 +1,9 @@ +package blackjack + +import blackjack.card.Card + +interface BlackjackParticipant { + fun receiveCard(card: Card): BlackjackParticipant + fun receiveCard(cards: List<Card>): BlackjackParticipant + fun calculateBestValue(): Int +} diff --git a/domain/src/main/kotlin/blackjack/dealer/Dealer.kt b/domain/src/main/kotlin/blackjack/dealer/Dealer.kt new file mode 100644 index 0000000000..c7938dd491 --- /dev/null +++ b/domain/src/main/kotlin/blackjack/dealer/Dealer.kt @@ -0,0 +1,26 @@ +package blackjack.dealer + +import action.BlackJackAction +import blackjack.BlackjackParticipant +import blackjack.card.Card +import blackjack.deck.Deck +import blackjack.hand.Hand +import blackjack.hand.StandardHand + +data class Dealer( + val dealerStrategy: DealerStrategy = DefaultDealerStrategy(), + private val hand: Hand = StandardHand(), +) : BlackjackParticipant { + + val cards: List<Card> get() = hand.cards() + + override fun receiveCard(card: Card): Dealer = copy(hand = hand.addCard(card)) + + override fun receiveCard(cards: List<Card>): Dealer = copy(hand = hand.addCard(cards)) + + override fun calculateBestValue(): Int = hand.calculateBestValue() + + fun decideAction(deck: Deck): BlackJackAction { + return dealerStrategy.decideAction(hand, deck) + } +} diff --git a/domain/src/main/kotlin/blackjack/dealer/DealerStrategy.kt b/domain/src/main/kotlin/blackjack/dealer/DealerStrategy.kt new file mode 100644 index 0000000000..016af3f4ef --- /dev/null +++ b/domain/src/main/kotlin/blackjack/dealer/DealerStrategy.kt @@ -0,0 +1,9 @@ +package blackjack.dealer + +import action.BlackJackAction +import blackjack.deck.Deck +import blackjack.hand.Hand + +interface DealerStrategy { + fun decideAction(hand: Hand, deck: Deck): BlackJackAction +} diff --git a/domain/src/main/kotlin/blackjack/dealer/DefaultDealerStrategy.kt b/domain/src/main/kotlin/blackjack/dealer/DefaultDealerStrategy.kt new file mode 100644 index 0000000000..1d0815f04b --- /dev/null +++ b/domain/src/main/kotlin/blackjack/dealer/DefaultDealerStrategy.kt @@ -0,0 +1,37 @@ +package blackjack.dealer + +import action.BlackJackAction +import blackjack.card.Card +import blackjack.card.CardRank +import blackjack.deck.Deck +import blackjack.hand.Hand + +internal class DefaultDealerStrategy : DealerStrategy { + override fun decideAction(hand: Hand, deck: Deck): BlackJackAction { + val dealerScore = hand.calculateBestValue() + val dealerMinScore = hand.calculateMinValue() + + val bustingProbability = maxOf( + calculateProbabilityOfBusting(dealerScore, deck), + calculateProbabilityOfBusting(dealerMinScore, deck) + ) + + return if (bustingProbability > 0.5) BlackJackAction.STAND else BlackJackAction.HIT + } + + private fun calculateProbabilityOfBusting(currentScore: Int, deck: Deck): Double { + val remainedScore = 21 - currentScore + val safeCards = deck.remainingCards.count { isSafe(it, remainedScore) } + + return 1.0 - safeCards.toDouble() / deck.remainingCards.size + } + + private fun isSafe(card: Card, remainedScore: Int): Boolean { + val cardValue = when (card.rank) { + CardRank.KING, CardRank.QUEEN, CardRank.JACK -> 10 + CardRank.ACE -> 11 + else -> card.rank.ordinal + 1 + } + return cardValue <= remainedScore + } +} diff --git a/domain/src/main/kotlin/blackjack/deck/Deck.kt b/domain/src/main/kotlin/blackjack/deck/Deck.kt index d3ae3bc5f3..ccb068e60d 100644 --- a/domain/src/main/kotlin/blackjack/deck/Deck.kt +++ b/domain/src/main/kotlin/blackjack/deck/Deck.kt @@ -1,7 +1,7 @@ package blackjack.deck import blackjack.card.Card -import java.util.Stack +import java.util.* class Deck( cardProvider: CardProvider = StandardCardProvider(), @@ -11,11 +11,16 @@ class Deck( addAll(cardShuffler.shuffle(cardProvider.provideCards())) } - val size - get() = cards.size + val remainingCards: List<Card> + get() = cards.toList() fun drawCard(): Card { check(cards.isNotEmpty()) { "덱에 카드가 없으면 카드를 뽑을 수 없습니다." } return cards.pop() } + + fun drawCard(count: Int): List<Card> { + check(cards.size >= count) { "덱에 $count 만큼 카드가 없습니다." } + return List(count) { cards.pop() } + } } diff --git a/domain/src/main/kotlin/blackjack/deck/RandomCardShuffler.kt b/domain/src/main/kotlin/blackjack/deck/RandomCardShuffler.kt index 40a69d5e6a..981d9ab18f 100644 --- a/domain/src/main/kotlin/blackjack/deck/RandomCardShuffler.kt +++ b/domain/src/main/kotlin/blackjack/deck/RandomCardShuffler.kt @@ -2,6 +2,6 @@ package blackjack.deck import blackjack.card.Card -class RandomCardShuffler : CardShuffler { +internal class RandomCardShuffler : CardShuffler { override fun shuffle(cards: List<Card>): List<Card> = cards.shuffled() } diff --git a/domain/src/main/kotlin/blackjack/deck/StandardCardProvider.kt b/domain/src/main/kotlin/blackjack/deck/StandardCardProvider.kt index ba77587382..1939a173e0 100644 --- a/domain/src/main/kotlin/blackjack/deck/StandardCardProvider.kt +++ b/domain/src/main/kotlin/blackjack/deck/StandardCardProvider.kt @@ -4,7 +4,7 @@ import blackjack.card.Card import blackjack.card.CardRank import blackjack.card.CardSuit -class StandardCardProvider : CardProvider { +internal class StandardCardProvider : CardProvider { override fun provideCards(): List<Card> = CardSuit.values().flatMap { suit -> CardRank.values().map { rank -> Card(suit, rank) } diff --git a/domain/src/main/kotlin/blackjack/game/BlackjackGame.kt b/domain/src/main/kotlin/blackjack/game/BlackjackGame.kt new file mode 100644 index 0000000000..fba04b359a --- /dev/null +++ b/domain/src/main/kotlin/blackjack/game/BlackjackGame.kt @@ -0,0 +1,147 @@ +package blackjack.game + +import action.BlackJackAction +import blackjack.BlackjackParticipant +import blackjack.card.Card +import blackjack.dealer.Dealer +import blackjack.dealer.DealerStrategy +import blackjack.dealer.DefaultDealerStrategy +import blackjack.deck.Deck +import blackjack.player.Player + +class BlackjackGame private constructor( + players: List<Player>, + dealer: Dealer = Dealer(), + private val deck: Deck = Deck(), +) { + init { + require(players.toSet().isNotEmpty()) { "플레이어가 최소 한 명은 존재해야 합니다." } + } + + var state: GameState = GameState.InitialDeal(players, dealer) + private set + + val players: List<Player> get() = state.players + val dealer: Dealer get() = state.dealer + + fun dealInitialCards() { + check(state is GameState.InitialDeal) { "Initial Deal 상태가 아닙니다." } + val nPlayers = List(players.size) { players[it].receiveCard(deck.drawCard(2)) } + val nDealer = dealer.receiveCard(deck.drawCard(2)) + state = GameState.PlayerTurn(nPlayers, nDealer, currentPlayerIndex = 0) + } + + fun dealPlayerTurn(player: Player, isDeal: Boolean) { + val playerTurnState = state as? GameState.PlayerTurn ?: throw IllegalStateException("Player Turn이 아닙니다.") + require(players.contains(player)) { "${player.name}이라는 플레이어는 없습니다." } + require(player == playerTurnState.currentPlayer) { "현재 턴은 ${player.name}의 턴이 아닙니다." } + + if (isDeal.not()) { + // 다음 플레이어로 넘어감 + moveToNextPlayerOrDealerTurn(playerTurnState.currentPlayerIndex) + } else { + // 카드 받기 + check(player.canHit() == BlackJackAction.HIT) { "해당 플레이어는 더 이상 카드를 받을 수 없습니다." } + val nPlayers = players.map { if (it == player) it.receiveCard(deck.drawCard()) else it } + state = GameState.PlayerTurn(nPlayers, dealer, playerTurnState.currentPlayerIndex) + } + } + + fun dealDealerTurn(): BlackJackAction { + check(state is GameState.DealerTurn) { "Dealer Turn이 아닙니다." } + val dealerAction = dealer.decideAction(deck) + return if (dealerAction == BlackJackAction.HIT) { + val drawnCard = deck.drawCard() + state = GameState.End(players, dealer.receiveCard(drawnCard)) + BlackJackAction.HIT + } else { + state = GameState.End(players, dealer) + BlackJackAction.STAND + } + } + + fun calculateResult(): Map<BlackjackParticipant, BlackjackResult> { + val results = mutableMapOf<BlackjackParticipant, BlackjackResult>() + results[dealer] = calculateDealerResult() + players.forEach { results[it] = calculatePlayerResult(it) } + return results + } + + fun showPlayerCards(playerName: String): List<Card> { + val player = state.players.find { it.name == playerName } + ?: throw IllegalArgumentException("${playerName}이라는 플레이어는 없습니다.") + return player.cards + } + + private fun calculateDealerResult(): BlackjackResult { + val dealerScore = dealer.calculateBestValue() + var win = 0 + var loss = 0 + players.forEach { + if (dealerScore > 21) loss++ + else if (it.calculateBestValue() > 21) win++ + else if (dealerScore > it.calculateBestValue()) win++ + else if (dealerScore <= it.calculateBestValue()) loss++ + } + return BlackjackResult(win, loss) + } + + private fun calculatePlayerResult(player: Player): BlackjackResult { + val playerScore = player.calculateBestValue() + val dealerScore = dealer.calculateBestValue() + return if (dealerScore > 21) { + BlackjackResult(1, 0) + } else if (playerScore > 21) { + BlackjackResult(0, 1) + } else if (playerScore >= dealerScore) { + BlackjackResult(1, 0) + } else { + BlackjackResult(0, 1) + } + } + + private fun moveToNextPlayerOrDealerTurn(currentPlayerIndex: Int) { + val nextPlayerIndex = (currentPlayerIndex + 1) % players.size + state = if (nextPlayerIndex == 0) { + GameState.DealerTurn(players, dealer) + } else { + GameState.PlayerTurn(players, dealer, nextPlayerIndex) + } + } + + class BlackjackGameBuilder { + private val players: MutableList<Player> = mutableListOf() + private var dealerStrategy: DealerStrategy = DefaultDealerStrategy() + + fun join(name: String) { + players.add(Player(name = name)) + } + + fun join(names: List<String>) { + names.forEach { + join(it) + } + } + + fun dealerStrategy(strategy: DealerStrategyType) { + when (strategy) { + DealerStrategyType.DEFAULT_DEALER_STRATEGY -> dealerStrategy = DefaultDealerStrategy() + // 다른 전략 추가 + } + } + + fun build(): BlackjackGame { + return BlackjackGame( + players = players.toList(), + dealer = Dealer(dealerStrategy = dealerStrategy) + ) + } + } +} + +enum class DealerStrategyType { + DEFAULT_DEALER_STRATEGY +} + +fun blackjackOpen(block: BlackjackGame.BlackjackGameBuilder.() -> Unit): BlackjackGame = + BlackjackGame.BlackjackGameBuilder().apply(block).build() diff --git a/domain/src/main/kotlin/blackjack/game/BlackjackResult.kt b/domain/src/main/kotlin/blackjack/game/BlackjackResult.kt new file mode 100644 index 0000000000..9056c754e6 --- /dev/null +++ b/domain/src/main/kotlin/blackjack/game/BlackjackResult.kt @@ -0,0 +1,6 @@ +package blackjack.game + +data class BlackjackResult( + val win: Int, + val lose: Int, +) diff --git a/domain/src/main/kotlin/blackjack/game/GameState.kt b/domain/src/main/kotlin/blackjack/game/GameState.kt new file mode 100644 index 0000000000..aca64a967d --- /dev/null +++ b/domain/src/main/kotlin/blackjack/game/GameState.kt @@ -0,0 +1,33 @@ +package blackjack.game + +import blackjack.dealer.Dealer +import blackjack.player.Player + +sealed class GameState( + val players: List<Player>, + val dealer: Dealer, +) { + class InitialDeal( + players: List<Player>, + dealer: Dealer, + ) : GameState(players, dealer) + + class PlayerTurn( + players: List<Player>, + dealer: Dealer, + val currentPlayerIndex: Int, + ) : GameState(players, dealer) { + val currentPlayer: Player + get() = players[currentPlayerIndex] + } + + class DealerTurn( + players: List<Player>, + dealer: Dealer, + ) : GameState(players, dealer) + + class End( + players: List<Player>, + dealer: Dealer, + ) : GameState(players, dealer) +} diff --git a/domain/src/main/kotlin/blackjack/hand/Hand.kt b/domain/src/main/kotlin/blackjack/hand/Hand.kt index 3c57027b42..b527880bb0 100644 --- a/domain/src/main/kotlin/blackjack/hand/Hand.kt +++ b/domain/src/main/kotlin/blackjack/hand/Hand.kt @@ -1,36 +1,18 @@ package blackjack.hand import blackjack.card.Card -import blackjack.card.CardRank -data class Hand( - val cards: List<Card> = emptyList() -) { - fun addCard(card: Card): Hand = copy(cards = cards + card) - - fun calculateBestValue(): Int { - val sumWithoutAces = cards.filter { it.rank != CardRank.ACE }.sumOf { cardValue(it) } - val aceCount = cards.count { it.rank == CardRank.ACE } - return calculateBestAceValue(sumWithoutAces, aceCount) - } - - private fun cardValue(card: Card): Int = when (card.rank) { - CardRank.KING, CardRank.QUEEN, CardRank.JACK -> FACE_CARD_VALUE - else -> card.rank.ordinal + 1 - } - - private fun calculateBestAceValue(sumWithoutAces: Int, aceCount: Int): Int { - var sum = sumWithoutAces - repeat(aceCount) { - sum += if (sum + ACE_HIGH_VALUE > MAX_HAND_VALUE) ACE_LOW_VALUE else ACE_HIGH_VALUE - } - return sum - } +interface Hand { + fun cards(): List<Card> + fun addCard(card: Card): Hand + fun addCard(cards: List<Card>): Hand + fun calculateMinValue(): Int + fun calculateBestValue(): Int companion object { - private const val FACE_CARD_VALUE = 10 - private const val MAX_HAND_VALUE = 21 - private const val ACE_HIGH_VALUE = 11 - private const val ACE_LOW_VALUE = 1 + const val FACE_CARD_VALUE = 10 + const val MAX_HAND_VALUE = 21 + const val ACE_HIGH_VALUE = 11 + const val ACE_LOW_VALUE = 1 } } diff --git a/domain/src/main/kotlin/blackjack/hand/StandardHand.kt b/domain/src/main/kotlin/blackjack/hand/StandardHand.kt new file mode 100644 index 0000000000..3c245bef5c --- /dev/null +++ b/domain/src/main/kotlin/blackjack/hand/StandardHand.kt @@ -0,0 +1,51 @@ +package blackjack.hand + +import blackjack.card.Card +import blackjack.card.CardRank +import blackjack.hand.Hand.Companion.ACE_HIGH_VALUE +import blackjack.hand.Hand.Companion.ACE_LOW_VALUE +import blackjack.hand.Hand.Companion.FACE_CARD_VALUE +import blackjack.hand.Hand.Companion.MAX_HAND_VALUE + +internal class StandardHand( + val cards: Set<Card> = emptySet() +) : Hand { + override fun cards(): List<Card> = cards.toList() + + override fun addCard(card: Card): StandardHand = StandardHand(cards = cards + card) + + override fun addCard(cards: List<Card>): StandardHand = StandardHand(cards = this.cards + cards) + + override fun calculateBestValue(): Int { + val sumWithoutAces = cards.filter { it.rank != CardRank.ACE }.sumOf { cardValue(it) } + val aceCount = cards.count { it.rank == CardRank.ACE } + return calculateBestAceValue(sumWithoutAces, aceCount) + } + + override fun calculateMinValue(): Int { + val sumWithoutAces = cards.filter { it.rank != CardRank.ACE }.sumOf { cardValue(it) } + val aceCount = cards.count { it.rank == CardRank.ACE } + return calculateMinAceValue(sumWithoutAces, aceCount) + } + + private fun cardValue(card: Card): Int = when (card.rank) { + CardRank.KING, CardRank.QUEEN, CardRank.JACK -> FACE_CARD_VALUE + else -> card.rank.ordinal + 1 + } + + private fun calculateBestAceValue(sumWithoutAces: Int, aceCount: Int): Int { + var sum = sumWithoutAces + repeat(aceCount) { + sum += if (sum + ACE_HIGH_VALUE > MAX_HAND_VALUE) ACE_LOW_VALUE else ACE_HIGH_VALUE + } + return sum + } + + private fun calculateMinAceValue(sumWithoutAces: Int, aceCount: Int): Int { + var sum = sumWithoutAces + repeat(aceCount) { + sum += ACE_LOW_VALUE + } + return sum + } +} diff --git a/domain/src/main/kotlin/blackjack/player/Player.kt b/domain/src/main/kotlin/blackjack/player/Player.kt index 0aa382d713..99d2b42355 100644 --- a/domain/src/main/kotlin/blackjack/player/Player.kt +++ b/domain/src/main/kotlin/blackjack/player/Player.kt @@ -1,18 +1,27 @@ package blackjack.player +import action.BlackJackAction +import blackjack.BlackjackParticipant import blackjack.card.Card import blackjack.hand.Hand +import blackjack.hand.StandardHand data class Player( val name: String, - private val hand: Hand, -) { - val cards: List<Card> - get() = hand.cards + private val hand: Hand = StandardHand() +) : BlackjackParticipant { - fun canReceiveCard(): Boolean = hand.calculateBestValue() <= 21 - fun receiveCard(card: Card): Player { - return copy(hand = hand.addCard(card)) + val cards: List<Card> get() = hand.cards() + + fun canHit(): BlackJackAction = if (hand.calculateMinValue() <= 21) { + BlackJackAction.HIT + } else { + BlackJackAction.STAND } - fun calculateBestValue(): Int = hand.calculateBestValue() + + override fun receiveCard(card: Card): Player = copy(hand = hand.addCard(card)) + + override fun receiveCard(cards: List<Card>): Player = copy(hand = hand.addCard(cards)) + + override fun calculateBestValue(): Int = hand.calculateBestValue() } diff --git a/domain/src/test/kotlin/blackjack/dealer/DealerTest.kt b/domain/src/test/kotlin/blackjack/dealer/DealerTest.kt new file mode 100644 index 0000000000..f4d6f3cd32 --- /dev/null +++ b/domain/src/test/kotlin/blackjack/dealer/DealerTest.kt @@ -0,0 +1,37 @@ +package blackjack.dealer + +import action.BlackJackAction +import blackjack.card.Card +import blackjack.card.CardRank +import blackjack.card.CardSuit +import blackjack.deck.Deck +import blackjack.hand.StandardHand +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +class DealerTest : FunSpec({ + + test("처음 딜러의 손패 수는 0이다.") { + val dealer = Dealer(hand = StandardHand()) + dealer.cards.size shouldBe 0 + } + + test("손패의 수가 0일 때 결정할 액션은 HIT이다") { + val dealer = Dealer(hand = StandardHand()) + dealer.decideAction(deck = Deck()) shouldBe BlackJackAction.HIT + } + + test("딜러는 카드를 받으면 손패의 수가 1 증가한다.") { + Dealer(hand = StandardHand()).also { + it.cards.size shouldBe 0 + }.receiveCard(card = Card(suit = CardSuit.CLUBS, rank = CardRank.ACE)) + .cards.size shouldBe 1 + } + + test("ACE와 JACK을 가지고 있을 때, 베스트는 21이다.") { + Dealer(hand = StandardHand()) + .receiveCard(card = Card(suit = CardSuit.CLUBS, rank = CardRank.ACE)) + .receiveCard(card = Card(suit = CardSuit.DIAMONDS, rank = CardRank.JACK)) + .calculateBestValue() shouldBe 21 + } +}) diff --git a/domain/src/test/kotlin/blackjack/deck/DeckTest.kt b/domain/src/test/kotlin/blackjack/deck/DeckTest.kt index aa798f3417..a13fe5892a 100644 --- a/domain/src/test/kotlin/blackjack/deck/DeckTest.kt +++ b/domain/src/test/kotlin/blackjack/deck/DeckTest.kt @@ -17,9 +17,9 @@ class DeckTest : StringSpec({ "drawCard 함수가 실행되면 덱의 사이즈가 1 줄어든다." { val deck = Deck(StandardCardProvider(), RandomCardShuffler()) - val initialSize = deck.size + val initialSize = deck.remainingCards.size deck.drawCard() - deck.size shouldBe (initialSize - 1) + deck.remainingCards.size shouldBe (initialSize - 1) } "덱이 비어있을 때 카드를 뽑으려고 하면 예외가 발생해야 한다" { diff --git a/domain/src/test/kotlin/blackjack/game/BlackjackGameTest.kt b/domain/src/test/kotlin/blackjack/game/BlackjackGameTest.kt new file mode 100644 index 0000000000..fcf84a3c4b --- /dev/null +++ b/domain/src/test/kotlin/blackjack/game/BlackjackGameTest.kt @@ -0,0 +1,104 @@ +package blackjack.game + +import action.BlackJackAction +import blackjack.dealer.DefaultDealerStrategy +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.throwable.shouldHaveMessage +import io.kotest.matchers.types.shouldBeInstanceOf + +class BlackjackGameTest : StringSpec({ + + "플레이어 추가 확인" { + val game = blackjackOpen { + join("Alice") + join("Bob") + } + game.players.map { it.name } shouldContainExactly listOf("Alice", "Bob") + } + + "딜러 전략 설정 확인" { + val game = blackjackOpen { + join("Alice") + join("Bob") + dealerStrategy(DealerStrategyType.DEFAULT_DEALER_STRATEGY) + } + game.dealer.dealerStrategy.shouldBeInstanceOf<DefaultDealerStrategy>() + } + + "게임 인스턴스 생성 확인" { + val game = blackjackOpen { + join("Charlie") + } + game shouldNotBe null + } + + "플레이어 없이 게임 생성 시 예외 발생 확인" { + val exception = shouldThrow<IllegalArgumentException> { + blackjackOpen { } + } + exception shouldHaveMessage "플레이어가 최소 한 명은 존재해야 합니다." + } + + "초기 카드 배분 확인" { + val game = blackjackOpen { + join("Alice") + join("Bob") + } + game.dealInitialCards() + + game.players.forEach { player -> + player.cards shouldHaveSize 2 + } + game.dealer.cards shouldHaveSize 2 + } + + "플레이어 턴 처리 확인" { + val game = blackjackOpen { + join("Alice") + join("Bob") + } + game.dealInitialCards() + val alice = game.players.first { it.name == "Alice" } + + // Alice가 턴을 끝냄 + game.dealPlayerTurn(alice, false) + alice.cards shouldHaveSize 2 + + // Bob의 턴으로 넘어감 + val bob = game.players.first { it.name == "Bob" } + (game.state as GameState.PlayerTurn).currentPlayer shouldBe bob + } + + "딜러 턴 처리 확인" { + val game = blackjackOpen { + join("Alice") + join("Bob") + } + game.dealInitialCards() + // 모든 플레이어의 턴을 종료 + game.players.forEach { game.dealPlayerTurn(it, false) } + + // 딜러 턴 시작 + game.state.shouldBeInstanceOf<GameState.DealerTurn>() + } + + "게임 결과 계산 확인" { + val game = blackjackOpen { + join("Alice") + join("Bob") + } + game.dealInitialCards() + // 모든 턴 종료 + game.players.forEach { game.dealPlayerTurn(it, false) } + game.dealDealerTurn() + + val results = game.calculateResult() + results.keys shouldContainExactlyInAnyOrder game.players + game.dealer + } +}) diff --git a/domain/src/test/kotlin/blackjack/hand/HandTest.kt b/domain/src/test/kotlin/blackjack/hand/HandTest.kt index 5cda518013..32c161f2b8 100644 --- a/domain/src/test/kotlin/blackjack/hand/HandTest.kt +++ b/domain/src/test/kotlin/blackjack/hand/HandTest.kt @@ -8,8 +8,8 @@ import io.kotest.matchers.shouldBe class HandTest : FunSpec({ test("손패가 Ace, 10으로 이뤄져 있다면 21로 계산한다.") { - val hand = Hand( - cards = listOf( + val hand = StandardHand( + cards = setOf( Card(suit = CardSuit.CLUBS, rank = CardRank.ACE), Card(suit = CardSuit.HEARTS, rank = CardRank.TEN) ) @@ -19,8 +19,8 @@ class HandTest : FunSpec({ } test("손패가 Ace, 10, 2으로 이뤄져 있다면 13으로 계산한다.") { - val hand = Hand( - cards = listOf( + val hand = StandardHand( + cards = setOf( Card(suit = CardSuit.CLUBS, rank = CardRank.ACE), Card(suit = CardSuit.HEARTS, rank = CardRank.TEN), Card(suit = CardSuit.HEARTS, rank = CardRank.TWO), diff --git a/domain/src/test/kotlin/blackjack/player/PlayerTest.kt b/domain/src/test/kotlin/blackjack/player/PlayerTest.kt index e659735123..762fda39ef 100644 --- a/domain/src/test/kotlin/blackjack/player/PlayerTest.kt +++ b/domain/src/test/kotlin/blackjack/player/PlayerTest.kt @@ -1,30 +1,30 @@ package blackjack.player +import action.BlackJackAction import blackjack.card.Card import blackjack.card.CardRank import blackjack.card.CardSuit -import blackjack.hand.Hand +import blackjack.hand.StandardHand import io.kotest.core.spec.style.StringSpec -import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.shouldBe class PlayerTest : StringSpec({ "플레이어는 핸드의 최고 값이 21 이하일 때 카드를 뽑을 수 있어야 한다" { - val hand = Hand(listOf(Card(CardSuit.SPADES, CardRank.ACE))) + val hand = StandardHand(setOf(Card(CardSuit.SPADES, CardRank.ACE))) val player = Player("테스터", hand) - player.canReceiveCard().shouldBeTrue() + player.canHit() shouldBe BlackJackAction.HIT } "플레이어가 카드를 뽑을 때 플레이어의 핸드가 업데이트 되어야 한다" { - val player = Player("테스터", Hand()) + val player = Player("테스터", StandardHand()) player.cards.size shouldBe 0 val newPlayer = player.receiveCard(card = Card(CardSuit.SPADES, CardRank.ACE)) newPlayer.cards.size shouldBe 1 } "플레이어의 최고 값 계산이 올바르게 수행되어야 한다: A스페이드 + 10다이아는 21이다." { - val hand = Hand(listOf(Card(CardSuit.SPADES, CardRank.ACE), Card(CardSuit.DIAMONDS, CardRank.TEN))) + val hand = StandardHand(setOf(Card(CardSuit.SPADES, CardRank.ACE), Card(CardSuit.DIAMONDS, CardRank.TEN))) val player = Player("테스터", hand) player.calculateBestValue() shouldBe 21 } diff --git a/presenter/src/main/kotlin/model/BlackjackParticipants.kt b/presenter/src/main/kotlin/model/BlackjackParticipants.kt new file mode 100644 index 0000000000..e13d2385ef --- /dev/null +++ b/presenter/src/main/kotlin/model/BlackjackParticipants.kt @@ -0,0 +1,9 @@ +package model + +import blackjack.dealer.Dealer +import blackjack.player.Player + +data class BlackjackParticipants( + val dealer: Dealer, + val players: List<Player> +) diff --git a/presenter/src/main/kotlin/ui/Main.kt b/presenter/src/main/kotlin/ui/Main.kt index fac17ff9ca..b3efab04d8 100644 --- a/presenter/src/main/kotlin/ui/Main.kt +++ b/presenter/src/main/kotlin/ui/Main.kt @@ -1,8 +1,9 @@ package ui -import blackjack.deck.Deck -import blackjack.hand.Hand -import blackjack.player.Player +import blackjack.game.BlackjackGame +import blackjack.game.DealerStrategyType +import blackjack.game.GameState +import blackjack.game.blackjackOpen import ui.input.InputView import ui.result.ResultView @@ -11,30 +12,56 @@ fun main() { val resultView = ResultView() val playerNames = inputView.inputPlayerNames() - val deck = Deck() - val players = playerNames.map { Player(it, Hand()) } + val blackjackGame = blackjackOpen { + join(playerNames) + dealerStrategy(DealerStrategyType.DEFAULT_DEALER_STRATEGY) + } - playBlackjack(deck, players, inputView, resultView) + while (blackjackGame.state !is GameState.End) { + processGameState(blackjackGame, inputView, resultView) + } + processGameState(blackjackGame, inputView, resultView) } -fun playBlackjack(deck: Deck, players: List<Player>, inputView: InputView, resultView: ResultView) { - val playingPlayers = resultView.showInitialCards(deck, players).toMutableList() - println() - - playingPlayers.forEach { player -> - handlePlayerTurn(deck, player, inputView, resultView) +private fun processGameState(blackjackGame: BlackjackGame, inputView: InputView, resultView: ResultView) { + when (val gameState = blackjackGame.state) { + is GameState.PlayerTurn -> processPlayerTurn(gameState, blackjackGame, inputView, resultView) + is GameState.DealerTurn -> processDealerTurn(blackjackGame, resultView) + is GameState.InitialDeal -> processInitialDeal(blackjackGame, resultView) + is GameState.End -> processGameEnd(blackjackGame, resultView) } +} - resultView.showFinalResults(playingPlayers) +private fun processPlayerTurn( + gameState: GameState.PlayerTurn, + blackjackGame: BlackjackGame, + inputView: InputView, + resultView: ResultView +) { + val currentPlayer = gameState.currentPlayer + val isDeal = inputView.askForAdditionalCard(currentPlayer.name) + blackjackGame.dealPlayerTurn(currentPlayer, isDeal) + resultView.showCards(currentPlayer.name, blackjackGame.showPlayerCards(currentPlayer.name)) } -fun handlePlayerTurn(deck: Deck, player: Player, inputView: InputView, resultView: ResultView) { - while (player.canReceiveCard()) { - if (!inputView.askForAdditionalCard(player.name)) { - return - } - player.drawCard(deck) - resultView.showHandCards(player) - } +private fun processDealerTurn(blackjackGame: BlackjackGame, resultView: ResultView) { + val action = blackjackGame.dealDealerTurn() + resultView.showDealerTurn(action) +} + +private fun processInitialDeal(blackjackGame: BlackjackGame, resultView: ResultView) { + blackjackGame.dealInitialCards() + resultView.showInitialDealMessage(blackjackGame.players) + resultView.showCards(blackjackGame.dealer) + resultView.showCards(blackjackGame.players) +} + +private fun processGameEnd(blackjackGame: BlackjackGame, resultView: ResultView) { println() + val result = blackjackGame.calculateResult() + + resultView.showCards(blackjackGame.dealer) + blackjackGame.players.forEach { resultView.showCards(it) } + + resultView.showParticipantsRecord(blackjackGame, result) } diff --git a/presenter/src/main/kotlin/ui/input/InputView.kt b/presenter/src/main/kotlin/ui/input/InputView.kt index 72902bcc40..bd136b9f66 100644 --- a/presenter/src/main/kotlin/ui/input/InputView.kt +++ b/presenter/src/main/kotlin/ui/input/InputView.kt @@ -1,10 +1,11 @@ package ui.input class InputView { - fun inputPlayerNames(): List<String> { println("게임에 참여할 사람의 이름을 입력하세요.(쉼표 기준으로 분리)") val input = readln() + println() + input.ifBlank { throw IllegalArgumentException("한 명이라도 입력해야 합니다.") } return input.split(",").map { it.trim() } } diff --git a/presenter/src/main/kotlin/ui/result/ResultView.kt b/presenter/src/main/kotlin/ui/result/ResultView.kt index 3eaf02cb91..a11a930fc9 100644 --- a/presenter/src/main/kotlin/ui/result/ResultView.kt +++ b/presenter/src/main/kotlin/ui/result/ResultView.kt @@ -1,31 +1,61 @@ package ui.result -import blackjack.deck.Deck +import action.BlackJackAction +import blackjack.BlackjackParticipant +import blackjack.card.Card +import blackjack.dealer.Dealer +import blackjack.game.BlackjackGame +import blackjack.game.BlackjackResult import blackjack.player.Player import toUiString class ResultView { - private fun showCardsForPlayer(player: Player) { - println("${player.name}카드: ${player.cards.joinToString(", ") { it.toUiString() }}") + fun showCards(cardOwner: String, cards: List<Card>) { + println("${cardOwner}카드: ${cards.joinToString(", ") { it.toUiString() }}") } - fun showInitialCards(deck: Deck, players: List<Player>): List<Player> { - val resultPlayers = players.take(2).map { it.receiveCard(deck.drawCard()).receiveCard(deck.drawCard()) } + fun showCards(participant: BlackjackParticipant) { + when (participant) { + is Dealer -> println("딜러 카드: ${participant.cards.joinToString(", ") { it.toUiString() }} - 결과: ${participant.calculateBestValue()}") + is Player -> println("${participant.name} 카드: ${participant.cards.joinToString(", ") { it.toUiString() }} - 결과: ${participant.calculateBestValue()}") + } + } - println("${resultPlayers.joinToString(", ") { it.name }}에게 2장의 나누었습니다.") - resultPlayers.forEach(this::showCardsForPlayer) + fun showCards(players: List<Player>) { + players.forEach(::showCards) + println() + } - return resultPlayers + fun showDealerTurn(dealerAction: BlackJackAction) { + println() + val message = if (dealerAction == BlackJackAction.HIT) { + "딜러는 16 이하라 1장 더 받습니다." + } else { + "딜러는 16을 초과하여 받지 않습니다." + } + println(message) } - fun showHandCards(player: Player) { - showCardsForPlayer(player) + fun showInitialDealMessage(players: List<Player>) { + println("딜러와 ${players.joinToString(", ") { it.name }}에게 2장의 카드를 나누었습니다.") } - fun showFinalResults(players: List<Player>) { - players.forEach { player -> - println("${player.name}카드: ${player.cards.joinToString(", ") { it.toUiString() }} - 결과: ${player.calculateBestValue()}") + fun showParticipantsRecord(blackjackGame: BlackjackGame, result: Map<BlackjackParticipant, BlackjackResult>) { + println() + println("## 최종 승패") + showDealerRecord(result[blackjackGame.dealer] ?: throw IllegalArgumentException()) + blackjackGame.players.forEach { + showPlayerRecord(it, result[it] ?: throw IllegalArgumentException()) } } + + private fun showDealerRecord(dealerResult: BlackjackResult) { + println("딜러: ${dealerResult.win}승 ${dealerResult.lose}패") + } + + private fun showPlayerRecord(player: Player, playerResult: BlackjackResult) { + val winOrLose = if (playerResult.win == 1) "승" else "패" + println("${player.name}: $winOrLose") + } }