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

[블랙잭 step1] 신은정 미션 제출합니다. #5

Open
wants to merge 20 commits into
base: rueun
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,41 @@
블랙잭 미션 저장소

## [미션 리드미](https://github.com/talmood/private-mission-README/tree/main/%EB%AF%B8%EC%85%98%204%20-%20%EB%B8%94%EB%9E%99%EC%9E%AD)


## 기능 명세서
- 카드
- [ ] 카드는 조커를 제외한 52장의 카드로 구성된다.
- [ ] King, Queen, Jack은 각각 10점으로 계산한다.
- [ ] Ace는 1점 또는 11점으로 계산한다.

- 딜러
- [ ] 딜러는 처음에 받은 카드 2장의 합이 16점 이하이면 반드시 1장의 카드를 더 받아야 한다.
- [ ] 딜러는 17점 이상이면 추가로 카드를 받을 수 없다.

- 플레이어
- [ ] 플레이어는 카드의 합이 21점을 초과하면 패배한다.
- [ ] 플레이어는 카드의 합이 21점을 넘지 않으면 원하는 만큼 카드를 추가로 더 받을 수 있다.
- [ ] 플레이어는 게임 종료 시 딜러와 자신의 카드를 비교하여 승패를 가린다.

- 게임
- [ ] 딜러와 플레이어 중 카드의 합이 21을 초과하지 않으면서 21에 가장 가까운 쪽이 승리한다.
- [ ] 딜러와 플레이어의 카드의 합이 같으면 딜러가 승리한다.
- [ ] 딜러와 플레이어가 블랙잭인 경우 무승부로 처리한다.
- [ ] 게임을 시작하면 딜러와 플레이어에게 각각 2장의 카드를 나눠준다.
- [ ] 게임을 종료하면 승패를 가린다.
- 결과
- [ ] 게임 종료 후 승패 결과를 보여준다.
- 계산
- 플레이어가 이기는 경우
- [ ] 플레이어가 블랙잭이면서 딜러가 블랙잭이 아닌 경우
- [ ] 플레이어가 버스트가 아니면서 딜러가 버스트인 경우
- [ ] 플레이어, 딜러 모두 버스트가 아니면서 플레이어가 딜러보다 점수가 높은 경우
- 플레이어가 지는 경우
- [ ] 딜러가 블랙잭이면서 플레이어가 블랙잭이 아닌 경우
- [ ] 딜러가 버스트가 아니면서 플레이어가 버스트인 경우
- [ ] 플레이어, 딜러 모두 버스트가 아니면서 플레이어가 딜러보다 점수가 낮은 경우
- 무승부
- [ ] 플레이어, 딜러 모두 블랙잭인 경우
- [ ] 플레이어, 딜러 모두 버스트인 경우
- [ ] 플레이어, 딜러 모두 버스트가 아니면서 플레이어와 딜러의 점수가 같은 경우
12 changes: 12 additions & 0 deletions src/main/java/blackjack/Application.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package blackjack;


import blackjack.controller.BlackJackGameController;

public class Application {

public static void main(String[] args) {
final BlackJackGameController blackJackGameController = new BlackJackGameController();
blackJackGameController.run();
}
}
35 changes: 35 additions & 0 deletions src/main/java/blackjack/controller/BlackJackGameController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package blackjack.controller;

import blackjack.domain.card.CardDeck;
import blackjack.domain.game.BlackJackGame;
import blackjack.domain.participant.Dealer;
import blackjack.domain.participant.Name;
import blackjack.domain.participant.Player;
import blackjack.domain.participant.Players;
import blackjack.view.InputView;

import java.util.List;
import java.util.stream.Collectors;

public class BlackJackGameController {

public void run() {
final CardDeck cardDeck = CardDeck.create();
cardDeck.shuffle();
Comment on lines +17 to +18
Copy link
Member

Choose a reason for hiding this comment

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

카드덱을 셔플하는 책임을 컨트롤러가 가져야할지는 의문이에요.
CardDeck.ofShuffled()처럼 이미 셔플된 상태의 카드덱을 가져와도 괜찮을 것 같습니다 :)


final Dealer dealer = Dealer.create();
final Players players = initPlayers();

final BlackJackGame blackJackGame = new BlackJackGame(cardDeck, players, dealer);
blackJackGame.play();
}

private Players initPlayers() {
final List<String> playerNames = InputView.inputPlayerNames();
final List<Player> players = playerNames.stream()
.map(name -> Player.of(Name.of(name)))
.collect(Collectors.toList());

return Players.of(players);
}
}
45 changes: 45 additions & 0 deletions src/main/java/blackjack/domain/card/Card.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package blackjack.domain.card;

import java.util.Objects;

