블랙잭 게임 - 2 핵심 로직

Kim Dong Kyun·2023년 9월 13일
2

블랙잭 게임 요구 사항

기능 요구 사항

블랙잭 게임을 변형한 프로그램을 구현한다. 
블랙잭 게임은 딜러와 플레이어 중 카드의 합이 21 또는 21에 가장 가까운 숫자를 가지는 쪽이 이기는 게임이다.

플레이어는 게임을 시작할 때 배팅 금액을 정해야 한다.
카드의 숫자 계산은 카드 숫자를 기본으로 하며, 예외로 Ace1 또는 11로 계산할 수 있으며,
King, Queen, Jack은 각각 10으로 계산한다.

게임을 시작하면 플레이어는 두 장의 카드를 지급 받으며, 
두 장의 카드 숫자를 합쳐 21을 초과하지 않으면서 21에 가깝게 만들면 이긴다.

21을 넘지 않을 경우 원한다면 얼마든지 카드를 계속 뽑을 수 있다., 카드를 추가로 뽑아 21을 초과할 경우 배팅 금액을 모두 잃게 된다.

처음 두 장의 카드 합이 21일 경우 블랙잭이 되면 베팅 금액의 1.5 배를 딜러에게 받는다. 

딜러와 플레이어가 모두 동시에 블랙잭인 경우 플레이어는 베팅한 금액을 돌려받는다.

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

딜러가 21을 초과하면 그 시점까지 남아 있던 플레이어들은 가지고 있는 패에 상관 없이 승리해 베팅 금액을 받는다.

지난 이야기들

Github 소스 코드

Card측에서 Enum 을 상태로 가지기로 했다.

클래스 다이어그램

  • 다이어그램 오류인지 모르겠는데, Dealer 클래스(딜러역할) 또한 Gamer를 상속받는다.

Card.java

public class Card {
    private final CardShape cardShape;
    private final CardNumber cardNumber;

    public Card(CardShape cardShape, CardNumber cardNumber) {
        this.cardShape = cardShape;
        this.cardNumber = cardNumber;
    }

    public CardNumber getCardNumber() {
        return this.cardNumber;
    }

    public CardShape getCardShape(){
        return this.cardShape;
    }

    public Integer getNumericValue() {
        return this.cardNumber.getNumericValue();
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Card card = (Card) o;
        return cardShape == card.cardShape && cardNumber == card.cardNumber;
    }

    @Override
    public int hashCode() {
        return Objects.hash(cardShape, cardNumber);
    }
}
  • 카드측에서 프로퍼티로 CardShape, CardNumber 와 같은 Enum을 가진다.

  • Enum의 예시는 아래와 같다

public enum CardNumber {
    ACE(1),
    TWO(2),
    THREE(3),
    FOUR(4),
    FIVE(5),
    SIX(6),
    SEVEN(7),
    EIGHT(8),
    NINE(9),
    J(10), Q(10), K(10);

    private final Integer value;

    CardNumber(Integer value) {
        this.value = value;
    }

    public int getNumericValue() {
        return this.value;
    }
}
  • 아주 간단한 녀석으로 만들어보았다. Card는 상태를 가지는 Enum들을 게터 매서드를 통해 전달해주고, 그걸 받은 게이머는 게임하는 식.

컬렉션 객체인 Cards

Cards.java

public class Cards {

    private final Stack<Card> cards;

    public Cards() {
       this.cards = createCardDeck();
    }

    public Stack<Card> createCardDeck() {
        Stack<Card> cards = new Stack<>();
        for (CardShape cardShape : CardShape.values()) {
            createNumbersByShape(cards, cardShape);
        }
        return cards;
    }

    private void createNumbersByShape(Stack<Card> cards, CardShape cardShape) {
        for (CardNumber cardNumber : CardNumber.values()) {
            cards.add(new Card(cardShape, cardNumber));
        }
    }
    public void shuffleCards(){
        Collections.shuffle(this.cards);
    }
    public Card popCard() {
        return this.cards.pop();
    }
}
  • 4개의 쉐이프, 12개의 숫자(K,Q,J) 를 초기화하고 제공하는 컬렉션

  • 불변 컬렉션을 의도했으나, 이미 사용한 카드는 빠져줘야 하므로 불변 컬렉션이 아니다

  • 카드 뭉치의 이미지에 제일 알맞는 Stack 을 사용했다.

  • 이 클래스는 왜 존재할까?

테스트를 하기 위해서는, 덱에서 카드를 꺼낼 때 순서가 보장되어(정렬되어) 있어야 한다.

  • 따라서, 카드 덱을 만드는 로직과 (public하게 열어둔 createCardDeck())

  • 카드 덱을 섞는 로직 (마찬가지로 퍼블릭인 shuffleCards()) 이 존재한다.


