Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(step2): 블랙잭 #692

Open
wants to merge 10 commits into
base: david-sang
Choose a base branch
from
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,37 @@
# kotlin-blackjack
# 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장이면 블랙잭이다
Empty file removed src/main/kotlin/.gitkeep
Empty file.
10 changes: 10 additions & 0 deletions src/main/kotlin/blackjack/Main.kt
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +7 to +9

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

main함수의 코드를 최소화 해보면 어떨까요?
controller의 함수를 통해 리턴된 game을 다시 controller의 함수의 파라미터로 사용하는 등의 동작은
불필요한 책임 아닐까요? 모두 BlackjackController에게 위임해보면 어떨까요?

또한, 게임이 시작 된 이후, 플레이들이 카드를 더받고 등의 로직은 비즈니스로직이 아닐까요?

}
38 changes: 38 additions & 0 deletions src/main/kotlin/blackjack/controller/BlackjackController.kt
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +13 to +14

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OutputView의 함수를 하나하나 실행하기보다,
OutputView에게 게임 초기값을 그려주라고 메세지를 던저 위임하면 어떨까요?

OutputView가 텍스트가 아닌, UI로 변경된다면 Controller의 로직 또한 변경되야할까요?

}
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()
Comment on lines +23 to +24

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

y,n 문자열또한 View의 책임 아닐까요?

else -> throw IllegalArgumentException("only 'y' or 'n' can be entered")
}
OutputView.printPlayerAndCard(phase.player)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indent(인덴트, 들여쓰기) depth를 2를 넘지 않도록 구현한다. 1까지만 허용한다.

프로그램 요구사항을 지켜보면 어떨까요?

game.moveToNextPhase()
}

fun progressEndPhase(game: Game) {
val phase = game.checkAndGetPhase(Phase.EndPhase::class)
phase.players.values.forEach {
OutputView.printPlayerAndCardAndScore(it)
}
}
}
57 changes: 57 additions & 0 deletions src/main/kotlin/blackjack/domain/Card.kt
Original file line number Diff line number Diff line change
@@ -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<Score> = 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);
Comment on lines +27 to +29

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

number는 무엇을 의미하나요?
11,12,13 이라는 숫자는 사용하지 않을거 같아요!

}

@JvmInline
value class PossibleScore(private val values: Set<Score>) {

fun getPossibleScoreSums(score: Score): List<Score> = values.map { score + it }

companion object {
fun getPossibleScore(rank: Rank) = when (rank) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코틀린에서 리턴타입을 생략할수 있지만,
명시적으로 써주는것을 지향하면 좋을거 같아요!
리턴타입을 알기위해 구현부를 살펴봐야하고, 또 구현부의 코드에 따라 의도하지 않아도 리턴타입이 변경되기도해요!

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)
}
Comment on lines +38 to +52

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Rank 별로 관리된다면, Rank에 score 변수를 추가하면 어떨까요?

  • ACE만을 위해, 굳이 list를 사용하는건 불필요하지 않을까요?
    Ace 계산을 위한 상수값을 활용하면 리스트를 활요앟지 않아도 될거같아요!

.map(Score::from)
.toSet()
.let(::PossibleScore)
}
}
22 changes: 22 additions & 0 deletions src/main/kotlin/blackjack/domain/Deck.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package blackjack.domain

@JvmInline
value class Deck(private val cards: MutableSet<Card>) {
fun draw(): Card {
check(cards.isNotEmpty()) { "no cards left" }
val card = cards.random()
cards.remove(card)
return card
}

companion object {
private val preparedDeck: Set<Card> =
Suit.values().flatMap { suit ->
Rank.values().map { rank ->
Card(suit = suit, rank = rank)
}
}.toSet()

fun getDeck(): Deck = preparedDeck.toMutableSet().let(::Deck)
}
}
32 changes: 32 additions & 0 deletions src/main/kotlin/blackjack/domain/Game.kt
Original file line number Diff line number Diff line change
@@ -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<Phase>
private var phaseIndex: Int = 0
private val phase
get() = phases[phaseIndex]
Comment on lines +7 to +10

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

