기능 요구 사항
블랙잭 게임을 변형한 프로그램을 구현한다.
블랙잭 게임은 딜러와 플레이어 중 카드의 합이 21 또는 21에 가장 가까운 숫자를 가지는 쪽이 이기는 게임이다.
플레이어는 게임을 시작할 때 배팅 금액을 정해야 한다.
카드의 숫자 계산은 카드 숫자를 기본으로 하며, 예외로 Ace는 1 또는 11로 계산할 수 있으며,
King, Queen, Jack은 각각 10으로 계산한다.
게임을 시작하면 플레이어는 두 장의 카드를 지급 받으며,
두 장의 카드 숫자를 합쳐 21을 초과하지 않으면서 21에 가깝게 만들면 이긴다.
21을 넘지 않을 경우 원한다면 얼마든지 카드를 계속 뽑을 수 있다.
단, 카드를 추가로 뽑아 21을 초과할 경우 배팅 금액을 모두 잃게 된다.
처음 두 장의 카드 합이 21일 경우 블랙잭이 되면 베팅 금액의 1.5 배를 딜러에게 받는다.
딜러와 플레이어가 모두 동시에 블랙잭인 경우 플레이어는 베팅한 금액을 돌려받는다.
딜러는 처음에 받은 2장의 합계가 16이하이면 반드시 1장의 카드를 추가로 받아야 하고, 17점 이상이면 추가로 받을 수 없다.
딜러가 21을 초과하면 그 시점까지 남아 있던 플레이어들은 가지고 있는 패에 상관 없이 승리해 베팅 금액을 받는다.
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;
}
}
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()) 이 존재한다.
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;
}
}
해당 클래스의 상태 (프로퍼티) 는 다음과 같다
그리고, 아래에는 여러 핵심 매서드들이 보인다. 그 중에서 가~장 핵심적인 녀석은 이것이다
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()) 위와 같이 나눔으로써 테스트가 용이해진다.
제일 중요한 건, 유저의 상태를 어떻게 관리 할 것인가? 이다.
유저의 손패를 계산해서, 유저가
더 받을 수 있는지
더 받을 수 없는지
블랙잭인지 (==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);
}
}