diff --git a/README.md b/README.md index 8d7e8aee..45018dbb 100644 --- a/README.md +++ b/README.md @@ -1 +1,47 @@ -# java-baseball-precourse \ No newline at end of file +# java-baseball-precourse + +1. 게임 시작 및 초기화 +- 애플리케이션 실행 시 숫자 야구 게임을 시작한다. +- 컴퓨터의 정답 숫자 3자리를 생성한다. +- 1~9 사이의 숫자만 사용한다. +- 중복되지 않는 서로 다른 숫자 3개로 구성한다. +- 3자리 수(문자열 또는 리스트 등)로 관리한다. + +--- +2. 사용자 입력 처리 +- 사용자로부터 3자리 숫자를 입력받는다. +- 입력값 검증을 수행한다. +- 길이가 3인지 확인한다. +- 모두 숫자인지 확인한다. +- 각 자리가 1~9인지 확인한다. +- 중복 숫자가 없는지 확인한다. +- 잘못된 입력이면 예외 처리 후 에러 메시지를 출력한다. +- [ERROR]로 시작하는 메시지를 출력한다. +- 에러 이후에도 게임은 종료되지 않고 다시 입력을 받는다. + +--- +3. 결과(힌트) 판정 +- 사용자 입력과 컴퓨터 정답을 비교하여 스트라이크/볼/낫싱을 계산한다. +- 같은 숫자가 같은 자리면 스트라이크 +- 같은 숫자가 다른 자리면 볼 +- 공통 숫자가 하나도 없으면 낫싱 +- 판정 결과를 출력한다. +- 스트라이크/볼이 존재하면 조합 형태로 출력한다. (예: 1스트라이크 1볼) +- 아무것도 없으면 낫싱을 출력한다. + +--- +4. 게임 진행 루프 +- 사용자가 정답 3자리를 모두 맞힐 때까지(3스트라이크) 입력 → 판정 → 출력 과정을 반복한다. +- 3스트라이크가 되면 게임 종료 메시지를 출력한다. + +--- +5. 게임 종료 후 재시작/종료 +- 게임 종료 후 사용자에게 재시작/종료 입력을 받는다. +- 1 입력 시 게임을 다시 시작한다 (정답 숫자 재생성). +- 2 입력 시 프로그램을 완전히 종료한다. +- 재시작/종료 입력값이 잘못된 경우에도 예외 처리한다. +- [ERROR]로 시작하는 메시지를 출력한다. +- 다시 입력을 받는다. + +## UML +Image diff --git a/build.gradle b/build.gradle index 20a92c9e..c3026c4c 100644 --- a/build.gradle +++ b/build.gradle @@ -18,6 +18,8 @@ repositories { dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testImplementation 'org.assertj:assertj-core:3.25.3' + compileOnly 'org.projectlombok:lombok:1.18.30' + annotationProcessor 'org.projectlombok:lombok:1.18.30' } test { diff --git a/src/main/java/.gitkeep b/src/main/java/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/Application.java b/src/main/java/Application.java new file mode 100644 index 00000000..a77514f2 --- /dev/null +++ b/src/main/java/Application.java @@ -0,0 +1,12 @@ +import baseball.controller.BaseballGame; +import baseball.model.ComputerNumber; +import baseball.view.InputView; +import baseball.view.OutputView; + +public class Application { + + public static void main(String[] args) { + BaseballGame controller = new BaseballGame(new InputView(), new OutputView(), ComputerNumber::createRandom); + controller.run(); + } +} diff --git a/src/main/java/baseball/controller/BaseballGame.java b/src/main/java/baseball/controller/BaseballGame.java new file mode 100644 index 00000000..b0bfa40a --- /dev/null +++ b/src/main/java/baseball/controller/BaseballGame.java @@ -0,0 +1,72 @@ +package baseball.controller; + +import baseball.exception.CommonErrorCode; +import baseball.exception.InvalidInputException; +import lombok.RequiredArgsConstructor; +import baseball.model.ComputerNumber; +import baseball.model.Judge; +import baseball.model.Result; +import baseball.model.UserGuess; +import baseball.view.InputView; +import baseball.view.OutputView; + +import java.util.function.Supplier; + +@RequiredArgsConstructor +public class BaseballGame { + private final InputView inputView; + private final OutputView outputView; + private final Supplier computerNumberSupplier; + + public void run() { + while (true) { + playSingleGame(); + if (!askRestart()) { + return; + } + } + } + + private void playSingleGame() { + ComputerNumber targetNum = computerNumberSupplier.get(); + + outputView.printStartMessage(); + + while (true) { + try { + String raw = inputView.readGuess(); + UserGuess guess = UserGuess.from(raw); + + Result result = Judge.judge(targetNum, guess); + outputView.printHint(result); + + if (result.isThreeStrike()) { + outputView.printGameEndMessage(); + break; + } + } catch (InvalidInputException e) { + outputView.printError(e.getMessage()); + } + + } + } + + private boolean askRestart() { + while (true) { + try { + String cmd = inputView.readRestartCommand(); + validateRestartCommand(cmd); + + return "1".equals(cmd); + } catch (InvalidInputException e) { + outputView.printError(e.getMessage()); + } + } + } + + private void validateRestartCommand(String cmd) { + if (!"1".equals(cmd) && !"2".equals(cmd)) { + throw new InvalidInputException(CommonErrorCode.INVALID_COMMAND); + } + } +} diff --git a/src/main/java/baseball/exception/CommonErrorCode.java b/src/main/java/baseball/exception/CommonErrorCode.java new file mode 100644 index 00000000..a1464d2b --- /dev/null +++ b/src/main/java/baseball/exception/CommonErrorCode.java @@ -0,0 +1,19 @@ +package baseball.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum CommonErrorCode implements ErrorCode { + + EMPTY_INPUT("EMPTY_INPUT", "입력값이 비어있습니다."), + INVALID_LENGTH("INVALID_LENGTH", "3자리 숫자를 입력해야 합니다."), + NOT_A_NUMBER("NOT_A_NUMBER", "숫자만 입력해야 합니다."), + NUMBER_OUT_OF_RANGE("NUMBER_OUT_OF_RANGE", "각 자리는 1~9 사이여야 합니다."), + DUPLICATED_NUMBER("DUPLICATED_NUMBER", "중복되지 않는 숫자 3개여야 합니다."), + INVALID_COMMAND("INVALID_COMMAND", "1 또는 2를 입력해야 합니다."); + + private final String code; + private final String message; +} diff --git a/src/main/java/baseball/exception/ErrorCode.java b/src/main/java/baseball/exception/ErrorCode.java new file mode 100644 index 00000000..ae2738c7 --- /dev/null +++ b/src/main/java/baseball/exception/ErrorCode.java @@ -0,0 +1,6 @@ +package baseball.exception; + +public interface ErrorCode { + String getCode(); + String getMessage(); +} diff --git a/src/main/java/baseball/exception/InvalidInputException.java b/src/main/java/baseball/exception/InvalidInputException.java new file mode 100644 index 00000000..5bf7f61c --- /dev/null +++ b/src/main/java/baseball/exception/InvalidInputException.java @@ -0,0 +1,13 @@ +package baseball.exception; + +import lombok.Getter; + +@Getter +public class InvalidInputException extends IllegalArgumentException { + private final CommonErrorCode commonErrorCode; + + public InvalidInputException(CommonErrorCode commonErrorCode) { + super(commonErrorCode.getMessage()); + this.commonErrorCode = commonErrorCode; + } +} diff --git a/src/main/java/baseball/model/ComputerNumber.java b/src/main/java/baseball/model/ComputerNumber.java new file mode 100644 index 00000000..48089a25 --- /dev/null +++ b/src/main/java/baseball/model/ComputerNumber.java @@ -0,0 +1,40 @@ +package baseball.model; + +import lombok.RequiredArgsConstructor; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +@RequiredArgsConstructor +public final class ComputerNumber { + private static final Random RANDOM = new Random(); + private final List digits; + + public static ComputerNumber createRandom() { + Set set = new LinkedHashSet<>(); + while (set.size() < 3) { + int n = RANDOM.nextInt(9) + 1; // 1~9 + set.add(n); // 중복 자동 제거 + } + return new ComputerNumber(new ArrayList<>(set)); + } + + public int digitAt(int index) { + return digits.get(index); + } + + public boolean contains(int digit) { + return digits.contains(digit); + } + + public int size() { + return digits.size(); + } + + public static ComputerNumber of(List digits) { + return new ComputerNumber(digits); + } +} diff --git a/src/main/java/baseball/model/Judge.java b/src/main/java/baseball/model/Judge.java new file mode 100644 index 00000000..a2fa3749 --- /dev/null +++ b/src/main/java/baseball/model/Judge.java @@ -0,0 +1,21 @@ +package baseball.model; + +public final class Judge { + private Judge() { } + + public static Result judge(ComputerNumber answer, UserGuess guess) { + int strike = 0; + int ball = 0; + + for (int i = 0; i < answer.size(); i++) { + int g = guess.digitAt(i); + + if (answer.digitAt(i) == g) { + strike++; + } else if (answer.contains(g)) { + ball++; + } + } + return new Result(strike, ball); + } +} diff --git a/src/main/java/baseball/model/Result.java b/src/main/java/baseball/model/Result.java new file mode 100644 index 00000000..eb1dc971 --- /dev/null +++ b/src/main/java/baseball/model/Result.java @@ -0,0 +1,9 @@ +package baseball.model; + +public record Result(int strike, int ball) { + private static final int DIGIT_COUNT = 3; + + public boolean isThreeStrike() { + return strike == DIGIT_COUNT; + } +} diff --git a/src/main/java/baseball/model/UserGuess.java b/src/main/java/baseball/model/UserGuess.java new file mode 100644 index 00000000..ef283ee4 --- /dev/null +++ b/src/main/java/baseball/model/UserGuess.java @@ -0,0 +1,53 @@ +package baseball.model; + +import baseball.exception.CommonErrorCode; +import baseball.exception.InvalidInputException; +import lombok.AllArgsConstructor; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@AllArgsConstructor +public class UserGuess { + private final List digits; + + private static final int DIGIT_COUNT = 3; + private static final int MIN_NUMBER = 1; + private static final int MAX_NUMBER = 9; + + public static UserGuess from(String input) { + if (input == null) { + throw new InvalidInputException(CommonErrorCode.EMPTY_INPUT); + } + if (input.length() != DIGIT_COUNT) { + throw new InvalidInputException(CommonErrorCode.INVALID_LENGTH); + } + + List digits = new ArrayList<>(DIGIT_COUNT); + Set dupCheck = new HashSet<>(); + + for (int i = 0; i < DIGIT_COUNT; i++) { + char c = input.charAt(i); + if (!Character.isDigit(c)) { + throw new InvalidInputException(CommonErrorCode.NOT_A_NUMBER); + } + + int d = c - '0'; + if (d < MIN_NUMBER || d > MAX_NUMBER) { + throw new InvalidInputException(CommonErrorCode.NUMBER_OUT_OF_RANGE); + } + if (!dupCheck.add(d)) { + throw new InvalidInputException(CommonErrorCode.DUPLICATED_NUMBER); + } + digits.add(d); + } + + return new UserGuess(digits); + } + + public int digitAt(int index) { + return digits.get(index); + } +} diff --git a/src/main/java/baseball/view/InputView.java b/src/main/java/baseball/view/InputView.java new file mode 100644 index 00000000..a3ee55ac --- /dev/null +++ b/src/main/java/baseball/view/InputView.java @@ -0,0 +1,17 @@ +package baseball.view; + +import java.util.Scanner; + +public class InputView { + private final Scanner scanner = new Scanner(System.in); + + public String readGuess() { + System.out.print("숫자를 입력해주세요: "); + return scanner.nextLine(); + } + + public String readRestartCommand() { + System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요."); + return scanner.nextLine(); + } +} diff --git a/src/main/java/baseball/view/OutputView.java b/src/main/java/baseball/view/OutputView.java new file mode 100644 index 00000000..1dfd4579 --- /dev/null +++ b/src/main/java/baseball/view/OutputView.java @@ -0,0 +1,32 @@ +package baseball.view; + +import baseball.model.Result; + +public class OutputView { + public void printStartMessage() { + System.out.println("숫자 야구 게임을 시작합니다."); + } + + public void printHint(Result result) { + int strike = result.strike(); + int ball = result.ball(); + + if (strike == 0 && ball == 0) { + System.out.println("낫싱"); + return; + } + + StringBuilder sb = new StringBuilder(); + if (ball > 0) sb.append(ball).append("볼 "); + if (strike > 0) sb.append(strike).append("스트라이크"); + System.out.println(sb.toString().trim()); + } + + public void printGameEndMessage() { + System.out.println("3개의 숫자를 모두 맞히셨습니다! 게임 종료"); + } + + public void printError(String message) { + System.out.println("[ERROR] " + message); + } +} diff --git a/src/test/java/.gitkeep b/src/test/java/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/test/java/baseball/model/ComputerNumberTest.java b/src/test/java/baseball/model/ComputerNumberTest.java new file mode 100644 index 00000000..677214f6 --- /dev/null +++ b/src/test/java/baseball/model/ComputerNumberTest.java @@ -0,0 +1,55 @@ +package baseball.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ComputerNumber 생성 테스트") +class ComputerNumberTest { + + @DisplayName("컴퓨터 숫자는 항상 3자리로 생성된다") + @Test + void createRandom_createsThreeDigits() { + ComputerNumber number = ComputerNumber.createRandom(); + + assertEquals(3, number.size()); + } + + @DisplayName("컴퓨터 숫자는 1~9 범위의 숫자로만 구성된다") + @Test + void createRandom_digitsAreBetweenOneAndNine() { + ComputerNumber number = ComputerNumber.createRandom(); + + for (int i = 0; i < number.size(); i++) { + int digit = number.digitAt(i); + assertTrue(digit >= 1 && digit <= 9); + } + } + + @DisplayName("컴퓨터 숫자는 중복되지 않는다") + @Test + void createRandom_digitsAreUnique() { + ComputerNumber number = ComputerNumber.createRandom(); + + Set unique = new HashSet<>(); + for (int i = 0; i < number.size(); i++) { + unique.add(number.digitAt(i)); + } + + assertEquals(3, unique.size()); + } + + @DisplayName("contains는 포함된 숫자에 대해 true를 반환한다") + @Test + void contains_returnsTrueForExistingDigit() { + ComputerNumber number = ComputerNumber.createRandom(); + + int digit = number.digitAt(0); + + assertTrue(number.contains(digit)); + } +} diff --git a/src/test/java/baseball/model/JudgeTest.java b/src/test/java/baseball/model/JudgeTest.java new file mode 100644 index 00000000..db30eb1b --- /dev/null +++ b/src/test/java/baseball/model/JudgeTest.java @@ -0,0 +1,73 @@ +package baseball.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Judge 스트라이크/볼 판정 테스트") +class JudgeTest { + + @DisplayName("모든 숫자와 자리가 같으면 3스트라이크이다") + @Test + void judge_allMatch_returnsThreeStrike() { + ComputerNumber answer = ComputerNumber.of(List.of(1, 3, 7)); + UserGuess guess = UserGuess.from("137"); + + Result result = Judge.judge(answer, guess); + + assertEquals(3, result.strike()); + assertEquals(0, result.ball()); + assertTrue(result.isThreeStrike()); + } + + @DisplayName("숫자는 같지만 자리가 다르면 볼로 판정한다") + @Test + void judge_sameDigitsDifferentPosition_returnsBalls() { + ComputerNumber answer = ComputerNumber.of(List.of(1, 3, 7)); + UserGuess guess = UserGuess.from("731"); + + Result result = Judge.judge(answer, guess); + + assertEquals(0, result.strike()); + assertEquals(3, result.ball()); + } + + @DisplayName("스트라이크와 볼이 함께 존재할 수 있다") + @Test + void judge_strikeAndBallMixed() { + ComputerNumber answer = ComputerNumber.of(List.of(1, 3, 7)); + UserGuess guess = UserGuess.from("173"); + + Result result = Judge.judge(answer, guess); + + assertEquals(1, result.strike()); // 1 + assertEquals(2, result.ball()); // 7, 3 + } + + @DisplayName("공통 숫자가 없으면 스트라이크와 볼은 0이다") + @Test + void judge_noMatchingDigits_returnsNothing() { + ComputerNumber answer = ComputerNumber.of(List.of(1, 3, 7)); + UserGuess guess = UserGuess.from("258"); + + Result result = Judge.judge(answer, guess); + + assertEquals(0, result.strike()); + assertEquals(0, result.ball()); + } + + @DisplayName("일부만 같은 경우 스트라이크만 판정될 수 있다") + @Test + void judge_onlyStrike() { + ComputerNumber answer = ComputerNumber.of(List.of(1, 3, 7)); + UserGuess guess = UserGuess.from("128"); + + Result result = Judge.judge(answer, guess); + + assertEquals(1, result.strike()); // 1 + assertEquals(0, result.ball()); + } +} diff --git a/src/test/java/baseball/model/ResultTest.java b/src/test/java/baseball/model/ResultTest.java new file mode 100644 index 00000000..4002f625 --- /dev/null +++ b/src/test/java/baseball/model/ResultTest.java @@ -0,0 +1,26 @@ +package baseball.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Result 결과 상태 테스트") +class ResultTest { + + @DisplayName("스트라이크가 3이면 isThreeStrike는 true를 반환한다") + @Test + void isThreeStrike_whenStrikeIsThree_returnsTrue() { + Result result = new Result(3, 0); + + assertTrue(result.isThreeStrike()); + } + + @DisplayName("스트라이크가 3이 아니면 isThreeStrike는 false를 반환한다") + @Test + void isThreeStrike_whenStrikeIsNotThree_returnsFalse() { + Result result = new Result(2, 1); + + assertFalse(result.isThreeStrike()); + } +} diff --git a/src/test/java/baseball/model/UserGuessTest.java b/src/test/java/baseball/model/UserGuessTest.java new file mode 100644 index 00000000..f2098b67 --- /dev/null +++ b/src/test/java/baseball/model/UserGuessTest.java @@ -0,0 +1,77 @@ +package baseball.model; + +import baseball.exception.CommonErrorCode; +import baseball.exception.InvalidInputException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class UserGuessTest { + + @DisplayName("정상적인 3자리 숫자 입력이면 UserGuess가 생성된다") + @Test + void from_validInput_createsUserGuess() { + UserGuess guess = UserGuess.from("135"); + + assertEquals(1, guess.digitAt(0)); + assertEquals(3, guess.digitAt(1)); + assertEquals(5, guess.digitAt(2)); + } + + @DisplayName("입력이 null이면 EMPTY_INPUT 예외가 발생한다") + @Test + void from_nullInput_throwsException() { + InvalidInputException e = assertThrows( + InvalidInputException.class, + () -> UserGuess.from(null) + ); + + assertEquals(CommonErrorCode.EMPTY_INPUT, e.getCommonErrorCode()); + } + + @DisplayName("입력 길이가 3이 아니면 INVALID_LENGTH 예외가 발생한다") + @Test + void from_invalidLength_throwsException() { + InvalidInputException e = assertThrows( + InvalidInputException.class, + () -> UserGuess.from("12") + ); + + assertEquals(CommonErrorCode.INVALID_LENGTH, e.getCommonErrorCode()); + } + + @DisplayName("숫자가 아닌 문자가 포함되면 NOT_A_NUMBER 예외가 발생한다") + @Test + void from_nonDigit_throwsException() { + InvalidInputException e = assertThrows( + InvalidInputException.class, + () -> UserGuess.from("1a3") + ); + + assertEquals(CommonErrorCode.NOT_A_NUMBER, e.getCommonErrorCode()); + } + + @DisplayName("숫자가 1~9 범위를 벗어나면 NUMBER_OUT_OF_RANGE 예외가 발생한다") + @Test + void from_outOfRange_throwsException() { + InvalidInputException e = assertThrows( + InvalidInputException.class, + () -> UserGuess.from("109") + ); + + assertEquals(CommonErrorCode.NUMBER_OUT_OF_RANGE, e.getCommonErrorCode()); + } + + @DisplayName("중복된 숫자가 있으면 DUPLICATED_NUMBER 예외가 발생한다") + @Test + void from_duplicatedDigits_throwsException() { + InvalidInputException e = assertThrows( + InvalidInputException.class, + () -> UserGuess.from("113") + ); + + assertEquals(CommonErrorCode.DUPLICATED_NUMBER, e.getCommonErrorCode()); + } +}