핵심 로직이 존재하는 Gamer 클래스

Gamer.java

public abstract class Gamer {
    private final List<Card> deck;
    private final String name;
    private Integer valance = 100000;
    private Integer cardSum = 0;
    private static final Integer ADDABLE_NUMBER_FOR_ACE = 10;
    private static final Integer NUMBER_CONSTRAINT = 21;

    protected Gamer(String name) {
        this.name = name;
        this.deck = new ArrayList<>();
    }

    public List<Card> getDeck(){
        return this.deck;
    }

    public String getName(){
        return this.name;
    }

    public Integer getValance() {
        return valance;
    }

    public void offerStake(Integer thisGameCost) {
        valance -= thisGameCost;
    }

    public void pickCard(Card card) { // For Test
        cardSum += card.getNumericValue();
        deck.add(card);
    }

    public void pickCard(List<Card> cards) { // For Logic
        deck.addAll(cards);
        cardSum += cards.stream().map(Card::getNumericValue).reduce(0, Integer::sum);
    }

    public Integer getCardSum(){
        if (isAceConvertable() && cardSum + ADDABLE_NUMBER_FOR_ACE <= NUMBER_CONSTRAINT) {
            return cardSum + ADDABLE_NUMBER_FOR_ACE;
        }
        return cardSum;
    }

    public boolean isSumOverMax() {
        return this.getCardSum() > 21;
    }

    private boolean containsAce(){ // For Test
        return this.deck.stream().map(Card::getNumericValue).anyMatch(integer -> integer == CardNumber.ACE.getNumericValue());
    }

    private boolean isAceConvertable(){
        if (containsAce()){
            return Math.abs(this.cardSum + ADDABLE_NUMBER_FOR_ACE - NUMBER_CONSTRAINT) < Math.abs(cardSum - NUMBER_CONSTRAINT);
        }
        return false;
    }
}

해당 클래스의 상태 (프로퍼티) 는 다음과 같다

  1. Deck : 부여받은 카드들을 모아놓은 핸드
  2. name : 유저의 이름
  3. valance : 잔고
  4. cardSum : 유저가 계산하는 덱의 가치

그리고, 아래에는 여러 핵심 매서드들이 보인다. 그 중에서 가~장 핵심적인 녀석은 이것이다

Core Methods


	public void pickCard(Card card) { 
        cardSum += card.getNumericValue();
        deck.add(card);
    }
    
    public void pickCard(List<Card> cards) { // For Logic
        deck.addAll(cards);
        cardSum += cards.stream().map(Card::getNumericValue).reduce(0, Integer::sum);
    }

    public Integer getCardSum(){
        if (isAceConvertable() && cardSum + ADDABLE_NUMBER_FOR_ACE <= NUMBER_CONSTRAINT) {
            return cardSum + ADDABLE_NUMBER_FOR_ACE;
        }
        return cardSum;
    }

    public boolean isSumOverMax() {
        return this.getCardSum() > 21;
    }

    private boolean containsAce(){ // For Test
        return this.deck.stream().map(Card::getNumericValue).anyMatch(integer -> integer == CardNumber.ACE.getNumericValue());
    }

    private boolean isAceConvertable(){
        if (containsAce()){
            return Math.abs(this.cardSum + ADDABLE_NUMBER_FOR_ACE - NUMBER_CONSTRAINT) < Math.abs(cardSum - NUMBER_CONSTRAINT);
        }
        return false;
    }
  • pickCard()는 카드, 혹은 카드들을 아규먼트로 받아서 (매서드 오버로드로 같은 이름으로 사용 가능) 가지고 있는 덱에 넣는 매서드이다.

  • getCardSum() 은 NUMBER_CONSTRAINT(룰에서 정한 21) 을 넘지 않는다면, 에이스를 11로 계산하여 넣는 매서드이다.

  • 그리고 나머지 매서드들은, 재사용 되기 위한 로직들이다. (특히 isAceConvertable()) 위와 같이 나눔으로써 테스트가 용이해진다.


남은 할일은?

제일 중요한 건, 유저의 상태를 어떻게 관리 할 것인가? 이다.

유저의 손패를 계산해서, 유저가

  1. 더 받을 수 있는지

  2. 더 받을 수 없는지

  3. 블랙잭인지 (==21)

판단해야 하며, 딜러 또한 마찬가지이다. 그러나 세부 사항이 다르다

그렇다면 바로 생각 나는 것은 FunctionalInterface 이다.

아마도, 유저의 상태를 나타내는 객체를 (혹은 인터페이스를) 선언 한 후 게임유저(Player) 단에서 주입받아서, 람다로 재정의해서 사용 할 듯 하다.


위 로직들의 테스트 코드

