diff --git a/README.md b/README.md index 8d7e8aee..e2da9c64 100644 --- a/README.md +++ b/README.md @@ -1 +1,34 @@ -# java-baseball-precourse \ No newline at end of file +# java-baseball-precourse + +### 문제 +- 기본적으로 1부터 9까지 서로 다른 수로 이루어진 3자리의 수를 맞추는 게임이다. +- 같은 수가 같은 자리에 있으면 스트라이크, 다른 자리에 있으면 볼, 같은 수가 전혀 없으면 포볼 또는 낫싱이란 힌트를 +얻고, 그 힌트를 이용해서 먼저 상대방(컴퓨터)의 수를 맞추면 승리한다. + - [예] 상대방(컴퓨터)의 수가 425일 때, 123을 제시한 경우 : 1스트라이크, 456을 제시한 경우 : 1스트라이크 1볼, + 789를 제시한 경우 : 낫싱 +- 위 숫자 야구게임에서 상대방의 역할을 컴퓨터가 한다. 컴퓨터는 1에서 9까지 서로 다른 임의의 수 3개를 선택한다. 게임 플레이어는 컴퓨터가 생각하고 있는 3개의 숫자를 입력하고, 컴퓨터는 입력한 숫자에 대한 결과를 출력한다. +- 이 같은 과정을 반복해 컴퓨터가 선택한 3개의 숫자를 모두 맞히면 게임이 종료된다. +- 게임을 종료한 후 게임을 다시 시작하거나 완전히 종료할 수 있다. +- 사용자가 잘못된 값을 입력할 경우 [ERROR]로 시작하는 에러 메시지를 출력하고 게임을 계속 진행할 수 있어야 + +### 구조 +- 입력기 (controller) +- 출력기 (view) +- baseball 판독기 (model) +- baseball 정답 생성기 (model) +- baseball 게임 진행 (model) +- 에러 핸들러 + +### 기능 요구사항 +- [X] baseball 정답 생성 + - [X] 1부터 9까지의 다른 수로 이루어진 3자리의 수를 생성합니다. + - 제약 사항: 1부터 9까지만, 정답 길이 3 +- [X] baseball 정답 판독 + - [X] 정답과 비교해서 자리와 숫자가 모두 같은 경우 strike의 카운트를 반환합니다. + - [X] 정답과 비교해서 숫자만 같고 자리만 같을 경우 ball 의 카운트를 올립니다. +- [X] baseball 게임 진행 + - [X] 게임이 시작된 상황이라면 숫자를 입력 받고 판독 결과를 반환합니다. + - [X] 게임이 진행중이 아니라면 1의 입력의 경우 정답 생성을 요청하고, 숫자 입력을 요구합니다. + - [X] 게임이 진행중이 아니라면 2의 입력의 경우 게임을 종료합니다. +- [X] 입력기 +- [X] 출력기 diff --git a/src/main/java/Main.java b/src/main/java/Main.java new file mode 100644 index 00000000..35200557 --- /dev/null +++ b/src/main/java/Main.java @@ -0,0 +1,10 @@ +import config.GameConfig; +import controller.GameController; + +public class Main { + + public static void main(String[] args) { + GameController gameController = new GameConfig().gameController(); + gameController.run(); + } +} diff --git a/src/main/java/config/GameConfig.java b/src/main/java/config/GameConfig.java new file mode 100644 index 00000000..16a2feb8 --- /dev/null +++ b/src/main/java/config/GameConfig.java @@ -0,0 +1,27 @@ +package config; + +import controller.GameController; +import model.BaseballAnswerGenerator; +import model.BaseballAnswerJudge; +import model.BaseballGameManager; +import model.BaseballInputValidator; +import view.OutputView; + +public class GameConfig { + + public GameController gameController() { + return new GameController( + gameManager(), + new BaseballInputValidator(), + new OutputView() + ); + } + + private BaseballGameManager gameManager() { + return new BaseballGameManager( + new BaseballAnswerGenerator(), + new BaseballAnswerJudge() + ); + } + +} diff --git a/src/main/java/constant/GameConstants.java b/src/main/java/constant/GameConstants.java new file mode 100644 index 00000000..ec10db91 --- /dev/null +++ b/src/main/java/constant/GameConstants.java @@ -0,0 +1,9 @@ +package constant; + +public class GameConstants { + public static final int ANSWER_LENGTH = 3; + public static final int MIN_DIGIT = 1; + public static final int MAX_DIGIT = 9; + public static final char EXCLUDED_DIGIT = '0'; + public static final String RESTART_COMMAND = "1"; +} diff --git a/src/main/java/constant/GameMessages.java b/src/main/java/constant/GameMessages.java new file mode 100644 index 00000000..078df844 --- /dev/null +++ b/src/main/java/constant/GameMessages.java @@ -0,0 +1,16 @@ +package constant; + +public class GameMessages { + public static final String GAME_START = "숫자 야구 게임을 시작합니다."; + public static final String INPUT_PROMPT = "숫자를 입력해주세요 : "; + public static final String NOTHING = "낫싱"; + public static final String BALL = "볼"; + public static final String STRIKE = "스트라이크"; + public static final String GAME_WON = "3개의 숫자를 모두 맞히셨습니다! 게임 종료"; + public static final String RESTART_PROMPT = "게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요."; + + public static final String ERROR_INVALID_LENGTH = "[ERROR] 3자리 숫자를 입력해주세요."; + public static final String ERROR_NOT_DIGIT = "[ERROR] 숫자만 입력해주세요."; + public static final String ERROR_INVALID_RANGE = "[ERROR] 1부터 9까지의 숫자만 입력해주세요."; + public static final String ERROR_DUPLICATE = "[ERROR] 중복되지 않는 숫자를 입력해주세요."; +} diff --git a/src/main/java/controller/GameController.java b/src/main/java/controller/GameController.java new file mode 100644 index 00000000..858ac58c --- /dev/null +++ b/src/main/java/controller/GameController.java @@ -0,0 +1,57 @@ +package controller; + +import constant.GameConstants; +import model.GameManager; +import model.InputValidator; +import view.OutputView; + +import java.util.Scanner; + +public class GameController { + + private final GameManager gameManager; + private final InputValidator inputValidator; + private final OutputView outputView; + private final Scanner scanner; + + public GameController(GameManager gameManager, InputValidator inputValidator, OutputView outputView) { + this.gameManager = gameManager; + this.inputValidator = inputValidator; + this.outputView = outputView; + this.scanner = new Scanner(System.in); + } + + public void run() { + outputView.printGameStart(); + while (true) { + playGame(); + if (!askRestart()) break; + } + } + + private void playGame() { + gameManager.startGame(); + while (gameManager.isPlaying()) { + outputView.printInputPrompt(); + String input = scanner.nextLine(); + processGuess(input); + } + outputView.printGameWon(); + } + + private void processGuess(String input) { + try { + inputValidator.validate(input); + int[] result = gameManager.guess(input); + outputView.printResult(result[0], result[1]); + } catch (IllegalArgumentException e) { + outputView.printError(e.getMessage()); + } + } + + private boolean askRestart() { + outputView.printRestartPrompt(); + String input = scanner.nextLine(); + return input.equals(GameConstants.RESTART_COMMAND); + } +} diff --git a/src/main/java/model/AnswerGenerator.java b/src/main/java/model/AnswerGenerator.java new file mode 100644 index 00000000..f48f03b0 --- /dev/null +++ b/src/main/java/model/AnswerGenerator.java @@ -0,0 +1,7 @@ +package model; + +public interface AnswerGenerator { + + String generateAnswer(); + +} diff --git a/src/main/java/model/AnswerJudge.java b/src/main/java/model/AnswerJudge.java new file mode 100644 index 00000000..1cb4941d --- /dev/null +++ b/src/main/java/model/AnswerJudge.java @@ -0,0 +1,8 @@ +package model; + +public interface AnswerJudge { + + Integer countStrikes(String input, String answer); + + Integer countBalls(String input, String answer); +} diff --git a/src/main/java/model/BaseballAnswerGenerator.java b/src/main/java/model/BaseballAnswerGenerator.java new file mode 100644 index 00000000..b56bce45 --- /dev/null +++ b/src/main/java/model/BaseballAnswerGenerator.java @@ -0,0 +1,29 @@ +package model; + +import constant.GameConstants; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; + +public class BaseballAnswerGenerator implements AnswerGenerator { + + @Override + public String generateAnswer() { + Set numbers = new HashSet<>(); + + while (numbers.size() < GameConstants.ANSWER_LENGTH) { + numbers.add(ThreadLocalRandom.current().nextInt( + GameConstants.MIN_DIGIT, + GameConstants.MAX_DIGIT + 1 + )); + } + + StringBuilder answer = new StringBuilder(); + for (int n : numbers) { + answer.append(n); + } + + return answer.toString(); + } +} diff --git a/src/main/java/model/BaseballAnswerJudge.java b/src/main/java/model/BaseballAnswerJudge.java new file mode 100644 index 00000000..b280c985 --- /dev/null +++ b/src/main/java/model/BaseballAnswerJudge.java @@ -0,0 +1,29 @@ +package model; + +import constant.GameConstants; + +public class BaseballAnswerJudge implements AnswerJudge { + + @Override + public Integer countStrikes(String input, String answer) { + int result = 0; + for (int index = 0; index < GameConstants.ANSWER_LENGTH; index++) { + if (input.charAt(index) == answer.charAt(index)) { + result++; + } + } + return result; + } + + @Override + public Integer countBalls(String input, String answer) { + int result = 0; + for (int index = 0; index < GameConstants.ANSWER_LENGTH; index++) { + char item = input.charAt(index); + if (item != answer.charAt(index) && answer.indexOf(item) != -1) { + result++; + } + } + return result; + } +} diff --git a/src/main/java/model/BaseballGameManager.java b/src/main/java/model/BaseballGameManager.java new file mode 100644 index 00000000..06ac6a25 --- /dev/null +++ b/src/main/java/model/BaseballGameManager.java @@ -0,0 +1,48 @@ +package model; + +import constant.GameConstants; + +public class BaseballGameManager implements GameManager { + private final AnswerGenerator answerGenerator; + private final AnswerJudge answerJudge; + private String answer; + private boolean playing; + private boolean gameWon; + + public BaseballGameManager(AnswerGenerator answerGenerator, AnswerJudge answerJudge) { + this.answerGenerator = answerGenerator; + this.answerJudge = answerJudge; + this.playing = false; + this.gameWon = false; + } + + @Override + public void startGame() { + this.answer = answerGenerator.generateAnswer(); + this.playing = true; + this.gameWon = false; + } + + @Override + public int[] guess(String input) { + int strikes = answerJudge.countStrikes(input, answer); + int balls = answerJudge.countBalls(input, answer); + + if (strikes == GameConstants.ANSWER_LENGTH) { + this.playing = false; + this.gameWon = true; + } + + return new int[]{strikes, balls}; + } + + @Override + public boolean isPlaying() { + return playing; + } + + @Override + public boolean isGameWon() { + return gameWon; + } +} diff --git a/src/main/java/model/BaseballInputValidator.java b/src/main/java/model/BaseballInputValidator.java new file mode 100644 index 00000000..4b1d3a1c --- /dev/null +++ b/src/main/java/model/BaseballInputValidator.java @@ -0,0 +1,49 @@ +package model; + +import constant.GameConstants; +import constant.GameMessages; + +import java.util.HashSet; +import java.util.Set; + +public class BaseballInputValidator implements InputValidator { + + @Override + public void validate(String input) { + validateLength(input); + validateDigitsOnly(input); + validateRange(input); + validateNoDuplicates(input); + } + + private void validateLength(String input) { + if (input.length() != GameConstants.ANSWER_LENGTH) { + throw new IllegalArgumentException(GameMessages.ERROR_INVALID_LENGTH); + } + } + + private void validateDigitsOnly(String input) { + for (char c : input.toCharArray()) { + if (!Character.isDigit(c)) { + throw new IllegalArgumentException(GameMessages.ERROR_NOT_DIGIT); + } + } + } + + private void validateRange(String input) { + for (char c : input.toCharArray()) { + if (c == GameConstants.EXCLUDED_DIGIT) { + throw new IllegalArgumentException(GameMessages.ERROR_INVALID_RANGE); + } + } + } + + private void validateNoDuplicates(String input) { + Set seen = new HashSet<>(); + for (char c : input.toCharArray()) { + if (!seen.add(c)) { + throw new IllegalArgumentException(GameMessages.ERROR_DUPLICATE); + } + } + } +} diff --git a/src/main/java/model/GameManager.java b/src/main/java/model/GameManager.java new file mode 100644 index 00000000..054db87f --- /dev/null +++ b/src/main/java/model/GameManager.java @@ -0,0 +1,13 @@ +package model; + +public interface GameManager { + + void startGame(); + + int[] guess(String input); + + boolean isPlaying(); + + boolean isGameWon(); + +} diff --git a/src/main/java/model/InputValidator.java b/src/main/java/model/InputValidator.java new file mode 100644 index 00000000..8715129d --- /dev/null +++ b/src/main/java/model/InputValidator.java @@ -0,0 +1,7 @@ +package model; + +public interface InputValidator { + + void validate(String input); + +} diff --git a/src/main/java/view/OutputView.java b/src/main/java/view/OutputView.java new file mode 100644 index 00000000..4d48c225 --- /dev/null +++ b/src/main/java/view/OutputView.java @@ -0,0 +1,42 @@ +package view; + +import constant.GameMessages; + +public class OutputView { + + public void printGameStart() { + System.out.println(GameMessages.GAME_START); + } + + public void printInputPrompt() { + System.out.print(GameMessages.INPUT_PROMPT); + } + + public void printResult(int strikes, int balls) { + if (strikes == 0 && balls == 0) { + System.out.println(GameMessages.NOTHING); + return; + } + + StringBuilder result = new StringBuilder(); + if (balls > 0) { + result.append(balls).append(GameMessages.BALL).append(" "); + } + if (strikes > 0) { + result.append(strikes).append(GameMessages.STRIKE); + } + System.out.println(result.toString().trim()); + } + + public void printGameWon() { + System.out.println(GameMessages.GAME_WON); + } + + public void printRestartPrompt() { + System.out.println(GameMessages.RESTART_PROMPT); + } + + public void printError(String message) { + System.out.println(message); + } +} diff --git a/src/test/java/BaseballAnswerGeneratorTest.java b/src/test/java/BaseballAnswerGeneratorTest.java new file mode 100644 index 00000000..f1fe8e33 --- /dev/null +++ b/src/test/java/BaseballAnswerGeneratorTest.java @@ -0,0 +1,46 @@ +import model.AnswerGenerator; +import model.BaseballAnswerGenerator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class BaseballAnswerGeneratorTest { + + private AnswerGenerator answerGenerator; + + @BeforeEach + void setup() { + answerGenerator = new BaseballAnswerGenerator(); + } + + @DisplayName("정답은 3자리 숫자이다") + @Test + void generateAnswer_returnsLengthThree() { + String answer = answerGenerator.generateAnswer(); + assertThat(answer.length()).isEqualTo(3); + } + + @DisplayName("정답은 0을 포함하지 않는다") + @Test + void generateAnswer_doesNotContainZero() { + String answer = answerGenerator.generateAnswer(); + assertThat(answer).doesNotContain("0"); + } + + @DisplayName("정답은 숫자로만 이루어져 있다") + @Test + void generateAnswer_containsOnlyDigits() { + String answer = answerGenerator.generateAnswer(); + assertThat(answer).matches("\\d+"); + } + + @DisplayName("정답의 모든 숫자는 서로 다르다") + @Test + void generateAnswer_hasNoDuplicateDigits() { + String answer = answerGenerator.generateAnswer(); + assertThat(answer.chars().distinct().count()) + .isEqualTo(answer.length()); + } +} diff --git a/src/test/java/BaseballAnswerJudgeTest.java b/src/test/java/BaseballAnswerJudgeTest.java new file mode 100644 index 00000000..d5862401 --- /dev/null +++ b/src/test/java/BaseballAnswerJudgeTest.java @@ -0,0 +1,59 @@ +import model.AnswerJudge; +import model.BaseballAnswerJudge; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class BaseballAnswerJudgeTest { + + private AnswerJudge answerJudge; + + @BeforeEach + void setup() { + answerJudge = new BaseballAnswerJudge(); + } + + @DisplayName("같은 위치의 숫자는 스트라이크로 판정한다") + @Test + void countStrikes_returnsCorrectCount_when_positionsMatch() { + int strikes = answerJudge.countStrikes("123", "153"); + assertThat(strikes).isEqualTo(2); + } + + @DisplayName("같은 위치의 숫자가 없으면 스트라이크는 0이다") + @Test + void countStrikes_returnsZero_when_noPositionMatches() { + int strikes = answerJudge.countStrikes("456", "123"); + assertThat(strikes).isZero(); + } + + @DisplayName("모든 숫자와 위치가 같으면 스트라이크는 3이다") + @Test + void countStrikes_returnsThree_when_guessEqualsAnswer() { + int strikes = answerJudge.countStrikes("123", "123"); + assertThat(strikes).isEqualTo(3); + } + + @DisplayName("같은 위치의 숫자는 볼로 판정하지 않는다.") + @Test + void countBalls_returnsCorrectCount_when_positionsMatch() { + int balls = answerJudge.countBalls("123", "153"); + assertThat(balls).isEqualTo(0); + } + + @DisplayName("같은 숫자가 위치가 다르면 볼이다.") + @Test + void countBalls_returnsOne_when_noPositionMatches_OneValueMatches() { + int balls = answerJudge.countBalls("456", "124"); + assertThat(balls).isEqualTo(1); + } + + @DisplayName("같은 숫자가 없으면 볼은 0이다.") + @Test + void countBalls_returnsZero_when_noPositionMatches() { + int balls = answerJudge.countBalls("123", "456"); + assertThat(balls).isZero(); + } +} diff --git a/src/test/java/BaseballGameManagerTest.java b/src/test/java/BaseballGameManagerTest.java new file mode 100644 index 00000000..34c08dc4 --- /dev/null +++ b/src/test/java/BaseballGameManagerTest.java @@ -0,0 +1,76 @@ +import model.AnswerGenerator; +import model.AnswerJudge; +import model.BaseballAnswerJudge; +import model.BaseballGameManager; +import model.GameManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class BaseballGameManagerTest { + + private GameManager gameManager; + private AnswerJudge answerJudge; + + @BeforeEach + void setup() { + AnswerGenerator fixedAnswerGenerator = () -> "123"; + answerJudge = new BaseballAnswerJudge(); + gameManager = new BaseballGameManager(fixedAnswerGenerator, answerJudge); + } + + @DisplayName("게임 시작 전에는 플레이 상태가 아니다") + @Test + void isPlaying_returnsFalse_beforeGameStart() { + assertThat(gameManager.isPlaying()).isFalse(); + } + + @DisplayName("게임 시작 후에는 플레이 상태이다") + @Test + void isPlaying_returnsTrue_afterGameStart() { + gameManager.startGame(); + assertThat(gameManager.isPlaying()).isTrue(); + } + + @DisplayName("추측 시 스트라이크와 볼 개수를 반환한다") + @Test + void guess_returnsStrikesAndBalls() { + gameManager.startGame(); + int[] result = gameManager.guess("145"); + assertThat(result[0]).isEqualTo(1); + assertThat(result[1]).isEqualTo(0); + } + + @DisplayName("3스트라이크 시 게임이 종료된다") + @Test + void guess_endsGame_whenThreeStrikes() { + gameManager.startGame(); + int[] result = gameManager.guess("123"); + assertThat(result[0]).isEqualTo(3); + assertThat(gameManager.isPlaying()).isFalse(); + assertThat(gameManager.isGameWon()).isTrue(); + } + + @DisplayName("3스트라이크가 아니면 게임이 계속된다") + @Test + void guess_continuesGame_whenNotThreeStrikes() { + gameManager.startGame(); + gameManager.guess("456"); + assertThat(gameManager.isPlaying()).isTrue(); + assertThat(gameManager.isGameWon()).isFalse(); + } + + @DisplayName("게임 재시작 시 상태가 초기화된다") + @Test + void startGame_resetsState_afterGameWon() { + gameManager.startGame(); + gameManager.guess("123"); + assertThat(gameManager.isGameWon()).isTrue(); + + gameManager.startGame(); + assertThat(gameManager.isPlaying()).isTrue(); + assertThat(gameManager.isGameWon()).isFalse(); + } +} diff --git a/src/test/java/BaseballInputValidatorTest.java b/src/test/java/BaseballInputValidatorTest.java new file mode 100644 index 00000000..fb494d73 --- /dev/null +++ b/src/test/java/BaseballInputValidatorTest.java @@ -0,0 +1,57 @@ +import model.BaseballInputValidator; +import model.InputValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatCode; + +public class BaseballInputValidatorTest { + + private InputValidator validator; + + @BeforeEach + void setup() { + validator = new BaseballInputValidator(); + } + + @DisplayName("올바른 입력은 예외가 발생하지 않는다") + @Test + void validate_doesNotThrow_whenValidInput() { + assertThatCode(() -> validator.validate("123")) + .doesNotThrowAnyException(); + } + + @DisplayName("3자리가 아니면 예외가 발생한다") + @Test + void validate_throwsException_whenLengthIsNotThree() { + assertThatThrownBy(() -> validator.validate("12")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR]"); + } + + @DisplayName("숫자가 아닌 문자가 포함되면 예외가 발생한다") + @Test + void validate_throwsException_whenContainsNonDigit() { + assertThatThrownBy(() -> validator.validate("12a")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR]"); + } + + @DisplayName("0이 포함되면 예외가 발생한다") + @Test + void validate_throwsException_whenContainsZero() { + assertThatThrownBy(() -> validator.validate("012")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR]"); + } + + @DisplayName("중복된 숫자가 있으면 예외가 발생한다") + @Test + void validate_throwsException_whenHasDuplicates() { + assertThatThrownBy(() -> validator.validate("112")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR]"); + } +}