diff --git a/README.md b/README.md index 8d7e8aee..40881135 100644 --- a/README.md +++ b/README.md @@ -1 +1,152 @@ -# java-baseball-precourse \ No newline at end of file +# 숫자야구 게임 명세서 + +--- + +## 📌 개요 + +숫자야구 게임은 컴퓨터가 생성한 **서로 다른 3자리 숫자**를 플레이어가 맞히는 게임입니다. +플레이어의 입력에 대해 **스트라이크(Strike)** 와 **볼(Ball)** 개수를 판정하며, **3 스트라이크**가 되면 게임이 종료됩니다. + +--- + +## 🧩 주요 클래스 구성 + +### 1. `controller.GameManager` + +* 게임 전체 흐름과 **Phase 전환**을 관리 +* 사용자 입력 검증 및 재입력 루프 처리 + +--- + +### 2. `model.NumberGenerator` + +**역할** + +* 게임에서 사용할 숫자 생성 + +**규칙(현재 구현 기준)** + +* 숫자 범위: **1 ~ 8** +* 생성 개수: **3개** +* 모든 숫자는 **서로 달라야 함** + +--- + +### 3. `model.JudgeManager` + +**역할** + +* 사용자 입력값과 생성된 숫자를 비교하여 결과 판정 + +**판정 내용** + +* 스트라이크(Strike) 개수 +* 볼(Ball) 개수 +* 스트라이크 + 볼 = 0 인 경우 `낫씽` 출력 + +--- + +### 4. `view.InputManager` + +**역할** + +* 콘솔에서 사용자 입력 수신 (`Scanner.next()` 사용) + +--- + +### 5. `view.OutputManager` + +**역할** + +* Phase 시작 메시지, 에러 메시지, 판정 결과 출력 + +--- + +## 🔄 Phase 구성 및 흐름 + +### 1. 시작 단계 + +* 프로그램은 **`GENERATE_NUMBER` Phase**에서 시작 +* 최초 실행 시 **시작 안내 메시지는 출력되지 않고** 바로 숫자 입력을 요청 + +--- + +### 2. `GENERATE_NUMBER` + +**설명** + +* 게임 시작 시 숫자를 생성하는 Phase + +**동작** + +1. `NumberGenerator.generateNumber()`로 숫자 생성 +2. 생성 후 `INPUT_NUMBER` Phase로 이동 + +--- + +### 3. `INPUT_NUMBER` + +**설명** + +* 사용자로부터 숫자를 입력받는 Phase + +**검증 조건** + +* 입력 길이가 3인가? +* 모든 입력이 **1 ~ 9** 사이의 숫자인가? +* 모든 입력 값이 서로 다른가? + +**분기 처리** + +* 입력이 **적절한 경우** + * 입력값 저장 + * `JUDGE_RESULT` Phase로 이동 +* 입력이 **부적절한 경우** + * 에러 메시지 출력 + * `INPUT_NUMBER` Phase 유지 + +--- + +### 4. `JUDGE_RESULT` + +**설명** + +* 입력값을 판정하고 결과를 출력하는 Phase + +**분기 처리** + +* 결과가 **3 Strike**인 경우 + * 축하 메시지 출력 + * `IDLE` Phase로 이동 +* 그 외의 경우 + * 결과 출력 + * `INPUT_NUMBER` Phase로 이동 + +--- + +### 5. `IDLE` + +**설명** + +* 게임을 새로 시작하거나 종료를 선택하는 Phase + +**분기 처리** + +* `1` 입력: `GENERATE_NUMBER` Phase로 이동 (새 게임) +* `2` 입력: 프로그램 종료 +* 그 외 입력: 에러 메시지 출력 후 재입력 + +--- + +## 🧾 출력 메시지 + +* 입력 요청: `숫자를 입력해주세요 : ` +* 잘못된 숫자 입력: `1 ~ 9 사이의 중복되지 않는 세 자리 숫자를 입력해주세요.` +* 게임 재시작/종료 선택: `게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.` +* 정답: `3개의 숫자를 모두 맞히셨습니다! 게임 끝` + +--- + +## 🏁 게임 종료 조건 + +* `IDLE` Phase에서 `2`를 입력하면 프로그램 종료 diff --git a/bin/main/.gitkeep b/bin/main/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/bin/main/App.class b/bin/main/App.class new file mode 100644 index 00000000..e203d85e Binary files /dev/null and b/bin/main/App.class differ diff --git a/bin/main/GameManager.class b/bin/main/GameManager.class new file mode 100644 index 00000000..2e70ca53 Binary files /dev/null and b/bin/main/GameManager.class differ diff --git a/bin/main/InputManager.class b/bin/main/InputManager.class new file mode 100644 index 00000000..1afe6b7c Binary files /dev/null and b/bin/main/InputManager.class differ diff --git a/bin/main/JudgeManager.class b/bin/main/JudgeManager.class new file mode 100644 index 00000000..7e480b9f Binary files /dev/null and b/bin/main/JudgeManager.class differ diff --git a/bin/main/NumberGenerator.class b/bin/main/NumberGenerator.class new file mode 100644 index 00000000..3e809a41 Binary files /dev/null and b/bin/main/NumberGenerator.class differ diff --git a/bin/main/TurnPhase.class b/bin/main/TurnPhase.class new file mode 100644 index 00000000..5b702edd Binary files /dev/null and b/bin/main/TurnPhase.class differ diff --git a/bin/test/.gitkeep b/bin/test/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/build.gradle b/build.gradle index 20a92c9e..82eb7572 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,6 @@ plugins { id 'java' + id 'application' } group = 'camp.nextstep.edu' @@ -11,6 +12,10 @@ java { } } +application { + mainClass = "App" +} + repositories { mavenCentral() } @@ -20,6 +25,10 @@ dependencies { testImplementation 'org.assertj:assertj-core:3.25.3' } +run { + standardInput = System.In +} + test { useJUnitPlatform() } diff --git a/src/main/java/App.java b/src/main/java/App.java new file mode 100644 index 00000000..d8ba08a3 --- /dev/null +++ b/src/main/java/App.java @@ -0,0 +1,11 @@ +import constant.TurnPhase; +import controller.GameManager; + +public class App { + private GameManager _gameManager; + + public static void main(String[] args) { + GameManager _gameManager = new GameManager(TurnPhase.GENERATE_NUMBER); + _gameManager.playGame(); + } +} diff --git a/src/main/java/Constant/ErrorEnum.java b/src/main/java/Constant/ErrorEnum.java new file mode 100644 index 00000000..b8ac14c5 --- /dev/null +++ b/src/main/java/Constant/ErrorEnum.java @@ -0,0 +1,6 @@ +package constant; + +public enum ErrorEnum { + IDLE_INPUT_ERROR, + USER_INPUT_ERROR +} diff --git a/src/main/java/Constant/JudgeResult.java b/src/main/java/Constant/JudgeResult.java new file mode 100644 index 00000000..3dd28663 --- /dev/null +++ b/src/main/java/Constant/JudgeResult.java @@ -0,0 +1,4 @@ +package constant; + +public record JudgeResult(int strike, int ball) { +} diff --git a/src/main/java/Constant/TurnPhase.java b/src/main/java/Constant/TurnPhase.java new file mode 100644 index 00000000..1e76ad0d --- /dev/null +++ b/src/main/java/Constant/TurnPhase.java @@ -0,0 +1,9 @@ +package constant; + +public enum TurnPhase { + QUIT, + IDLE, + GENERATE_NUMBER, + INPUT_NUMBER, + JUDGE_RESULT +} diff --git a/src/main/java/Controller/GameManager.java b/src/main/java/Controller/GameManager.java new file mode 100644 index 00000000..7e9dee25 --- /dev/null +++ b/src/main/java/Controller/GameManager.java @@ -0,0 +1,106 @@ +package controller; + +import java.util.HashMap; +import java.util.Map; + +import constant.ErrorEnum; +import constant.JudgeResult; +import constant.TurnPhase; +import model.JudgeManager; +import model.NumberGenerator; +import view.InputManager; +import view.OutputManager; + +public class GameManager { + private static final int BASEBALL_SIZE = 3; + + private TurnPhase _turnPhase; + private final NumberGenerator _numberGenerator; + private final InputManager _inputManager; + private final JudgeManager _judgeManager; + private final OutputManager _outputManager; + private String _generatedNumber; + private String _userNumber; + private final Map _phaseHandler; + + public GameManager(TurnPhase turnPhase) { + _turnPhase = turnPhase; + _numberGenerator = new NumberGenerator(); + _inputManager = new InputManager(); + _judgeManager = new JudgeManager(); + _outputManager = new OutputManager(); + + _phaseHandler = new HashMap<>(); + _phaseHandler.put(TurnPhase.IDLE, this::handleIdlePhase); + _phaseHandler.put(TurnPhase.GENERATE_NUMBER, this::handleGenerateNumberPhase); + _phaseHandler.put(TurnPhase.INPUT_NUMBER, this::handleInputNumberPhase); + _phaseHandler.put(TurnPhase.JUDGE_RESULT, this::handleJudgeResultPhase); + } + + private void handleIdlePhase() { + while (true) { + String idleInput = _inputManager.getInput(); + if (idleInput.equals("1")) { + _turnPhase = TurnPhase.GENERATE_NUMBER; + _generatedNumber = ""; + return; + } + if (idleInput.equals("2")) { + _turnPhase = TurnPhase.QUIT; + return; + } + _outputManager.printErrorMessage(ErrorEnum.IDLE_INPUT_ERROR); + } + } + + private void handleGenerateNumberPhase() { + _generatedNumber = _numberGenerator.generateNumber(); + _turnPhase = TurnPhase.INPUT_NUMBER; + } + + private boolean isProperInput(String input) { + if (input.length() != BASEBALL_SIZE) { + return false; + } + Map map = new HashMap<>(); + for (int i = 0; i < BASEBALL_SIZE; i++) { + char character = input.charAt(i); + if (character < '1' || character > '9' || map.containsKey(character)) { + return false; + } + map.put(character, ""); + } + return true; + } + + private void handleInputNumberPhase() { + while (true) { + String userInput = _inputManager.getInput(); + if (!isProperInput(userInput)) { + _outputManager.printErrorMessage(ErrorEnum.USER_INPUT_ERROR); + continue; + } + _userNumber = userInput; + break; + } + _turnPhase = TurnPhase.JUDGE_RESULT; + } + + private void handleJudgeResultPhase() { + JudgeResult result = _judgeManager.judgeResult(_generatedNumber, _userNumber); + _outputManager.printJudgeResult(result); + if (result.strike() != BASEBALL_SIZE) { + _turnPhase = TurnPhase.INPUT_NUMBER; + return; + } + _turnPhase = TurnPhase.IDLE; + } + + public void playGame() { + while (_turnPhase != TurnPhase.QUIT) { + _outputManager.printPhaseStartMessage(_turnPhase); + Runnable handler = _phaseHandler.get(_turnPhase); + handler.run(); + } + } +} diff --git a/src/main/java/Model/JudgeManager.java b/src/main/java/Model/JudgeManager.java new file mode 100644 index 00000000..d7eb2324 --- /dev/null +++ b/src/main/java/Model/JudgeManager.java @@ -0,0 +1,30 @@ +package model; + +import java.util.HashMap; +import java.util.Map; + +import constant.JudgeResult; + +public class JudgeManager { + private static final int BASEBALL_SIZE = 3; + private static final int PLAYER_CNT = 2; + + public JudgeManager() {} + + public JudgeResult judgeResult(String number, String user) { + Map map = new HashMap<>(); + for (int i = 0; i < BASEBALL_SIZE; i++) { + map.put(number.charAt(i), ""); + map.put(user.charAt(i), ""); + } + int ball = BASEBALL_SIZE * PLAYER_CNT - map.size(); + int strike = 0; + for (int i = 0; i < BASEBALL_SIZE; i++) { + if (number.charAt(i) == user.charAt(i)) { + strike++; + } + } + ball -= strike; + return new JudgeResult(strike, ball); + } +} diff --git a/src/main/java/Model/NumberGenerator.java b/src/main/java/Model/NumberGenerator.java new file mode 100644 index 00000000..5fd4de8c --- /dev/null +++ b/src/main/java/Model/NumberGenerator.java @@ -0,0 +1,48 @@ +package model; + +import java.util.HashMap; +import java.util.Map; +import java.util.Random; + +public class NumberGenerator { + private static final int BASEBALL_SIZE = 3; + + public NumberGenerator() {} + + public String generateNumber() { + String result = ""; + Map map = new HashMap<>(); + Random random = new Random(); + while (result.length() < BASEBALL_SIZE) { + String randomNumber = random.nextInt(1, 9) + ""; + if (map.get(randomNumber) != null) { + continue; + } + map.put(randomNumber, ""); + result += randomNumber; + } + return result; + } + + public String generateFixedNumber(String fixedNumber) { + if (!isProperNumber(fixedNumber)) { + throw new IllegalArgumentException("Invalid fixed number"); + } + return fixedNumber; + } + + private boolean isProperNumber(String number) { + if (number == null || number.length() != BASEBALL_SIZE) { + return false; + } + Map map = new HashMap<>(); + for (int i = 0; i < BASEBALL_SIZE; i++) { + char c = number.charAt(i); + if (c < '1' || c > '9' || map.containsKey(c)) { + return false; + } + map.put(c, ""); + } + return true; + } +} diff --git a/src/main/java/View/InputManager.java b/src/main/java/View/InputManager.java new file mode 100644 index 00000000..a24d2c28 --- /dev/null +++ b/src/main/java/View/InputManager.java @@ -0,0 +1,15 @@ +package view; + +import java.util.Scanner; + +public class InputManager { + private final Scanner scanner; + + public InputManager() { + scanner = new Scanner(System.in); + } + + public String getInput() { + return scanner.next(); + } +} diff --git a/src/main/java/View/OutputManager.java b/src/main/java/View/OutputManager.java new file mode 100644 index 00000000..6f762242 --- /dev/null +++ b/src/main/java/View/OutputManager.java @@ -0,0 +1,50 @@ +package view; + +import java.util.HashMap; +import java.util.Map; + +import constant.ErrorEnum; +import constant.JudgeResult; +import constant.TurnPhase; + +public class OutputManager { + // errorEnumStringMap의 string에는 뒤에 \n을 넣어 출력 메서드는 println이 아닌 print를 사용 + private final Map _errorEnumStringMap; + private final Map _phaseStartMessageMap; + + public OutputManager() { + _errorEnumStringMap = new HashMap<>(); + _errorEnumStringMap.put(ErrorEnum.IDLE_INPUT_ERROR, "1 혹은 2를 입력해주세요.\n"); + _errorEnumStringMap.put( + ErrorEnum.USER_INPUT_ERROR, + "1 ~ 9 사이의 중복되지 않는 세 자리 숫자를 입력해주세요.\n숫자를 입력해주세요 : " + ); + + _phaseStartMessageMap = new HashMap<>(); + _phaseStartMessageMap.put(TurnPhase.IDLE, "게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.\n"); + _phaseStartMessageMap.put(TurnPhase.INPUT_NUMBER, "숫자를 입력해주세요 : "); + } + + public void printErrorMessage(ErrorEnum errorEnum) { + if (_errorEnumStringMap.containsKey(errorEnum)) { + System.out.print(_errorEnumStringMap.get(errorEnum)); + } + } + + public void printPhaseStartMessage(TurnPhase turnPhase) { + if (_phaseStartMessageMap.containsKey(turnPhase)) { + System.out.print(_phaseStartMessageMap.get(turnPhase)); + } + } + + public void printJudgeResult(JudgeResult result) { + if (result.strike() == 3) { + System.out.println("3개의 숫자를 모두 맞히셨습니다! 게임 끝"); + return; + } + String resultString = result.strike() + result.ball() == 0 + ? "낫씽" + : result.strike() + "스트라이크 " + result.ball() + "볼"; + System.out.println(resultString); + } +} diff --git a/src/test/java/AppTest.java b/src/test/java/AppTest.java new file mode 100644 index 00000000..728461a8 --- /dev/null +++ b/src/test/java/AppTest.java @@ -0,0 +1,17 @@ +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + +import org.junit.jupiter.api.Test; + +class AppTest { + @Test + void mainMethodHasExpectedSignature() throws Exception { + Method main = App.class.getDeclaredMethod("main", String[].class); + + assertThat(main.getReturnType()).isEqualTo(void.class); + assertThat(Modifier.isPublic(main.getModifiers())).isTrue(); + assertThat(Modifier.isStatic(main.getModifiers())).isTrue(); + } +} diff --git a/src/test/java/Model/JudgeManagerTest.java b/src/test/java/Model/JudgeManagerTest.java new file mode 100644 index 00000000..51627c1c --- /dev/null +++ b/src/test/java/Model/JudgeManagerTest.java @@ -0,0 +1,52 @@ +package model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import constant.JudgeResult; + +class JudgeManagerTest { + private static Stream judgeCases() { + return Stream.of( + Arguments.of("456", 0, 0), + Arguments.of("415", 0, 1), + Arguments.of("314", 0, 2), + Arguments.of("312", 0, 3), + Arguments.of("145", 1, 0), + Arguments.of("134", 1, 1), + Arguments.of("132", 1, 2), + Arguments.of("124", 2, 0), + Arguments.of("123", 3, 0) + ); + } + + @ParameterizedTest + @MethodSource("judgeCases") + void judgeResultCoversValidStrikeBallCombinations(String userNumber, int strike, int ball) { + JudgeManager judgeManager = new JudgeManager(); + + JudgeResult result = judgeManager.judgeResult("123", userNumber); + + assertThat(result.strike()).isEqualTo(strike); + assertThat(result.ball()).isEqualTo(ball); + } + + @Test + void twoStrikeOneBallIsNotReachableWithUniqueDigits() { + JudgeManager judgeManager = new JudgeManager(); + String[] permutations = {"123", "132", "213", "231", "312", "321"}; + + for (String userNumber : permutations) { + JudgeResult result = judgeManager.judgeResult("123", userNumber); + assertThat(result.strike() == 2 && result.ball() == 1) + .as("2S1B should be unreachable for %s", userNumber) + .isFalse(); + } + } +} diff --git a/src/test/java/Model/NumberGeneratorTest.java b/src/test/java/Model/NumberGeneratorTest.java new file mode 100644 index 00000000..930eafdf --- /dev/null +++ b/src/test/java/Model/NumberGeneratorTest.java @@ -0,0 +1,34 @@ +package model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class NumberGeneratorTest { + @Test + void generateFixedNumberReturnsSameValueWhenValid() { + NumberGenerator generator = new NumberGenerator(); + + assertThat(generator.generateFixedNumber("123")).isEqualTo("123"); + } + + @ParameterizedTest + @ValueSource(strings = {"12", "1234", "012", "11", "1a3", "9 1", "990"}) + void generateFixedNumberRejectsInvalidValues(String value) { + NumberGenerator generator = new NumberGenerator(); + + assertThatThrownBy(() -> generator.generateFixedNumber(value)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void generateFixedNumberRejectsNull() { + NumberGenerator generator = new NumberGenerator(); + + assertThatThrownBy(() -> generator.generateFixedNumber(null)) + .isInstanceOf(IllegalArgumentException.class); + } +}