class GamerTest {
    static Cards cards;
    static Gamer gamer;
    static Dealer dealer;
    static final String NAME = "DK";
    static final Integer VALANCE = 100000;
    static final Integer THIS_GAME_COST = 10000;
    @BeforeEach
    void createGamer() {
        dealer = new Dealer(NAME);
        gamer = new GameUser(NAME);
        cards = new Cards();
    }

    @Test
    void getName() {
        assertThat(gamer.getName()).isEqualTo(NAME);
    }

    @Test
    void valanceTest(){
        assertThat(gamer.getValance()).isEqualTo(VALANCE);
        gamer.offerStake(THIS_GAME_COST);
        assertThat(gamer.getValance()).isEqualTo(VALANCE - THIS_GAME_COST);
    }

    @Test
    void pickCardTest(){
        Card card = cards.popCard();
        gamer.pickCard(card);
        assertThat(gamer.getCardSum()).isEqualTo(10);
    }

    @Test
    void checkSum(){
        Card card = cards.popCard();
        Card card1 = cards.popCard();
        Card card2 = cards.popCard(); // K, Q, J 순서로 뽑힘
        gamer.pickCard(List.of(card2, card1, card));
        assertThat(gamer.isSumOverMax()).isTrue();
    }

    @Test
    @DisplayName("유저가 ACE를 가지고, 총합이 21이라서 1로 계산된다.")
    void userSum_Equals_21_AND_ACE_IS_1() {
        Card card = cards.popCard();
        Card card1 = cards.popCard();
        Card card2 = new Card(CardShape.SPADE, CardNumber.ACE); // == 21
        gamer.pickCard(List.of(card2, card1, card));
        assertThat(gamer.getCardSum()).isEqualTo(21);
    }

    @Test
    @DisplayName("유저가 ACE를 가지고, 총합이 21이라서 11로 계산된다")
    void userSum_Equals_21_AND_ACE_IS_11() {
        Card card = cards.popCard();
        Card card2 = new Card(CardShape.SPADE, CardNumber.ACE); // == 21
        gamer.pickCard(List.of(card2, card));
        assertThat(gamer.getCardSum()).isEqualTo(21);
    }

    @Test
    @DisplayName("유저가 ACE를 가지고, 총합이 25가 넘어서 1로 계산된다")
    void userSum_Above_21_AND_ACE_IS_1() {
        Card card = cards.popCard();
        Card card1 = cards.popCard();
        Card card2 = new Card(CardShape.SPADE, CardNumber.ACE);
        Card card3 = new Card(CardShape.SPADE, CardNumber.FIVE); // 이미 총합 25
        gamer.pickCard(List.of(card2, card, card1, card3));
        assertThat(gamer.getCardSum()).isEqualTo(26);
    }

    @Test
    @DisplayName("유저가 ACE를 가지고, ACE가 11로 계산되면 21이 넘어서 1로 계산된다.")
    void userSum_ABOUT_TO_ABOVE_21_AND_ACE_IS_1() {
        Card card = new Card(CardShape.SPADE, CardNumber.SIX);
        Card card3 = new Card(CardShape.SPADE, CardNumber.FIVE); // 이미 11, ACE가 11 이라면 초과해버림 (21을)
        Card card2 = new Card(CardShape.SPADE, CardNumber.ACE);
        gamer.pickCard(List.of(card2, card, card3));
        assertThat(gamer.getCardSum()).isEqualTo(12);
    }

    @Test
    @DisplayName("유저가 ACE를 여러 개 가진다 PLAN1 - ACE3개")
    void user_HAS_SEVERAL_ACE_allACE() {
        Card card = new Card(CardShape.SPADE, CardNumber.ACE);
        Card card3 = new Card(CardShape.SPADE, CardNumber.ACE); // 이미 11, ACE가 11 이라면 초과해버림 (21을)
        Card card2 = new Card(CardShape.SPADE, CardNumber.ACE);
        gamer.pickCard(List.of(card2, card, card3));
        assertThat(gamer.getCardSum()).isEqualTo(13);
    }

    @Test
    @DisplayName("유저가 ACE를 여러 개 가진다 PLAN2 - ACE2개")
    void user_HAS_SEVERAL_TWO_ACE() {
        Card card = new Card(CardShape.SPADE, CardNumber.EIGHT);
        Card card3 = new Card(CardShape.SPADE, CardNumber.ACE); // 이미 11, ACE가 11 이라면 초과해버림 (21을)
        Card card2 = new Card(CardShape.SPADE, CardNumber.ACE);
        gamer.pickCard(List.of(card2, card, card3));
        assertThat(gamer.getCardSum()).isEqualTo(20);
    }
}

이상한 점, 미흡한 점 언제든지 코멘트 부탁드립니다!

0개의 댓글