diff --git a/README.md b/README.md index 8d7e8aee..85fb4975 100644 --- a/README.md +++ b/README.md @@ -1 +1,103 @@ -# java-baseball-precourse \ No newline at end of file +# 숫자 야구 게임 + +## 📌 프로젝트 소개 +1~9 사이의 서로 다른 숫자 3개로 구성된 정답을 컴퓨터가 생성하고, 사용자는 숫자를 입력하여 스트라이크/볼 힌트를 통해 정답을 맞히는 콘솔 게임입니다. +정답(3 스트라이크)을 맞히면 게임이 종료되며, 사용자는 게임을 재시작(1)하거나 완전히 종료(2)할 수 있습니다. + +--- + +## ✅ 기능 목록 + +### 게임 흐름 +- [x] 게임 시작 시 컴퓨터가 1~9 범위의 서로 다른 숫자 3개를 생성한다. +- [x] 사용자에게 숫자 입력을 요청하고 입력을 받는다. +- [x] 입력한 숫자에 대한 스트라이크/볼/낫싱 힌트를 계산하여 출력한다. +- [x] 3스트라이크가 되면 게임 종료 메시지를 출력한다. +- [x] 게임 종료 후 `1(재시작)` / `2(종료)` 입력을 받아 처리한다. + - [x] 재시작 시 정답 숫자를 새로 생성하여 게임을 다시 진행한다. + - [x] 종료를 선택하면 프로그램을 종료한다. + +### 입력 검증 / 예외 처리 +- [x] 사용자 입력이 잘못된 경우 `[ERROR]`로 시작하는 에러 메시지를 출력하고 게임을 계속 진행한다. +- [x] 숫자 입력 검증 + - [x] 3자리인지 검증한다. + - [x] 각 자리가 1~9 범위인지 검증한다. (도메인: `BaseballNumber`) + - [x] 3자리 숫자가 서로 다른 수인지 검증한다. (도메인: `BaseballNumbers`) +- [x] 재시작/종료 입력 검증 + - [x] 1 또는 2만 허용한다. + +--- + +## 🧱 도메인 모델 + +### `BaseballNumber` +- 한 자리 숫자를 의미하는 값 객체(Value Object) +- 유효 범위: 1~9 +- 생성 시 검증을 수행하여 항상 유효한 상태만 유지 + +### `BaseballNumbers` (일급 컬렉션) +- `BaseballNumber` 3개를 보유하는 일급 컬렉션 +- 생성 시 다음 규칙을 검증하여 항상 유효한 상태만 유지 + - 숫자 개수는 3개 + - 서로 다른 숫자(중복 없음) + +### `Hint` +- 스트라이크/볼 결과를 표현하는 값 객체 + +### `BaseballGame` +- 정답 보유 및 입력과의 비교 결과(`Hint`) 생성 + +--- + +## 🧩 패키지 구조(예시) + +- `game.baseball.domain` + - `BaseballNumber` : 한 자리 숫자(1~9) + - `BaseballNumbers` : 3자리 숫자 일급 컬렉션(개수/중복 검증) + - `Hint` : 스트라이크/볼 결과 값 객체 + - `BaseballGame` : 정답 보유 및 힌트 계산 + +- `game.baseball.application` + - `BaseballGameService` : 게임 시작 및 추측 처리(유즈케이스) + +- `game.baseball.application.port` + - `in` : `BaseballGameUseCase`, `command(GuessCommand, RestartCommand)` + - `out` : `GameInputPort`, `GameOutputPort`, `NumberGeneratorPort` + +- `game.baseball.adapter` + - `in` : `BaseballGameInputView`, `BaseballGameOutputView`, `BaseballGameController` + - `out` : `RandomNumberGenerator`, `BaseballPrinter` + +- `game` + - `Application` : 프로그램 시작점(의존성 조립) + - `GameRunner` : 실행 트리거 + +> 핵심 로직(도메인/애플리케이션)과 UI(System.in/out)를 분리하여, 도메인 로직을 단위 테스트 대상으로 삼습니다. + +--- + +## 🧪 테스트 전략 +- 도메인 로직에 대해 단위 테스트를 작성합니다. +- UI 로직(System.in/out, Scanner 등)은 테스트 범위에서 제외합니다. +- JUnit5 + AssertJ를 사용합니다. + +--- + +## ⚙️ 프로그래밍 요구사항 준수 +- indent depth는 2까지만 허용합니다. +- `else`, `switch/case`를 사용하지 않습니다. +- Java Stream API를 사용하지 않습니다. (람다는 가능) +- 메서드는 15라인을 넘지 않도록 분리합니다. + +--- + +## ▶️ 실행 방법(예시) +- IDE에서 `Application.main()` 실행 +- 또는 Gradle 기반 프로젝트라면: + - `./gradlew test` + - `./gradlew run` + +--- + +## ✍️ 커밋/진행 방식 +- 기능 구현 전 README에 기능 목록을 작성하고, 기능 단위로 커밋합니다. diff --git a/src/main/java/Application.java b/src/main/java/Application.java new file mode 100644 index 00000000..162338ff --- /dev/null +++ b/src/main/java/Application.java @@ -0,0 +1,9 @@ +import game.GameRunner; +import game.baseball.config.BaseballGameConfig; + +public class Application { + public static void main(String[] args) { + GameRunner gameRunner = new BaseballGameConfig().gameRunner(); + gameRunner.run(); + } +} diff --git a/src/main/java/game/GamePrinter.java b/src/main/java/game/GamePrinter.java new file mode 100644 index 00000000..30070c32 --- /dev/null +++ b/src/main/java/game/GamePrinter.java @@ -0,0 +1,6 @@ +package game; + +public interface GamePrinter { + void printStartingMessage(); + void printEndingMessage(); +} diff --git a/src/main/java/game/GameRunner.java b/src/main/java/game/GameRunner.java new file mode 100644 index 00000000..b7abd7da --- /dev/null +++ b/src/main/java/game/GameRunner.java @@ -0,0 +1,17 @@ +package game; + +public class GameRunner { + private final GamingConsole nowRunningGame; + private final GamePrinter nowRunningGamePrinter; + + public GameRunner(GamingConsole nowRunningGame, GamePrinter nowRunningGamePrinter) { + this.nowRunningGame = nowRunningGame; + this.nowRunningGamePrinter = nowRunningGamePrinter; + } + + public void run() { + nowRunningGamePrinter.printStartingMessage(); + nowRunningGame.play(); + nowRunningGamePrinter.printEndingMessage(); + } +} diff --git a/src/main/java/game/GamingConsole.java b/src/main/java/game/GamingConsole.java new file mode 100644 index 00000000..0573e40f --- /dev/null +++ b/src/main/java/game/GamingConsole.java @@ -0,0 +1,5 @@ +package game; + +public interface GamingConsole { + void play(); +} diff --git a/src/main/java/game/baseball/adapter/RandomNumberGenerator.java b/src/main/java/game/baseball/adapter/RandomNumberGenerator.java new file mode 100644 index 00000000..4f59f6f9 --- /dev/null +++ b/src/main/java/game/baseball/adapter/RandomNumberGenerator.java @@ -0,0 +1,33 @@ +package game.baseball.adapter; + +import game.baseball.application.port.out.NumberGeneratorPort; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +public class RandomNumberGenerator implements NumberGeneratorPort { + private static final int NUMBER_COUNT = 3; + private static final int MIN_NUMBER = 1; + private static final int MAX_NUMBER = 9; + private static final int RANDOM_RANGE = MAX_NUMBER - MIN_NUMBER + 1; + + private final Random random = new Random(); + + @Override + public List generate() { + List numbers = new ArrayList<>(); + while (numbers.size() < NUMBER_COUNT) { + int candidate = random.nextInt(RANDOM_RANGE) + MIN_NUMBER; + addIfAbsent(numbers, candidate); + } + return numbers; + } + + private void addIfAbsent(List numbers, int candidate) { + if (numbers.contains(candidate)) { + return; + } + numbers.add(candidate); + } +} diff --git a/src/main/java/game/baseball/adapter/in/BaseballGameController.java b/src/main/java/game/baseball/adapter/in/BaseballGameController.java new file mode 100644 index 00000000..9a7e4eea --- /dev/null +++ b/src/main/java/game/baseball/adapter/in/BaseballGameController.java @@ -0,0 +1,87 @@ +package game.baseball.adapter.in; + +import game.GamingConsole; +import game.baseball.application.port.in.BaseballGameUseCase; +import game.baseball.application.port.in.command.GuessCommand; +import game.baseball.application.port.in.command.RestartCommand; +import game.baseball.application.port.out.GameInputPort; +import game.baseball.application.port.out.GameOutputPort; +import game.baseball.domain.Hint; + +public class BaseballGameController implements GamingConsole { + private final BaseballGameUseCase useCase; + private final GameInputPort inputPort; + private final GameOutputPort outputPort; + + public BaseballGameController( + BaseballGameUseCase useCase, + GameInputPort inputPort, + GameOutputPort outputPort + ) { + this.useCase = useCase; + this.inputPort = inputPort; + this.outputPort = outputPort; + } + + @Override + public void play() { + while (true) { + useCase.startNewGame(); + playUntilSolved(); + if (!restartSelected()) { + return; + } + } + } + + private void playUntilSolved() { + while (true) { + Hint hint = readValidHint(); + outputPort.showResult(hint); + if (hint.isSolved()) { + outputPort.showGameEnd(); + return; + } + } + } + + private Hint readValidHint() { + while (true) { + Hint hint = tryGuessOnce(); + if (hint != null) { + return hint; + } + } + } + + private Hint tryGuessOnce() { + try { + outputPort.showGuessPrompt(); + GuessCommand command = inputPort.readGuessCommand(); + return useCase.guess(command); + } catch (IllegalArgumentException e) { + outputPort.showError(e.getMessage()); + return null; + } + } + + private boolean restartSelected() { + while (true) { + Boolean restart = tryReadRestartCommand(); + if (restart != null) { + return restart; + } + } + } + + private Boolean tryReadRestartCommand() { + try { + outputPort.showRestartPrompt(); + RestartCommand command = inputPort.readRestartCommand(); + return command.restart(); + } catch (IllegalArgumentException e) { + outputPort.showError(e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/game/baseball/adapter/in/BaseballGameInputView.java b/src/main/java/game/baseball/adapter/in/BaseballGameInputView.java new file mode 100644 index 00000000..8147f797 --- /dev/null +++ b/src/main/java/game/baseball/adapter/in/BaseballGameInputView.java @@ -0,0 +1,81 @@ +package game.baseball.adapter.in; + +import game.baseball.application.port.out.GameInputPort; +import game.baseball.application.port.in.command.GuessCommand; +import game.baseball.application.port.in.command.RestartCommand; + +import java.util.ArrayList; +import java.util.List; +import java.util.Scanner; + +public class BaseballGameInputView implements GameInputPort { + private static final int GUESS_LENGTH = 3; + private static final char DIGIT_ZERO = '0'; + private static final String RESTART_COMMAND = "1"; + private static final String QUIT_COMMAND = "2"; + + private final Scanner scanner = new Scanner(System.in); + + @Override + public GuessCommand readGuessCommand() { + return new GuessCommand(parseGuess(readLine())); + } + + @Override + public RestartCommand readRestartCommand() { + return new RestartCommand(parseRestart(readLine())); + } + + private String readLine() { + return scanner.nextLine(); + } + + private List parseGuess(String userInput) { + String input = normalize(userInput); + validateLength(input); + return toDigits(input); + } + + private String normalize(String userInput) { + if (userInput == null) { + throw new IllegalArgumentException("입력값이 null 입니다."); + } + return userInput.trim(); + } + + private void validateLength(String input) { + if (input.length() != GUESS_LENGTH) { + throw new IllegalArgumentException( + String.format("%d자리 숫자를 입력해야 합니다.", GUESS_LENGTH) + ); + } + } + + private List toDigits(String input) { + List digits = new ArrayList<>(); + for (int i = 0; i < input.length(); i++) { + digits.add(parseDigit(input.charAt(i))); + } + return digits; + } + + private int parseDigit(char ch) { + if (!Character.isDigit(ch)) { + throw new IllegalArgumentException("숫자만 입력할 수 있습니다."); + } + return ch - DIGIT_ZERO; + } + + private boolean parseRestart(String command) { + String normalized = normalize(command); + if (RESTART_COMMAND.equals(normalized)) { + return true; + } + if (QUIT_COMMAND.equals(normalized)) { + return false; + } + throw new IllegalArgumentException( + String.format("%s 또는 %s를 입력해야 합니다.", RESTART_COMMAND, QUIT_COMMAND) + ); + } +} diff --git a/src/main/java/game/baseball/adapter/in/BaseballGameOutputView.java b/src/main/java/game/baseball/adapter/in/BaseballGameOutputView.java new file mode 100644 index 00000000..6b2c0a48 --- /dev/null +++ b/src/main/java/game/baseball/adapter/in/BaseballGameOutputView.java @@ -0,0 +1,41 @@ +package game.baseball.adapter.in; + +import game.baseball.application.port.out.GameOutputPort; +import game.baseball.domain.Hint; + +public class BaseballGameOutputView implements GameOutputPort { + private static final int ANSWER_LENGTH = 3; + private static final String RESTART_OPTION = "1"; + private static final String QUIT_OPTION = "2"; + + private static final String NUMBER_INPUT_PROMPT = "숫자를 입력해주세요 : "; + private static final String CORRECT_MESSAGE = + String.format("%d개의 숫자를 모두 맞히셨습니다. 게임 종료", ANSWER_LENGTH); + private static final String OPTION_INPUT_PROMPT = + String.format("게임을 새로 시작하려면 %s, 종료하려면 %s를 입력하세요.", RESTART_OPTION, QUIT_OPTION); + + @Override + public void showGuessPrompt() { + System.out.print(NUMBER_INPUT_PROMPT); + } + + @Override + public void showResult(Hint hint) { + System.out.println(hint.message()); + } + + @Override + public void showGameEnd() { + System.out.println(CORRECT_MESSAGE); + } + + @Override + public void showRestartPrompt() { + System.out.println(OPTION_INPUT_PROMPT); + } + + @Override + public void showError(String message) { + System.out.println("[ERROR] " + message); + } +} diff --git a/src/main/java/game/baseball/adapter/out/BaseballPrinter.java b/src/main/java/game/baseball/adapter/out/BaseballPrinter.java new file mode 100644 index 00000000..f9bd70ee --- /dev/null +++ b/src/main/java/game/baseball/adapter/out/BaseballPrinter.java @@ -0,0 +1,18 @@ +package game.baseball.adapter.out; + +import game.GamePrinter; + +public class BaseballPrinter implements GamePrinter { + private static final String START_MESSAGE = "숫자 야구 게임을 시작합니다."; + private static final String END_MESSAGE = "게임을 종료합니다."; + + @Override + public void printStartingMessage() { + System.out.println(START_MESSAGE); + } + + @Override + public void printEndingMessage() { + System.out.println(END_MESSAGE); + } +} diff --git a/src/main/java/game/baseball/application/BaseballGameService.java b/src/main/java/game/baseball/application/BaseballGameService.java new file mode 100644 index 00000000..18a2fde5 --- /dev/null +++ b/src/main/java/game/baseball/application/BaseballGameService.java @@ -0,0 +1,36 @@ +package game.baseball.application; + +import game.baseball.application.port.in.BaseballGameUseCase; +import game.baseball.application.port.in.command.GuessCommand; +import game.baseball.application.port.out.NumberGeneratorPort; +import game.baseball.domain.BaseballGame; +import game.baseball.domain.BaseballNumbers; +import game.baseball.domain.Hint; + +public class BaseballGameService implements BaseballGameUseCase { + private final NumberGeneratorPort numberGeneratorPort; + private BaseballGame game; + + public BaseballGameService(NumberGeneratorPort numberGeneratorPort) { + this.numberGeneratorPort = numberGeneratorPort; + } + + @Override + public void startNewGame() { + BaseballNumbers answer = BaseballNumbers.from(numberGeneratorPort.generate()); + this.game = new BaseballGame(answer); + } + + @Override + public Hint guess(GuessCommand command) { + ensureGameStarted(); + BaseballNumbers guess = BaseballNumbers.from(command.digits()); + return game.guess(guess); + } + + private void ensureGameStarted() { + if (game == null) { + throw new IllegalStateException("게임이 시작되지 않았습니다."); + } + } +} diff --git a/src/main/java/game/baseball/application/port/in/BaseballGameUseCase.java b/src/main/java/game/baseball/application/port/in/BaseballGameUseCase.java new file mode 100644 index 00000000..481500a1 --- /dev/null +++ b/src/main/java/game/baseball/application/port/in/BaseballGameUseCase.java @@ -0,0 +1,10 @@ +package game.baseball.application.port.in; + +import game.baseball.application.port.in.command.GuessCommand; +import game.baseball.domain.Hint; + +public interface BaseballGameUseCase { + void startNewGame(); + + Hint guess(GuessCommand command); +} diff --git a/src/main/java/game/baseball/application/port/in/command/GuessCommand.java b/src/main/java/game/baseball/application/port/in/command/GuessCommand.java new file mode 100644 index 00000000..f66c6d0b --- /dev/null +++ b/src/main/java/game/baseball/application/port/in/command/GuessCommand.java @@ -0,0 +1,9 @@ +package game.baseball.application.port.in.command; + +import java.util.List; + +public record GuessCommand(List digits) { + public GuessCommand { + digits = List.copyOf(digits); + } +} diff --git a/src/main/java/game/baseball/application/port/in/command/RestartCommand.java b/src/main/java/game/baseball/application/port/in/command/RestartCommand.java new file mode 100644 index 00000000..331936c3 --- /dev/null +++ b/src/main/java/game/baseball/application/port/in/command/RestartCommand.java @@ -0,0 +1,4 @@ +package game.baseball.application.port.in.command; + +public record RestartCommand(boolean restart) { +} diff --git a/src/main/java/game/baseball/application/port/out/GameInputPort.java b/src/main/java/game/baseball/application/port/out/GameInputPort.java new file mode 100644 index 00000000..4ed10114 --- /dev/null +++ b/src/main/java/game/baseball/application/port/out/GameInputPort.java @@ -0,0 +1,10 @@ +package game.baseball.application.port.out; + +import game.baseball.application.port.in.command.GuessCommand; +import game.baseball.application.port.in.command.RestartCommand; + +public interface GameInputPort { + GuessCommand readGuessCommand(); + + RestartCommand readRestartCommand(); +} diff --git a/src/main/java/game/baseball/application/port/out/GameOutputPort.java b/src/main/java/game/baseball/application/port/out/GameOutputPort.java new file mode 100644 index 00000000..745ddf4f --- /dev/null +++ b/src/main/java/game/baseball/application/port/out/GameOutputPort.java @@ -0,0 +1,15 @@ +package game.baseball.application.port.out; + +import game.baseball.domain.Hint; + +public interface GameOutputPort { + void showGuessPrompt(); + + void showResult(Hint hint); + + void showGameEnd(); + + void showRestartPrompt(); + + void showError(String message); +} diff --git a/src/main/java/game/baseball/application/port/out/NumberGeneratorPort.java b/src/main/java/game/baseball/application/port/out/NumberGeneratorPort.java new file mode 100644 index 00000000..f5550b2c --- /dev/null +++ b/src/main/java/game/baseball/application/port/out/NumberGeneratorPort.java @@ -0,0 +1,7 @@ +package game.baseball.application.port.out; + +import java.util.List; + +public interface NumberGeneratorPort { + List generate(); +} diff --git a/src/main/java/game/baseball/config/BaseballGameConfig.java b/src/main/java/game/baseball/config/BaseballGameConfig.java new file mode 100644 index 00000000..7fb30495 --- /dev/null +++ b/src/main/java/game/baseball/config/BaseballGameConfig.java @@ -0,0 +1,31 @@ +package game.baseball.config; + +import game.GamePrinter; +import game.GameRunner; +import game.GamingConsole; +import game.baseball.adapter.RandomNumberGenerator; +import game.baseball.adapter.in.BaseballGameController; +import game.baseball.adapter.in.BaseballGameInputView; +import game.baseball.adapter.in.BaseballGameOutputView; +import game.baseball.adapter.out.BaseballPrinter; +import game.baseball.application.BaseballGameService; +import game.baseball.application.port.in.BaseballGameUseCase; +import game.baseball.application.port.out.GameInputPort; +import game.baseball.application.port.out.GameOutputPort; +import game.baseball.application.port.out.NumberGeneratorPort; + +public class BaseballGameConfig { + public GameRunner gameRunner() { + NumberGeneratorPort numberGeneratorPort = new RandomNumberGenerator(); + GameInputPort inputPort = new BaseballGameInputView(); + GameOutputPort outputPort = new BaseballGameOutputView(); + BaseballGameUseCase useCase = new BaseballGameService(numberGeneratorPort); + GamingConsole console = new BaseballGameController( + useCase, + inputPort, + outputPort + ); + GamePrinter printer = new BaseballPrinter(); + return new GameRunner(console, printer); + } +} diff --git a/src/main/java/game/baseball/domain/BaseballGame.java b/src/main/java/game/baseball/domain/BaseballGame.java new file mode 100644 index 00000000..770ebeec --- /dev/null +++ b/src/main/java/game/baseball/domain/BaseballGame.java @@ -0,0 +1,15 @@ +package game.baseball.domain; + +public class BaseballGame { + private final BaseballNumbers answer; + + public BaseballGame(BaseballNumbers answer) { + this.answer = answer; + } + + public Hint guess(BaseballNumbers guess) { + int strike = answer.countStrike(guess); + int ball = answer.countBall(guess); + return Hint.of(strike, ball); + } +} diff --git a/src/main/java/game/baseball/domain/BaseballNumber.java b/src/main/java/game/baseball/domain/BaseballNumber.java new file mode 100644 index 00000000..57173db7 --- /dev/null +++ b/src/main/java/game/baseball/domain/BaseballNumber.java @@ -0,0 +1,44 @@ +package game.baseball.domain; + +public class BaseballNumber { + private static final int MIN_NUMBER = 1; + private static final int MAX_NUMBER = 9; + + private final Integer number; + + private BaseballNumber(Integer number) { + this.number = number; + } + + public static BaseballNumber of(Integer number) { + validate(number); + return new BaseballNumber(number); + } + + private static void validate(final Integer number) { + if (number < MIN_NUMBER || number > MAX_NUMBER) { + throw new IllegalArgumentException( + String.format("야구 숫자의 범위는 %d에서 %d까지의 자연수 입니다.", MIN_NUMBER, MAX_NUMBER) + ); + } + } + + public int value() { + return number; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof BaseballNumber val)) { + return false; + } + return number.equals(val.number); + } + + @Override + public int hashCode() { + return number.hashCode(); + } + +} diff --git a/src/main/java/game/baseball/domain/BaseballNumbers.java b/src/main/java/game/baseball/domain/BaseballNumbers.java new file mode 100644 index 00000000..513aad4c --- /dev/null +++ b/src/main/java/game/baseball/domain/BaseballNumbers.java @@ -0,0 +1,87 @@ +package game.baseball.domain; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class BaseballNumbers { + private static final int NUMBERS_SIZE = 3; + + private final List numbers; + + private BaseballNumbers(List numbers) { + this.numbers = List.copyOf(numbers); + } + + public static BaseballNumbers from(final List numbers) { + validate(numbers); + return new BaseballNumbers(createBaseballNumbers(numbers)); + } + + private static void validate(final List numbers) { + validateNotNull(numbers); + validateSize(numbers); + validateDistinct(numbers); + } + + private static void validateNotNull(List numbers) { + if (numbers == null) { + throw new IllegalArgumentException("숫자 목록이 null 입니다."); + } + } + + private static void validateSize(final List numbers) { + if (numbers.size() != NUMBERS_SIZE) { + throw new IllegalArgumentException( + String.format("숫자야구의 숫자 개수는 %d개입니다.", NUMBERS_SIZE) + ); + } + } + + private static void validateDistinct(final List numbers) { + Set unique = new HashSet<>(); + for (Integer number : numbers) { + if (unique.contains(number)) { + throw new IllegalArgumentException("숫자는 서로 중복될 수 없습니다."); + } + unique.add(number); + } + } + + private static List createBaseballNumbers(List numbers) { + List baseballNumbers = new ArrayList<>(); + for (Integer number : numbers) { + baseballNumbers.add(BaseballNumber.of(number)); + } + return baseballNumbers; + } + + public int countStrike(BaseballNumbers guess) { + int strike = 0; + for (int i = 0; i < numbers.size(); i++) { + if (numbers.get(i).equals(guess.numbers.get(i))) { + strike++; + } + } + return strike; + } + + public int countBall(BaseballNumbers guess) { + int ball = 0; + for (int i = 0; i < numbers.size(); i++) { + BaseballNumber candidate = guess.numbers.get(i); + if (isStrikePosition(candidate, i)) { + continue; + } + if (numbers.contains(candidate)) { + ball++; + } + } + return ball; + } + + private boolean isStrikePosition(BaseballNumber candidate, int index) { + return numbers.get(index).equals(candidate); + } +} diff --git a/src/main/java/game/baseball/domain/Hint.java b/src/main/java/game/baseball/domain/Hint.java new file mode 100644 index 00000000..866671d2 --- /dev/null +++ b/src/main/java/game/baseball/domain/Hint.java @@ -0,0 +1,77 @@ +package game.baseball.domain; + +public class Hint { + private static final int MIN_COUNT = 0; + private static final int MAX_COUNT = 3; + private static final int SOLVED_STRIKE_COUNT = 3; + + private final int strike; + private final int ball; + + private Hint(int strike, int ball) { + this.strike = strike; + this.ball = ball; + } + + public static Hint of(int strike, int ball) { + validate(strike, ball); + return new Hint(strike, ball); + } + + public boolean isSolved() { + return strike == SOLVED_STRIKE_COUNT; + } + + public String message() { + if (isNothing()) { + return "낫싱"; + } + return buildMessage(); + } + + private boolean isNothing() { + return strike == MIN_COUNT && ball == MIN_COUNT; + } + + private String buildMessage() { + StringBuilder sb = new StringBuilder(); + appendStrike(sb); + appendBall(sb); + return sb.toString(); + } + + private void appendStrike(StringBuilder sb) { + if (strike == MIN_COUNT) { + return; + } + sb.append(strike).append("스트라이크"); + } + + private void appendBall(StringBuilder sb) { + if (ball == MIN_COUNT) { + return; + } + if (!sb.isEmpty()) { + sb.append(" "); + } + sb.append(ball).append("볼"); + } + + private static void validate(int strike, int ball) { + if (strike < MIN_COUNT || strike > MAX_COUNT) { + throw new IllegalArgumentException( + String.format("스트라이크는 %d~%d 범위여야 합니다.", MIN_COUNT, MAX_COUNT) + ); + } + if (ball < MIN_COUNT || ball > MAX_COUNT) { + throw new IllegalArgumentException( + String.format("볼은 %d~%d 범위여야 합니다.", MIN_COUNT, MAX_COUNT) + ); + } + if (strike + ball > MAX_COUNT) { + throw new IllegalArgumentException( + String.format("스트라이크와 볼의 합은 %d을 초과할 수 없습니다.", MAX_COUNT) + ); + } + } +} diff --git a/src/test/java/game/baseball/domain/BaseballGameTest.java b/src/test/java/game/baseball/domain/BaseballGameTest.java new file mode 100644 index 00000000..2904ae3f --- /dev/null +++ b/src/test/java/game/baseball/domain/BaseballGameTest.java @@ -0,0 +1,38 @@ +package game.baseball.domain; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class BaseballGameTest { + + @Test + void guessAllStrike() { + BaseballGame game = new BaseballGame(BaseballNumbers.from(List.of(1, 2, 3))); + + Hint hint = game.guess(BaseballNumbers.from(List.of(1, 2, 3))); + + assertThat(hint.isSolved()).isTrue(); + assertThat(hint.message()).isEqualTo("3스트라이크"); + } + + @Test + void guessStrikeAndBall() { + BaseballGame game = new BaseballGame(BaseballNumbers.from(List.of(1, 2, 3))); + + Hint hint = game.guess(BaseballNumbers.from(List.of(1, 3, 2))); + + assertThat(hint.message()).isEqualTo("1스트라이크 2볼"); + } + + @Test + void guessNothing() { + BaseballGame game = new BaseballGame(BaseballNumbers.from(List.of(1, 2, 3))); + + Hint hint = game.guess(BaseballNumbers.from(List.of(4, 5, 6))); + + assertThat(hint.message()).isEqualTo("낫싱"); + } +} diff --git a/src/test/java/game/baseball/domain/BaseballNumberTest.java b/src/test/java/game/baseball/domain/BaseballNumberTest.java new file mode 100644 index 00000000..917a8cd0 --- /dev/null +++ b/src/test/java/game/baseball/domain/BaseballNumberTest.java @@ -0,0 +1,28 @@ +package game.baseball.domain; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BaseballNumberTest { + + @Test + void createWithValidRange() { + BaseballNumber number = BaseballNumber.of(1); + + assertThat(number.value()).isEqualTo(1); + } + + @Test + void rejectNumberLessThanOne() { + assertThatThrownBy(() -> BaseballNumber.of(0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectNumberGreaterThanNine() { + assertThatThrownBy(() -> BaseballNumber.of(10)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/game/baseball/domain/BaseballNumbersTest.java b/src/test/java/game/baseball/domain/BaseballNumbersTest.java new file mode 100644 index 00000000..c28d127f --- /dev/null +++ b/src/test/java/game/baseball/domain/BaseballNumbersTest.java @@ -0,0 +1,45 @@ +package game.baseball.domain; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BaseballNumbersTest { + + @Test + void createWithValidNumbers() { + BaseballNumbers numbers = BaseballNumbers.from(List.of(1, 2, 3)); + + assertThat(numbers.countStrike(BaseballNumbers.from(List.of(1, 2, 3)))).isEqualTo(3); + } + + @Test + void rejectNullList() { + assertThatThrownBy(() -> BaseballNumbers.from(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectWrongSize() { + assertThatThrownBy(() -> BaseballNumbers.from(List.of(1, 2))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectDuplicateNumbers() { + assertThatThrownBy(() -> BaseballNumbers.from(List.of(1, 1, 2))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void countStrikeAndBall() { + BaseballNumbers answer = BaseballNumbers.from(List.of(1, 2, 3)); + BaseballNumbers guess = BaseballNumbers.from(List.of(1, 3, 2)); + + assertThat(answer.countStrike(guess)).isEqualTo(1); + assertThat(answer.countBall(guess)).isEqualTo(2); + } +} diff --git a/src/test/java/game/baseball/domain/HintTest.java b/src/test/java/game/baseball/domain/HintTest.java new file mode 100644 index 00000000..155ff093 --- /dev/null +++ b/src/test/java/game/baseball/domain/HintTest.java @@ -0,0 +1,55 @@ +package game.baseball.domain; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HintTest { + + @Test + void messageWhenNothing() { + Hint hint = Hint.of(0, 0); + + assertThat(hint.message()).isEqualTo("낫싱"); + } + + @Test + void messageWhenStrikeAndBall() { + Hint hint = Hint.of(1, 2); + + assertThat(hint.message()).isEqualTo("1스트라이크 2볼"); + } + + @Test + void messageWhenOnlyBall() { + Hint hint = Hint.of(0, 2); + + assertThat(hint.message()).isEqualTo("2볼"); + } + + @Test + void solvedWhenThreeStrike() { + Hint hint = Hint.of(3, 0); + + assertThat(hint.isSolved()).isTrue(); + } + + @Test + void rejectInvalidStrikeRange() { + assertThatThrownBy(() -> Hint.of(4, 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectInvalidBallRange() { + assertThatThrownBy(() -> Hint.of(0, 4)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectSumGreaterThanThree() { + assertThatThrownBy(() -> Hint.of(2, 2)) + .isInstanceOf(IllegalArgumentException.class); + } +}