public class Card {
private final Suit suit;
private final Rank rank;

private Card(final Suit suit, final Rank rank) {
this.suit = suit;
this.rank = rank;
}

public static Card of(final String suit, final String rank) {
return new Card(Suit.of(suit), Rank.of(rank));
}

public static Card of(final Suit suit, final Rank rank) {
return new Card(suit, rank);
}

public Suit getSuit() {
return suit;
}

public Rank getRank() {
return rank;
}

public String showCardInfo() {
return rank.getName() + suit.getName();
}
Comment on lines +30 to +32
Copy link
Member

Choose a reason for hiding this comment

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

"카드 숫자 + 카드 문양"을 나타내는 것은 UI로직에 해당해요.
즉, 현재 도메인 객체가 UI로직에 의존하고 있어요.
도메인이 뷰(UI)를 의존할 경우 어떤 추가 비용이 발생할 수 있을지 고민해보시면 좋을 것 같습니다 :)


@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Card card)) return false;
return suit == card.suit && rank == card.rank;
}

@Override
public int hashCode() {
return Objects.hash(suit, rank);
}
Comment on lines +34 to +44
Copy link
Member

@haero77 haero77 Jun 6, 2024

Choose a reason for hiding this comment

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

값 객체에 대한 equals & hashcode 재정의 좋네요 👍

}
73 changes: 73 additions & 0 deletions src/main/java/blackjack/domain/card/CardDeck.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package blackjack.domain.card;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

public class CardDeck {
private static final int DECK_SIZE = 52;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
private static final int DECK_SIZE = 52;
private static final int DECK_SIZE = Suit.values().length * Rank.values().length;

물론 트럼프 카드가 문양 4개, 숫자 13개로 고정되어서 덱 사이즈가 52에서 변경될 일이 거의 없기는 해요.
다만 이 미션 자체가 '커스텀 된 블랙잭'인 만큼, 카드 문양과 숫자를 제한하는 요구사항 역시 충분히 발생할 수 있을 것 같더라고요. 이럴 경우 리터럴값으로 값이 고정되는 것보다 위같은 방식을 통해 더 유연하게 대응하면 어떨까 싶어 제안 드려봅니다 :)

private static final String DECK_EMPTY_MESSAGE = "덱이 비었습니다.";
private static final String DECK_SIZE_MESSAGE = String.format("덱의 크기는 %d 입니다.", DECK_SIZE);
private static final String DUPLICATE_CARD_MESSAGE = "중복된 카드가 존재합니다.";
Comment on lines +10 to +12
Copy link
Member

Choose a reason for hiding this comment

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

예외 메시지를 상수로 관리하는 것에 대한 은정님의 의견이 궁금합니다 :)

저는 예외 메시지가 같은 메서드 스코프 안에서 보이지 않을 경우 스크롤을 왔다갔다 해야해서 가독성이 많이 떨어진다는 느낌을 받아서, 웬만하면 예외 메시지는 예외를 던지는 곳과 같이 위치시키는 편이에요.

물론, "DECK_EMPTY_MESSAGE" 예외 메시지 자체를 2번 이상 사용하는 경우 상수로 관리하는 것에 대한 이득을 누릴 수 있지만, 예외 메시지는 한 번 작성하면 거의 변경되질 않기 때문에 중복되더라도 하드 코딩하는 것이 가독성을 위해 일부러 중복으로 작성하기도 합니다 :D


private final List<Card> cards;

private CardDeck() {
final List<Card> initializedCards = initialize();
validate(initializedCards);
this.cards = initializedCards;
}

public static CardDeck create() {
return new CardDeck();
}

private static List<Card> initialize() {
return Arrays.stream(Suit.values())
.flatMap(suit -> Arrays.stream(Rank.values())
.map(rank -> Card.of(suit, rank)))
.collect(Collectors.toList());
}

private static void validate(final List<Card> cards) {
if (cards.isEmpty()) {
throw new IllegalArgumentException(DECK_EMPTY_MESSAGE);
}
if (cards.size() != DECK_SIZE) {
throw new IllegalArgumentException(DECK_SIZE_MESSAGE);
}
if (cards.stream().distinct().count() != DECK_SIZE) {
throw new IllegalArgumentException(DUPLICATE_CARD_MESSAGE);
}
}

public void shuffle() {
Collections.shuffle(cards);
}

public List<Card> getCards() {
return List.copyOf(cards);
}


/**
* 초기 카드 2장을 뽑고, 덱에서 제거
* @return List<Card> 초기 카드 2장
*/
public List<Card> drawInitialCards() {
return List.of(draw(), draw());
}
Comment on lines +58 to +60
Copy link
Member

Choose a reason for hiding this comment

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

CardDeck이 블랙잭 게임 초기 세팅을 위해서 카드 2장을 뽑는 것까지 알 필요가 있을지는 조금 의문이에요.
CardDeck까지 블랙잭 초기 세팅을 위한 비즈니스 로직까지 알아야 하나라는 생각이 들어요.
이미 몇 장을 뽑을지는 CardDeck 에게 메시지를 보낼 객체가 알고 있지 않을까요?


