diff --git a/README.md b/README.md index 8d7e8aee..b679c3bf 100644 --- a/README.md +++ b/README.md @@ -1 +1,24 @@ -# java-baseball-precourse \ No newline at end of file +# [ ⚾ 숫자 야구 게임 ⚾ ] + +## 📑 기능 목록 + +### 1. 게임 시작(초기화) 시 난수 생성 +* 게임 시작 시 1에서 9까지 서로 다른 임의의 수 3개를 선택하여 컴퓨터의 숫자를 생성한다. + +### 2. 사용자 입력 및 유효성 검사 +* `숫자를 입력해주세요 : ` 문구를 출력하고 사용자로부터 3자리의 숫자를 입력받는다. +* 사용자가 유효하지 않은 값을 입력할 경우 `[ERROR]` 메시지를 출력한다. + * (검증 기준: 숫자가 아닌 값, 3자리가 아닌 경우, 1~9 범위를 벗어난 수) + * 사용자의 경우 3자리 각 숫자가 서로 달라야 한다는 조건은 없다. + +### 3. 볼/스트라이크 판정 및 결과 출력 +* 컴퓨터의 수와 플레이어의 수를 비교하여 결과를 판정한다. + * 같은 수가 같은 자리에 있으면 `스트라이크` + * 같은 수가 다른 자리에 있으면 `볼` + * 같은 수가 전혀 없으면 `낫싱` +* 판정 결과를 `1스트라이크 1볼`, `낫싱`, `3스트라이크`와 같은 형식으로 출력. + +### 4. 게임 종료 및 재시작 +* 3스트라이크가 되면 `3개의 숫자를 모두 맞히셨습니다! 게임 끝` 메시지를 출력하며 게임을 종료한다. +* 게임 종료 후 `게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.` 문구를 출력한다. +* 입력값에 따라 게임을 재시작(1)하거나 완전히 종료(2)한다. \ No newline at end of file diff --git a/src/main/java/baseball/BaseballGameApplication.java b/src/main/java/baseball/BaseballGameApplication.java new file mode 100644 index 00000000..39b78e48 --- /dev/null +++ b/src/main/java/baseball/BaseballGameApplication.java @@ -0,0 +1,19 @@ +package baseball; + +import baseball.controller.GameController; +import baseball.domain.Judge; +import baseball.domain.NumbersGenerator; +import baseball.view.InputView; +import baseball.view.OutputView; + +// 숫자 야구 게임 시작점 +public class BaseballGameApplication { + + public static void main(String[] args) { + // 게임 실행 흐름을 제어하는 컨트롤러 구성 + GameController gameController = new GameController(new NumbersGenerator(), new InputView(), new OutputView(), new Judge()); + + // 숫자 야구 게임 실행 + gameController.run(); + } +} \ No newline at end of file diff --git a/src/main/java/baseball/controller/GameController.java b/src/main/java/baseball/controller/GameController.java new file mode 100644 index 00000000..67400406 --- /dev/null +++ b/src/main/java/baseball/controller/GameController.java @@ -0,0 +1,85 @@ +package baseball.controller; + +import baseball.domain.*; +import baseball.view.InputView; +import baseball.view.OutputView; + +import java.io.IOException; + +import static baseball.domain.GuessError.ONE_OR_TWO; + +public class GameController { + private final NumbersGenerator generator; + private final InputView inputView; + private final OutputView outputView; + private final Judge judge; + + public GameController(NumbersGenerator generator, InputView inputView, OutputView outputView, Judge judge) { + this.generator = generator; // 컴퓨터 숫자 생성기 + this.inputView = inputView; // 사용자 입력 담당 뷰 + this.outputView = outputView; // 결과 출력 담당 뷰 + this.judge = judge; // 스트라이크, 볼 판단 + } + + // 게임 실행 + public void run() { + while (true) { + // 한 판 게임 시작 + playOneGame(); + + // 1이면 재시작, 2이면 종료 + boolean restart = askToRestart(); + if (restart) continue; + return; + } + } + + // 한 판 게임 실행(컴퓨터 숫자 생성 → 입력/판정 반복 → 3스트라이크 시 종료) + private void playOneGame() { + // 컴퓨터가 생성한 숫자 + ComputerNumbers answer = generator.generate(); + + // 올바른 사용자 input이 들어오면 스트라이크/볼 판정 후 3스트라이크가 되면 게임 종료 + while (true) { + Guess guess = readGuessUntilValid(); + Result result = judge.judge(answer, guess); + outputView.printResult(result); + + if (result.isThreeStrikes()) { + outputView.printWinMessage(); + return; + } + } + } + + // 재시작 여부를 묻고 1이면 true, 2면 false를 반환 (유효한 입력이 들어올 때까지 재시도) + private boolean askToRestart() { + while (true) { + try { + outputView.printRestartMessage(); + String input = inputView.readRestart(); + if ("1".equals(input)) return true; + if ("2".equals(input)) { + outputView.printGameTerminateMessage(); + return false; + } + outputView.printError(ONE_OR_TWO.message()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + // 올바르게 입력할때 까지 재시도 - Guess는 사용자가 입력할 예측값 + private Guess readGuessUntilValid() { + while (true) { + try { + return Guess.from(inputView.readGuess()); + } catch (IllegalArgumentException e) { + System.out.println(e.getMessage()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/baseball/domain/ComputerNumbers.java b/src/main/java/baseball/domain/ComputerNumbers.java new file mode 100644 index 00000000..60eff9e8 --- /dev/null +++ b/src/main/java/baseball/domain/ComputerNumbers.java @@ -0,0 +1,31 @@ +package baseball.domain; + +// 컴퓨터가 뽑은 임의의 수를 하나의 객체로 관리 +public class ComputerNumbers { + private final int[] digits; + + public ComputerNumbers(int[] digits) { + this.digits = digits; + } + + // 특정 위치의 숫자 조회 + public int digitAt(int index) { + return digits[index]; + } + + // 숫자가 포함되어 있는지 여부 반환 + public boolean contains(int value) { + for (int digit : digits) { + if (digit == value) { + return true; + } + } + return false; + } + + // 디버깅 용 + @Override + public String toString() { + return "컴퓨터가 생성한 숫자: " + digits[0] + digits[1] + digits[2]; + } +} \ No newline at end of file diff --git a/src/main/java/baseball/domain/Guess.java b/src/main/java/baseball/domain/Guess.java new file mode 100644 index 00000000..4febfc98 --- /dev/null +++ b/src/main/java/baseball/domain/Guess.java @@ -0,0 +1,78 @@ +package baseball.domain; + +import java.util.Objects; + +// 사용자가 입력한 3자리 숫자를 도메인 객체로 표현 +public class Guess { + private static final int SIZE = 3; + private static final int MIN = 1; + private static final int MAX = 9; + + private final int[] digits; + + // 숫자 3자리를 내부 상태로 보관 + private Guess(int[] digits) { + this.digits = digits; + } + + // 사용자 입력을 순서대로 검증한 뒤 Guess로 변환 + public static Guess from(String input) { + String value = validateNotNullAndTrim(input); + validateLength(value); + validateDigits(value); + int[] digits = toDigits(value); + validateRange(digits); + return new Guess(digits); + } + + // 지정한 위치의 숫자 반환 + public int digitAt(int index) { + return digits[index]; + } + + // null 여부 확인 후 앞뒤 공백 제거 + private static String validateNotNullAndTrim(String input) { + Objects.requireNonNull(input, GuessError.NULL_INPUT.message()); + return input.trim(); + } + + // 입력 문자열의 길이가 정확히 3인지 검증 + private static void validateLength(String value) { + if (value.length() != SIZE) { + throw new IllegalArgumentException(GuessError.INVALID_LENGTH.message()); + } + } + + // 모든 문자가 숫자인지 검증 + private static void validateDigits(String value) { + for (int i = 0; i < SIZE; i++) { + if (!Character.isDigit(value.charAt(i))) { + throw new IllegalArgumentException(GuessError.NON_DIGIT.message()); + } + } + } + + // 각 자리 숫자가 1~9 범위인지 검증 + private static void validateRange(int[] digits) { + for (int digit : digits) { + if (digit < MIN || digit > MAX) { + throw new IllegalArgumentException(GuessError.OUT_OF_RANGE.message()); + } + } + } + + // 문자열의 각 문자를 정수 배열로 변환 + private static int[] toDigits(String value) { + int[] result = new int[SIZE]; + for (int i = 0; i < SIZE; i++) { + result[i] = value.charAt(i) - '0'; + } + return result; + } + + // 디버깅 용 + @Override + public String toString() { + return "유저가 입력한 숫자: " + digits[0] + digits[1] + digits[2]; + } +} \ No newline at end of file diff --git a/src/main/java/baseball/domain/GuessError.java b/src/main/java/baseball/domain/GuessError.java new file mode 100644 index 00000000..ed8e2c6d --- /dev/null +++ b/src/main/java/baseball/domain/GuessError.java @@ -0,0 +1,20 @@ +package baseball.domain; + +// Guess 생성 과정에서 발생할 수 있는 에러 유형 +public enum GuessError { + NULL_INPUT("[ERROR] 입력값이 null입니다."), + INVALID_LENGTH("[ERROR] 3자리 숫자를 입력해야 합니다."), + NON_DIGIT("[ERROR] 숫자만 입력할 수 있습니다."), + OUT_OF_RANGE("[ERROR] 1~9 범위의 숫자만 입력할 수 있습니다."), + ONE_OR_TWO("[ERROR] 1 또는 2를 입력해야 합니다."); + + private final String message; + + GuessError(String message) { + this.message = message; + } + + public String message() { + return message; + } +} diff --git a/src/main/java/baseball/domain/Judge.java b/src/main/java/baseball/domain/Judge.java new file mode 100644 index 00000000..6a4b7838 --- /dev/null +++ b/src/main/java/baseball/domain/Judge.java @@ -0,0 +1,45 @@ +package baseball.domain; + +// 컴퓨터 숫자와 사용자 입력을 비교해 결과를 계산 +public class Judge { + private static final int SIZE = 3; + + // 컴퓨터 숫자와 사용자 입력을 비교해 스트라이크, 볼 개수 계산 + public Result judge(ComputerNumbers answer, Guess guess) { + int strikes = countStrikes(answer, guess); + int balls = countBalls(answer, guess); + return new Result(strikes, balls); + } + + // 같은 자리에서 같은 숫자인 경우 스트라이크 개수 계산 + private int countStrikes(ComputerNumbers answer, Guess guess) { + int count = 0; + + for (int index = 0; index < SIZE; index++) { + if (answer.digitAt(index) == guess.digitAt(index)) { + count++; + } + } + + return count; + } + + // 자리는 다르지만 포함된 숫자인 경우 볼 개수 계산 + private int countBalls(ComputerNumbers answer, Guess guess) { + int count = 0; + + for (int index = 0; index < SIZE; index++) { + int value = guess.digitAt(index); + + if (answer.digitAt(index) == value) { + continue; + } + + if (answer.contains(value)) { + count++; + } + } + + return count; + } +} \ No newline at end of file diff --git a/src/main/java/baseball/domain/NumbersGenerator.java b/src/main/java/baseball/domain/NumbersGenerator.java new file mode 100644 index 00000000..3f860b44 --- /dev/null +++ b/src/main/java/baseball/domain/NumbersGenerator.java @@ -0,0 +1,51 @@ +package baseball.domain; + +import java.util.*; + +/** + * 컴퓨터가 사용할 숫자 3개를 생성하는 역할을 담당 + * 책임: + * - 1~9 범위의 숫자를 사용 + * - 서로 다른 숫자 3개를 생성 + * - 생성된 결과를 ComputerNumbers 객체로 반환 + * 숫자의 유효성(범위, 중복 없음)은 생성 과정에서 보장 (단위 테스트 진행) + */ +public class NumbersGenerator { + // 생성할 숫자의 개수 + private static final int SIZE = 3; + + // 생성 가능한 숫자의 최댓값 (1 ~ 9) + private static final int MAX = 9; + + // 난수 생성을 위한 Random 객체 + private final Random random = new Random(); + + // 1~9 범위의 서로 다른 숫자 3개를 생성하여 ComputerNumbers로 반환 + public ComputerNumbers generate() { + Set numbers = new HashSet<>(); + + while (numbers.size() < SIZE) { + numbers.add(randomNumber()); + } + + return new ComputerNumbers(toArray(numbers)); + } + + // 1~9 범위의 난수 하나를 생성 + private int randomNumber() { + return random.nextInt(MAX) + 1; // 1 ~ 9 + } + + // 생성된 숫자 Set를 배열 형태로 변환 + private int[] toArray(Set numbers) { + // 오름차순으로되는 것을 막기 위해 shuffle로 순서 섞기 로직 추가 + List list = new ArrayList<>(numbers); + Collections.shuffle(list, random); + + int[] result = new int[SIZE]; + for (int i = 0; i < SIZE; i++) { + result[i] = list.get(i); + } + return result; + } +} diff --git a/src/main/java/baseball/domain/Result.java b/src/main/java/baseball/domain/Result.java new file mode 100644 index 00000000..6cc96262 --- /dev/null +++ b/src/main/java/baseball/domain/Result.java @@ -0,0 +1,33 @@ +package baseball.domain; + +// 스트라이크, 볼 개수를 보관하는 값 객체 +public class Result { + private final int strikes; + private final int balls; + + // 스트라이크, 볼 개수를 가진 결과 생성 + public Result(int strikes, int balls) { + this.strikes = strikes; + this.balls = balls; + } + + // 스트라이크 개수 반환 + public int strikes() { + return strikes; + } + + // 볼 개수 반환 + public int balls() { + return balls; + } + + // 스트라이크와 볼이 모두 없는지 여부 반환 + public boolean isNothing() { + return strikes == 0 && balls == 0; + } + + // 3스트라이크인지 여부 반환 + public boolean isThreeStrikes() { + return strikes == 3; + } +} \ No newline at end of file diff --git a/src/main/java/baseball/view/InputView.java b/src/main/java/baseball/view/InputView.java new file mode 100644 index 00000000..063f58c2 --- /dev/null +++ b/src/main/java/baseball/view/InputView.java @@ -0,0 +1,21 @@ +package baseball.view; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; + +// 사용자 입력 처리(UI) +public class InputView { + private final BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); + + // 숫자 입력 안내 후 문자열 입력 수집 + public String readGuess() throws IOException { + System.out.print("숫자를 입력해주세요 : "); + return br.readLine(); + } + + // 재시작 여부 입력 수집 (1 또는 2) + public String readRestart() throws IOException { + return br.readLine(); + } +} diff --git a/src/main/java/baseball/view/OutputView.java b/src/main/java/baseball/view/OutputView.java new file mode 100644 index 00000000..d815d3be --- /dev/null +++ b/src/main/java/baseball/view/OutputView.java @@ -0,0 +1,57 @@ +package baseball.view; + +import baseball.domain.Result; + +// 판정 결과를 콘솔에 출력하는 뷰 +public class OutputView { + + // 스트라이크, 볼 개수에 따라 결과 문자열을 출력 + public void printResult(Result result) { + if (result.isNothing()) { + System.out.println("낫싱"); + return; + } + + String message = buildMessage(result); + System.out.println(message); + } + + // 스트라이크, 볼 개수에 맞는 메시지 생성 + private String buildMessage(Result result) { + StringBuilder builder = new StringBuilder(); + + int strikes = result.strikes(); + if (strikes > 0) { + builder.append(strikes) + .append("스트라이크 "); + } + + int balls = result.balls(); + if (balls > 0) { + builder.append(balls) + .append("볼"); + } + + return builder.toString().trim(); + } + + // 사용자가 3스트라이크를 맞혔을 때 출력 + public void printWinMessage() { + System.out.println("3개의 숫자를 모두 맞히셨습니다! 게임 끝"); + } + + // 게임 재시작 안내 출력 + public void printRestartMessage() { + System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요."); + } + + // 게임 종료 메시지 출력 + public void printGameTerminateMessage() { + System.out.println("게임을 종료하겠습니다."); + } + + // [ERROR] 메시지 출력용 편의 메서드 + public void printError(String message) { + System.out.println(message); + } +} \ No newline at end of file diff --git a/src/test/java/baseball/domain/GuessTest.java b/src/test/java/baseball/domain/GuessTest.java new file mode 100644 index 00000000..6ef32473 --- /dev/null +++ b/src/test/java/baseball/domain/GuessTest.java @@ -0,0 +1,77 @@ +package baseball.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GuessTest { + + @DisplayName("3자리 숫자 문자열을 입력하면 각 자리가 올바르게 저장된다") + @Test + void from_valid_input_creates_guess() { + Guess guess = Guess.from("123"); + + assertThat(guess.digitAt(0)).isEqualTo(1); + assertThat(guess.digitAt(1)).isEqualTo(2); + assertThat(guess.digitAt(2)).isEqualTo(3); + } + + @DisplayName("사용자 입력은 중복 숫자를 허용한다") + @Test + void from_allows_duplicate_digits() { + Guess guess = Guess.from("111"); + + assertThat(guess.digitAt(0)).isEqualTo(1); + assertThat(guess.digitAt(1)).isEqualTo(1); + assertThat(guess.digitAt(2)).isEqualTo(1); + } + + @DisplayName("앞뒤 공백은 제거한 뒤 검증한다") + @Test + void from_trims_input() { + Guess guess = Guess.from(" 789 "); + + assertThat(guess.digitAt(0)).isEqualTo(7); + assertThat(guess.digitAt(1)).isEqualTo(8); + assertThat(guess.digitAt(2)).isEqualTo(9); + } + + @DisplayName("입력값이 null이면 예외가 발생한다") + @Test + void from_null_throws() { + assertThatThrownBy(() -> Guess.from(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageStartingWith("[ERROR]"); + } + + @DisplayName("3자리가 아니면 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {"", "1", "12", "1234", " 12", "12 ", "9999"}) + void from_invalid_length_throws(String input) { + assertThatThrownBy(() -> Guess.from(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("[ERROR]"); + } + + @DisplayName("숫자가 아닌 문자가 포함되면 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {"12a", "a23", "1 3", "1,2", "+++"}) + void from_non_digit_throws(String input) { + assertThatThrownBy(() -> Guess.from(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("[ERROR]"); + } + + @DisplayName("0 또는 10 이상이 포함되면 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {"023", "120", "900"}) + void from_out_of_range_throws(String input) { + assertThatThrownBy(() -> Guess.from(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("[ERROR]"); + } +} diff --git a/src/test/java/baseball/domain/JudgeTest.java b/src/test/java/baseball/domain/JudgeTest.java new file mode 100644 index 00000000..b2968f74 --- /dev/null +++ b/src/test/java/baseball/domain/JudgeTest.java @@ -0,0 +1,60 @@ +package baseball.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class JudgeTest { + + @DisplayName("숫자와 자리가 모두 같으면 스트라이크로 판정") + @Test + void judge_all_strikes() { + ComputerNumbers answer = new ComputerNumbers(new int[]{1, 2, 3}); + Guess guess = Guess.from("123"); + + Result result = new Judge().judge(answer, guess); + + assertThat(result.strikes()).isEqualTo(3); + assertThat(result.balls()).isZero(); + assertThat(result.isThreeStrikes()).isTrue(); + } + + @DisplayName("자리는 다르지만 같은 숫자면 볼로 판정") + @Test + void judge_only_balls() { + ComputerNumbers answer = new ComputerNumbers(new int[]{1, 2, 3}); + Guess guess = Guess.from("312"); + + Result result = new Judge().judge(answer, guess); + + assertThat(result.strikes()).isZero(); + assertThat(result.balls()).isEqualTo(3); + assertThat(result.isNothing()).isFalse(); + } + + @DisplayName("스트라이크와 볼이 함께 존재할 수 있다") + @Test + void judge_strikes_and_balls() { + ComputerNumbers answer = new ComputerNumbers(new int[]{1, 2, 3}); + Guess guess = Guess.from("132"); + + Result result = new Judge().judge(answer, guess); + + assertThat(result.strikes()).isEqualTo(1); + assertThat(result.balls()).isEqualTo(2); + } + + @DisplayName("겹치는 숫자가 하나도 없으면 낫싱") + @Test + void judge_nothing() { + ComputerNumbers answer = new ComputerNumbers(new int[]{1, 2, 3}); + Guess guess = Guess.from("456"); + + Result result = new Judge().judge(answer, guess); + + assertThat(result.strikes()).isZero(); + assertThat(result.balls()).isZero(); + assertThat(result.isNothing()).isTrue(); + } +} diff --git a/src/test/java/baseball/domain/NumbersGeneratorTest.java b/src/test/java/baseball/domain/NumbersGeneratorTest.java new file mode 100644 index 00000000..2b3bc9fd --- /dev/null +++ b/src/test/java/baseball/domain/NumbersGeneratorTest.java @@ -0,0 +1,53 @@ +package baseball.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class NumbersGeneratorTest { + + @DisplayName("컴퓨터 숫자는 3자리로 생성") + @Test + void generate_creates_three_digits() { + NumbersGenerator generator = new NumbersGenerator(); + + ComputerNumbers numbers = generator.generate(); + + assertThat(numbers).isNotNull(); + // digitAt이 0~2까지 접근 가능하면 3자리 보장 + assertThat(numbers.digitAt(0)).isNotNull(); + assertThat(numbers.digitAt(1)).isNotNull(); + assertThat(numbers.digitAt(2)).isNotNull(); + } + + @DisplayName("컴퓨터 숫자의 각 자리는 1~9 범위") + @Test + void generate_digits_are_between_1_and_9() { + NumbersGenerator generator = new NumbersGenerator(); + + ComputerNumbers numbers = generator.generate(); + + assertThat(numbers.digitAt(0)).isBetween(1, 9); + assertThat(numbers.digitAt(1)).isBetween(1, 9); + assertThat(numbers.digitAt(2)).isBetween(1, 9); + } + + @DisplayName("컴퓨터 숫자는 서로 다른 3개의 숫자") + @Test + void generate_digits_are_distinct() { + NumbersGenerator generator = new NumbersGenerator(); + + ComputerNumbers numbers = generator.generate(); + + Set set = new HashSet<>(); + set.add(numbers.digitAt(0)); + set.add(numbers.digitAt(1)); + set.add(numbers.digitAt(2)); + + assertThat(set).hasSize(3); + } +}