diff --git a/README.md b/README.md index 8d7e8aee..dc00ab73 100644 --- a/README.md +++ b/README.md @@ -1 +1,69 @@ -# java-baseball-precourse \ No newline at end of file +# 숫자 야구 게임 + +## 프로젝트 설명 + +- 1~9 사이 서로 다른 3자리 숫자를 맞히는 숫자 야구 게임을 구현한다. +- 사용자는 컴퓨터가 만든 3자리 숫자를 맞힐 때까지 입력을 반복한다. +- 입력 결과에 따라 스트라이크/볼/낫싱 힌트를 출력한다. +- 3스트라이크가 되면 게임이 종료되며, 재시작 또는 완전 종료를 선택한다. + +## 구현해야 하는 기능 + +- 컴퓨터의 3자리 숫자 생성 (1~9, 중복 없음) +- 사용자 입력 처리 + - 3자리 숫자 입력 + - 1~9 범위 확인 + - 중복 여부 확인 + - 잘못된 입력 시 `[ERROR]` 메시지 출력 후 재입력 +- 결과 판정 + - 스트라이크: 같은 숫자 & 같은 자리 + - 볼: 같은 숫자 & 다른 자리 + - 낫싱: 같은 숫자 없음 +- 게임 종료 처리 + - 3스트라이크 시 종료 메시지 출력 + - 재시작(1) / 종료(2) 입력 처리 + +## 설계 + +### 패키지 구조 (MVC) + +``` +baseball/ + BaseballGameApplication.java + controller/ + BaseballGameController.java + domain/ + BaseballNumber.java + BaseballResult.java + view/ + InputView.java + OutputView.java +``` + +### 역할 분리 + +- **domain**: 숫자 생성/검증, 결과 계산 등 핵심 로직 +- **controller**: 게임 진행 흐름 제어 +- **view**: 입력 안내/결과 출력 + +### 핵심 클래스 책임 + +- `BaseballNumber`: 3자리 숫자 검증 및 생성 +- `BaseballResult`: 스트라이크/볼 계산 결과 표현 +- `BaseballNumber.random()`: 컴퓨터 숫자 생성 +- `BaseballGameController`: 게임 루프, 입력 검증, 재시작 처리 +- `InputView` / `OutputView`: 콘솔 입출력 전담 + +### 구현 순서 + +1. 전체 뼈대 생성 + - 패키지/클래스 생성 + - 메서드 시그니처만 두고 내부 로직은 TODO 수준으로 둔다. +2. 도메인 내부 로직 구현 + - 숫자 생성/검증, 스트라이크/볼 계산 +3. Controller 상세 구현 + - 게임 루프, 입력 검증, 재시작 처리 +4. 출력 포맷 마무리 + - 결과 출력 규칙과 안내 메시지 정리 +5. 테스트 코드 작성 + - 도메인 로직 단위 테스트(JUnit5 + AssertJ) diff --git a/build.gradle b/build.gradle index 20a92c9e..d6a88dcf 100644 --- a/build.gradle +++ b/build.gradle @@ -16,8 +16,14 @@ repositories { } dependencies { + // lombok + compileOnly 'org.projectlombok:lombok:1.18.34' + annotationProcessor 'org.projectlombok:lombok:1.18.34' + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testImplementation 'org.assertj:assertj-core:3.25.3' + testCompileOnly 'org.projectlombok:lombok:1.18.34' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.34' } test { diff --git a/src/main/java/baseball/BaseballGameApplication.java b/src/main/java/baseball/BaseballGameApplication.java new file mode 100644 index 00000000..be44720c --- /dev/null +++ b/src/main/java/baseball/BaseballGameApplication.java @@ -0,0 +1,10 @@ +package baseball; + +import baseball.controller.BaseballGameController; + +public class BaseballGameApplication { + public static void main(String[] args) { + BaseballGameController controller = BaseballGameController.create(); + controller.play(); + } +} diff --git a/src/main/java/baseball/controller/BaseballGameController.java b/src/main/java/baseball/controller/BaseballGameController.java new file mode 100644 index 00000000..4ddc960f --- /dev/null +++ b/src/main/java/baseball/controller/BaseballGameController.java @@ -0,0 +1,75 @@ +package baseball.controller; + +import baseball.domain.BaseballNumber; +import baseball.domain.BaseballResult; +import baseball.view.InputView; +import baseball.view.OutputView; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class BaseballGameController { + private final InputView inputView; + private final OutputView outputView; + + public static BaseballGameController create() { + return new BaseballGameController(new InputView(), new OutputView()); + } + + public void play() { + while (true) { + BaseballNumber answer = BaseballNumber.random(); + playGame(answer); + if (shouldEnd()) { + return; + } + } + } + + private void playGame(BaseballNumber answer) { + while (true) { + BaseballNumber guess = readValidGuess(); + BaseballResult result = BaseballResult.of(answer, guess); + printResult(result); + if (result.isGameEnd()) { + outputView.printGameEnd(); + return; + } + } + } + + private BaseballNumber readValidGuess() { + while (true) { + try { + String input = inputView.readGuess(); + return BaseballNumber.from(input); + } catch (IllegalArgumentException e) { + outputView.printError(e.getMessage()); + } + } + } + + private boolean shouldEnd() { + while (true) { + try { + String input = inputView.readRestartCommand(); + return parseRestartCommand(input); + } catch (IllegalArgumentException e) { + outputView.printError(e.getMessage()); + } + } + } + + private void printResult(BaseballResult result) { + outputView.printResult(result); + } + + private boolean parseRestartCommand(String input) { + if ("1".equals(input)) { + return false; + } + if ("2".equals(input)) { + return true; + } + throw new IllegalArgumentException("1 또는 2만 입력할 수 있습니다."); + } +} diff --git a/src/main/java/baseball/domain/BaseballNumber.java b/src/main/java/baseball/domain/BaseballNumber.java new file mode 100644 index 00000000..d9c39494 --- /dev/null +++ b/src/main/java/baseball/domain/BaseballNumber.java @@ -0,0 +1,54 @@ +package baseball.domain; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +public record BaseballNumber(List digits) { + public static BaseballNumber from(String input) { + return new BaseballNumber(validateDigits(input)); + } + + public static BaseballNumber random() { + Random random = new Random(); + List digits = new ArrayList<>(); + while (digits.size() < 3) { + int digit = random.nextInt(9) + 1; + if (digits.contains(digit)) { + continue; + } + digits.add(digit); + } + return new BaseballNumber(digits); + } + + private static List validateDigits(String input) { + if (input == null) { + throw new IllegalArgumentException("입력이 비어있습니다."); + } + String value = input.trim(); + if (value.length() != 3) { + throw new IllegalArgumentException("입력은 3자리 숫자여야 합니다."); + } + List digits = new ArrayList<>(); + Set unique = new HashSet<>(); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + if (!Character.isDigit(ch)) { + throw new IllegalArgumentException("입력은 숫자만 가능합니다."); + } + int digit = ch - '0'; + if (digit == 0) { + throw new IllegalArgumentException("0은 사용할 수 없습니다."); + } + if (!unique.add(digit)) { + throw new IllegalArgumentException("중복된 숫자가 있습니다."); + } + digits.add(digit); + } + return digits; + } + +} diff --git a/src/main/java/baseball/domain/BaseballResult.java b/src/main/java/baseball/domain/BaseballResult.java new file mode 100644 index 00000000..6cb7e51c --- /dev/null +++ b/src/main/java/baseball/domain/BaseballResult.java @@ -0,0 +1,27 @@ +package baseball.domain; + +public record BaseballResult(int strike, int ball) { + public static BaseballResult of(BaseballNumber computer, BaseballNumber guess) { + int strike = 0; + int ball = 0; + for (int i = 0; i < computer.digits().size(); i++) { + int guessDigit = guess.digits().get(i); + if (computer.digits().get(i).equals(guessDigit)) { + strike++; + continue; + } + if (computer.digits().contains(guessDigit)) { + ball++; + } + } + return new BaseballResult(strike, ball); + } + + public boolean isNothing() { + return strike == 0 && ball == 0; + } + + public boolean isGameEnd() { + return strike == 3; + } +} diff --git a/src/main/java/baseball/view/InputView.java b/src/main/java/baseball/view/InputView.java new file mode 100644 index 00000000..5200486d --- /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().trim(); + } + + public String readRestartCommand() { + System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요."); + return scanner.nextLine().trim(); + } +} diff --git a/src/main/java/baseball/view/OutputView.java b/src/main/java/baseball/view/OutputView.java new file mode 100644 index 00000000..0e499fb2 --- /dev/null +++ b/src/main/java/baseball/view/OutputView.java @@ -0,0 +1,49 @@ +package baseball.view; + +import baseball.domain.BaseballResult; + +public class OutputView { + public void printResult(BaseballResult result) { + System.out.println(formatResult(result)); + } + + public void printGameEnd() { + System.out.println("3개의 숫자를 모두 맞히셨습니다! 게임 끝"); + } + + public void printError(String message) { + System.out.println("[ERROR] " + message); + } + + private String formatResult(BaseballResult result) { + if (result.isNothing()) { + return "낫싱"; + } + StringBuilder builder = new StringBuilder(); + appendStrike(builder, result.strike()); + appendBall(builder, result.ball()); + return builder.toString().trim(); + } + + private void appendStrike(StringBuilder builder, int strike) { + if (strike == 0) { + return; + } + builder.append(strike).append("스트라이크"); + } + + private void appendBall(StringBuilder builder, int ball) { + if (ball == 0) { + return; + } + appendSpaceIfNeeded(builder); + builder.append(ball).append("볼"); + } + + private void appendSpaceIfNeeded(StringBuilder builder) { + if (builder.isEmpty()) { + return; + } + builder.append(' '); + } +} diff --git a/src/test/java/baseball/domain/BaseballNumberTest.java b/src/test/java/baseball/domain/BaseballNumberTest.java new file mode 100644 index 00000000..34ba5b8f --- /dev/null +++ b/src/test/java/baseball/domain/BaseballNumberTest.java @@ -0,0 +1,59 @@ +package baseball.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class BaseballNumberTest { + @Test + void createFromValidInput() { + BaseballNumber number = BaseballNumber.from("123"); + assertThat(number.digits()).containsExactly(1, 2, 3); + } + + @Test + void rejectNullInput() { + assertThatThrownBy(() -> BaseballNumber.from(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectNonNumericInput() { + assertThatThrownBy(() -> BaseballNumber.from("12a")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectWrongLength() { + assertThatThrownBy(() -> BaseballNumber.from("12")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectZeroDigit() { + assertThatThrownBy(() -> BaseballNumber.from("102")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectDuplicateDigits() { + assertThatThrownBy(() -> BaseballNumber.from("112")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void randomHasThreeUniqueDigits() { + BaseballNumber number = BaseballNumber.random(); + List digits = number.digits(); + assertThat(digits).hasSize(3); + Set unique = new HashSet<>(digits); + assertThat(unique).hasSize(3); + for (int digit : digits) { + assertThat(digit).isBetween(1, 9); + } + } +} diff --git a/src/test/java/baseball/domain/BaseballResultTest.java b/src/test/java/baseball/domain/BaseballResultTest.java new file mode 100644 index 00000000..4072adbb --- /dev/null +++ b/src/test/java/baseball/domain/BaseballResultTest.java @@ -0,0 +1,38 @@ +package baseball.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class BaseballResultTest { + @Test + void calculateStrikeAndBall() { + BaseballNumber answer = BaseballNumber.from("425"); + BaseballNumber guess = BaseballNumber.from("456"); + + BaseballResult result = BaseballResult.of(answer, guess); + + assertThat(result.strike()).isEqualTo(1); + assertThat(result.ball()).isEqualTo(1); + } + + @Test + void calculateNothing() { + BaseballNumber answer = BaseballNumber.from("425"); + BaseballNumber guess = BaseballNumber.from("789"); + + BaseballResult result = BaseballResult.of(answer, guess); + + assertThat(result.isNothing()).isTrue(); + } + + @Test + void detectGameEnd() { + BaseballNumber answer = BaseballNumber.from("123"); + BaseballNumber guess = BaseballNumber.from("123"); + + BaseballResult result = BaseballResult.of(answer, guess); + + assertThat(result.isGameEnd()).isTrue(); + } +}