phase를 구분하신 이유가 있을까요?
개인적으로는 일반적인 for문을 사용하는 것과 크게 다르진 않을거같단 생각이 드는거 같아요 ㅠㅠ!


constructor(names: List<String>) : this(names.map(::Player).let(::Players))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

생성자보단, 팩토리함수를 활용해보면 어떨까요>?

참고해보아요!
https://kyucumber.github.io/posts/book/effective-kotlin/effective-kotlin-item-33


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 <T : Phase> checkAndGetPhase(phaseType: KClass<T>): T {
check(phaseType.isInstance(phase)) { "not ${phaseType::class.java.name}" }
return phase as T
}

}
34 changes: 34 additions & 0 deletions src/main/kotlin/blackjack/domain/Hand.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package blackjack.domain

@JvmInline
value class Hand(val cards: MutableList<Card> = mutableListOf()) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cards는 외부에서 접근가능하여 변경 가능한 구조네요!
외부에서 변경가능하지 않도록 변경가능지점을 보호해보면 어떨까요?


fun add(card: Card) = cards.add(card)

fun getCardCount() = cards.size

fun getBestScore(): Score {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

블랙잭의 룰에서는 Ace는 21을 초과하지 않을때는 11, 21이 초과될떄는 1로 계산되고 있어요,
11, 1 모든 케이스를 고려하신 부분은 알겠지만, 불필요하게 로직이 복잡해졌다는 느낌이 드네요,
로직을 간소화해보면 어떨까요

자유롭게 참고해보셔도 좋을거같아요

간단하게 기본적으로 Ace를 1로 계산하고,
카드내에 Ace가 있다면 Ace를 11로 계산했을때 21을 넘지 않는다면 11로 계산하면 좋을거같단 생각이드네요
if (sum + 10 > Score.BLACKJACK) sum else sum + 10

val scoreSums = mutableSetOf<Score>()
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<Card>, index: Int, sum: Score, sums: MutableSet<Score>) {
if (index == cards.size) {
sums.add(sum)
return
}
cards[index].getPossibleScoreSums(sum).forEach {
calculateScoreSums(cards, index + 1, it, sums)
}
}
}
13 changes: 13 additions & 0 deletions src/main/kotlin/blackjack/domain/Phase.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
67 changes: 67 additions & 0 deletions src/main/kotlin/blackjack/domain/Player.kt
Original file line number Diff line number Diff line change
@@ -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() }
Comment on lines +3 to +7

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resultScore 는 hand에 따라 상태가 관리되는 함수가 아닌가요?
게임중간에 의도하지 않은곳에서 resultScore가 호출된다면 어떤문제가 생길까요?
변수로 들고 있기보다, 함수로 만드는게 좋을거같아요

val resultScore: Score get() = decideScore()

state 또한 stay를 제외하고는 hand 상태에 따라 상태가 변경되는 구조네요,
hand가 변경될때마다 state가 변경되는 상태라면 불필요하지 않을까요?
오히려 의존적인 상태로 인해 객체가 더 복잡해지는거 같아요,
개인적으로는 state는 Finish되었는지에 대한 부분만 알고있어도 좋을거 같단생각이드네요!


fun initialize(deck: Deck) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deck을 파라미터로 받기보다, draw된 cards를 넘겨받는 구조는 어떨까요?
Player는 Card가 관심사지, 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
Comment on lines +30 to +31

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

score의 Value에 접근하기보단,
score에게 책임을 위임해보는건 어떨까요?

https://dkswnkk.tistory.com/687 참고해보아요!
ex> score.isBust

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<Player>) {

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;
}
7 changes: 7 additions & 0 deletions src/main/kotlin/blackjack/domain/Rule.kt
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 16 additions & 0 deletions src/main/kotlin/blackjack/domain/Score.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 13 additions & 0 deletions src/main/kotlin/blackjack/view/InputView.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package blackjack.view

object InputView {
fun getNames(): List<String> {
println("게임에 참여할 사람의 이름을 입력하세요.(쉼표 기준으로 분리)")
return readln().split(",")
}

fun getHitOrStay(name: String): String {
println("${name}은(는) 한장의 카드를 더 받겠습니까?(예는 y, 아니오는 n)")
return readln()
}
}
Loading