/**
* 카드를 뽑고, 덱에서 제거
* @return Card 뽑은 카드
*/
public Card draw() {
if (cards.isEmpty()) {
throw new IllegalArgumentException(DECK_EMPTY_MESSAGE);
}
Comment on lines +67 to +69
Copy link
Member

Choose a reason for hiding this comment

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

'Illegal' 한 'Argument'가 없는데 IllegalArgumentException을 발생시키고 있어요.
이럴 경우 보통 IllegalStateException을 사용해요 :)

// 마지막 카드를 뽑아내고, 덱에서 제거
return cards.remove(cards.size() - 1);
}
Comment on lines +70 to +72
Copy link
Member

Choose a reason for hiding this comment

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

리스트에서 카드를 제거 하는 로직 자체를 직접 구현했는데, Java 가 제공하는 기본 API를 써서 대체해볼 수 있을 것 같아요. (자료구조가 List가 아닌 큐 자료구조를 써보면 카드를 한 장 뽑는다는 로직을 쉽게 구현해볼 수 있을 것 같아요)

}
54 changes: 54 additions & 0 deletions src/main/java/blackjack/domain/card/Cards.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package blackjack.domain.card;

import java.util.List;

public class Cards {
private final List<Card> cards;

private Cards(final List<Card> cards) {
this.cards = cards;
}

public static Cards of(final List<Card> cards) {
return new Cards(cards);
}

public void add(final Card card) {
cards.add(card);
}

public List<String> showCardsInfo() {
return cards.stream()
.map(Card::showCardInfo)
.toList();
}

public boolean isBlackjack() {
return calculateScore() == 21 && cards.size() == 2;
}

public int calculateScore() {

int totalScore = cards.stream()
.mapToInt(card -> card.getRank().getScore())
.sum();

int aceCount = (int) cards.stream()
.map(Card::getRank)
.filter(Rank.ACE::equals)
.count();

return calculateAceScore(totalScore, aceCount);
}

private int calculateAceScore(final int totalScore, int aceCount) {
int score = totalScore;

while (aceCount > 0 && score + 10 <= 21) {
score += 10;
aceCount--;
}

return score;
}
}
41 changes: 41 additions & 0 deletions src/main/java/blackjack/domain/card/Rank.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package blackjack.domain.card;

public enum Rank {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
public enum Rank {
public enum CardRank {

클래스명을 그 자체로 무슨 뜻인지 명확히 알 수 있게 지어보면 어떨까요?

물론 이 미션이 블랙잭 게임을 구현하는 미션이고, Rank 클래스가 card 패키지 안에 있으니 이 Rank 클래스는 Card의 Rank임을 '유추'할 수는 있어요. 그러나 이러한 배경 정보를 바탕으로 '유추'하는 것과 클래스명 그 자체로 알 수 있도록 하는 것은 큰 차이가 있어요.

프로젝트를 유지보수하면서 클래스는 계속 늘어나고, Rank가 사실은 Card의 Rank였다는 사실을 계속 인지하는 것은 점차 어려워질거에요. (어쩌면 이름은 같고 역할은 다른 Rank 클래스도 생길 수도 있고요.)

앞으로 벌어나지도 않을 일 때문에 미리 그것까지 고려해야될까라는 물음에는 저는 이정도면 충분히 고려를 해야한다고 봐요. 이러한 점들을 종합적으로 고려했을 때, 클래스를 설계하는 시점부터 클래스명을 더욱 명확하게, 의미를 좁히는 방향으로 지어보면 어떨까 싶습니다 :D

ACE("A", 1),
TWO("2", 2),
THREE("3", 3),
FOUR("4", 4),
FIVE("5", 5),
SIX("6", 6),
SEVEN("7", 7),
EIGHT("8", 8),
NINE("9", 9),
TEN("10", 10),
JACK("J", 10),
QUEEN("Q", 10),
KING("K", 10);

private final String name;
private final int score;

Rank(final String name, final int score) {
this.name = name;
this.score = score;
}

public static Rank of(final String name) {
return Rank.valueOf(name);
}

public String getName() {
return name;
}

public int getScore() {
return score;
}

public boolean isAce() {
return this == ACE;
}
}
27 changes: 27 additions & 0 deletions src/main/java/blackjack/domain/card/Suit.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package blackjack.domain.card;

import java.util.Arrays;

public enum Suit {
SPADE("스페이드"),
HEART("하트"),
DIAMOND("다이아몬드"),
CLOVER("클로버");

private final String name;

Suit(final String name) {
this.name = name;
}

public static Suit of(final String name) {
return Arrays.stream(Suit.values())
Comment on lines +17 to +18
Copy link
Member

@haero77 haero77 Jun 6, 2024

Choose a reason for hiding this comment

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

지금처럼 팩토리 메서드 네이밍을 관례로 사용할 경우,
파라미터가 하나만 있을 때는 of 가 아닌 from을 사용해요 :)

.filter(suit -> suit.name.equals(name))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("유효하지 않은 카드 모양입니다."));
}

public String getName() {
return name;
}
}
Loading