diff --git a/README.md b/README.md index 8d7e8aee..2f53a81d 100644 --- a/README.md +++ b/README.md @@ -1 +1,49 @@ -# java-baseball-precourse \ No newline at end of file +# java-baseball-precourse + +## 개요 +컴퓨터가 생성한 3자리 수를 맞히는 콘솔 게임이다. + +## 규칙 +- 컴퓨터는 1부터 9까지의 서로 다른 수로 이루어진 3개의 수를 선택한다. +- 사용자는 3자리 수를 입력한다. +- 같은 숫자가 같은 자리에 있으면 스트라이크를 출력한다. +- 같은 숫자가 다른 자리에 있으면 볼을 출력한다. +- 같은 숫자가 하나도 없으면 낫싱을 출력한다. + +## 기능 목록 +- 1부터 9까지의 서로 다른 3개의 수로 이루어진 두 개의 3자리 수를 비교할 수 있다. + - 같은 숫자가 같은 자리에 있으면 스트라이크이다. + - 같은 숫자가 다른 자리에 있으면 볼이다. + - 같은 숫자가 하나도 없으면 낫싱이다. +- 1부터 9까지의 서로 다른 3개의 수를 랜덤으로 생성한다. +- 게임은 시작 시점에 자신의 3자리 수를 생성한다. +- 게임은 초기화 기능을 제공하고, 초기화시 새로운 3자리 수를 생성한다. +- 게임은 3자리 수를 입력받았을 때 자신이 생성한 숫자와 비교하여 결과를 반환한다. +- 사용자는 3자리 수를 입력할 수 있다. + - 입력 안내 메시지는 `숫자를 입력해주세요 : `이다. +- 입력값에 대한 결과를 스트라이크/볼/낫싱으로 출력한다. +- 3개의 숫자를 모두 맞히면 게임을 종료한다. + - 게임 종료 메시지는 `3개의 숫자를 모두 맞히셨습니다! 게임 끝`이다. +- 게임을 종료한 후 게임을 다시 시작하거나 완전히 종료할 수 있다. + - 안내 메시지는 `게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.`이며, 사용자의 입력을 다음 줄에 받는다. +- 사용자가 잘못된 값을 입력할 경우, [ERROR]로 시작하는 에러 메시지를 출력하고 게임을 계속 진행할 수 있어야 한다. + +## 프로그램 실행 예시 +```text +숫자를 입력해주세요 : 123 +1스트라이크 1볼 +숫자를 입력해주세요 : 145 +1볼 +숫자를 입력해주세요 : 671 +2볼 +숫자를 입력해주세요 : 216 +1스트라이크 +숫자를 입력해주세요 : 713 +3스트라이크 +3개의 숫자를 모두 맞히셨습니다! 게임 끝 +게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요. +1 +숫자를 입력해주세요 : 123 +1볼 +... +``` 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/baseball/Application.java b/src/main/java/baseball/Application.java new file mode 100644 index 00000000..bbc1679f --- /dev/null +++ b/src/main/java/baseball/Application.java @@ -0,0 +1,24 @@ +package baseball; + +import java.util.Scanner; + +import baseball.controller.GameController; +import baseball.model.Game; +import baseball.model.generator.AbstractDigitsGenerator; +import baseball.model.generator.RandomDigitsGenerator; +import baseball.view.InputView; +import baseball.view.OutputView; + +public class Application { + + public static void main(String[] args) { + Scanner scanner = new Scanner(System.in); + InputView inputView = new InputView(scanner); + OutputView outputView = new OutputView(); + AbstractDigitsGenerator generator = new RandomDigitsGenerator(); + Game game = new Game(3, generator); + GameController controller = new GameController(game, inputView, outputView); + + controller.play(); + } +} diff --git a/src/main/java/baseball/controller/GameController.java b/src/main/java/baseball/controller/GameController.java new file mode 100644 index 00000000..ce65dd57 --- /dev/null +++ b/src/main/java/baseball/controller/GameController.java @@ -0,0 +1,96 @@ +package baseball.controller; + +import java.util.ArrayList; +import java.util.List; + +import baseball.model.CompareResult; +import baseball.model.Game; +import baseball.view.InputView; +import baseball.view.OutputView; + +public class GameController { + + private final Game game; + private final InputView inputView; + private final OutputView outputView; + + public GameController(Game game, InputView inputView, OutputView outputView) { + this.game = game; + this.inputView = inputView; + this.outputView = outputView; + } + + public void play() { + while (true) { + playSingleGame(); + if (shouldExit()) { + return; + } + game.reset(); + } + } + + private void playSingleGame() { + while (true) { + if (guess()) { + outputView.printGameEnd(game.getDigitsLength()); + return; + } + } + } + + private boolean guess() { + try { + CompareResult result = compareInput(); + outputView.printResult(result); + return isGameEnd(result); + } catch (RuntimeException exception) { + outputView.printError(exception.getMessage()); + } + return false; + } + + private CompareResult compareInput() { + String input = inputView.readDigits(); + List digits = parseDigits(input); + return game.compare(digits); + } + + private List parseDigits(String input) { + List numbers = new ArrayList<>(); + for (int i = 0; i < input.length(); i++) { + char digit = input.charAt(i); + validateDigit(digit); + numbers.add(digit - '0'); + } + return numbers; + } + + private void validateDigit(char digit) { + if (digit < '0' || digit > '9') { + throw new RuntimeException("숫자만 입력해 주세요"); + } + } + + private boolean isGameEnd(CompareResult result) { + return result.strike() == result.length(); + } + + private boolean shouldExit() { + while (true) { + try { + int command = inputView.readGameCommand(); + validateGameCommand(command); + return command == 2; + } catch (RuntimeException exception) { + outputView.printError(exception.getMessage()); + } + } + } + + private void validateGameCommand(int command) { + if (command != 1 && command != 2) { + throw new RuntimeException("1 또는 2를 입력해 주세요."); + } + } +} diff --git a/src/main/java/baseball/model/CompareResult.java b/src/main/java/baseball/model/CompareResult.java new file mode 100644 index 00000000..efaad609 --- /dev/null +++ b/src/main/java/baseball/model/CompareResult.java @@ -0,0 +1,17 @@ +package baseball.model; + +public record CompareResult(int length, int strike, int ball) { + + public CompareResult { + validate(length, strike, ball); + } + + private void validate(int length, int strike, int ball) { + if (strike < 0 || ball < 0) { + throw new RuntimeException("야구 숫자 비교 결과가 음수이면 안됩니다."); + } + if (length < strike || length < ball) { + throw new RuntimeException("strike, ball 개수가 숫자 개수보다 많으면 안됩니다."); + } + } +} diff --git a/src/main/java/baseball/model/Digits.java b/src/main/java/baseball/model/Digits.java new file mode 100644 index 00000000..59ae9136 --- /dev/null +++ b/src/main/java/baseball/model/Digits.java @@ -0,0 +1,71 @@ +package baseball.model; + +import java.util.Arrays; +import java.util.List; + +public class Digits { + + private final List numbers; + + public Digits(List numbers) { + boolean[] isExist = new boolean[10]; + Arrays.fill(isExist, false); + + for (int number : numbers) { + validate(isExist, number); + isExist[number] = true; + } + this.numbers = List.copyOf(numbers); + } + + public CompareResult compareTo(Digits digits) { + validateSameLength(digits); + + int strike = 0; + int ball = 0; + for (int i = 0; i < numbers.size(); i++) { + if (digits.isMatch(i, numbers.get(i))) { + strike++; + continue; + } + if (digits.isInside(numbers.get(i))) { + ball++; + } + } + + return new CompareResult(numbers.size(), strike, ball); + } + + public int getLength() { + return numbers.size(); + } + + private void validate(boolean[] isExist, int number) { + if (number < 1 || number > 9) { + throw new RuntimeException("1부터 9까지의 숫자가 아닙니다. 중복되지 않는 1부터 9까지의 숫자를 입력해 주세요."); + } + + if (isExist[number]) { + throw new RuntimeException("중복된 숫자가 있습니다. 중복되지 않는 1부터 9까지의 숫자를 입력해 주세요."); + } + } + + private boolean isInside(int target) { + for (int number : numbers) { + if (number == target) { + return true; + } + } + return false; + } + + private boolean isMatch(int index, int target) { + return numbers.get(index) == target; + } + + private void validateSameLength(Digits digits) { + if (digits.getLength() != numbers.size()) { + throw new RuntimeException("비교할 숫자 길이가 다릅니다."); + } + } +} diff --git a/src/main/java/baseball/model/Game.java b/src/main/java/baseball/model/Game.java new file mode 100644 index 00000000..74511ea2 --- /dev/null +++ b/src/main/java/baseball/model/Game.java @@ -0,0 +1,45 @@ +package baseball.model; + +import java.util.List; + +import baseball.model.generator.AbstractDigitsGenerator; + +public class Game { + + private final int digitsLength; + private final AbstractDigitsGenerator generator; + private Digits target; + + public Game(int digitsLength, AbstractDigitsGenerator generator) { + validate(digitsLength); + this.digitsLength = digitsLength; + this.generator = generator; + this.target = this.generator.generate(this.digitsLength); + } + + public CompareResult compare(List input) { + validateLength(input); + Digits digits = new Digits(input); + return target.compareTo(digits); + } + + public void reset() { + target = generator.generate(digitsLength); + } + + public int getDigitsLength() { + return digitsLength; + } + + private void validate(int digitsLength) { + if (digitsLength < 1 || digitsLength > 9) { + throw new RuntimeException("야구 게임의 자리수는 1부터 9사이어야 합니다."); + } + } + + private void validateLength(List input) { + if (input.size() != digitsLength) { + throw new RuntimeException("숫자 개수가 올바르지 않습니다. " + digitsLength + "자리 숫자를 입력해 주세요."); + } + } +} diff --git a/src/main/java/baseball/model/generator/AbstractDigitsGenerator.java b/src/main/java/baseball/model/generator/AbstractDigitsGenerator.java new file mode 100644 index 00000000..c5db8884 --- /dev/null +++ b/src/main/java/baseball/model/generator/AbstractDigitsGenerator.java @@ -0,0 +1,19 @@ +package baseball.model.generator; + +import baseball.model.Digits; + +public abstract class AbstractDigitsGenerator { + + public final Digits generate(int length) { + validateLength(length); + return doGenerate(length); + } + + protected abstract Digits doGenerate(int length); + + private void validateLength(int length) { + if (length < 1 || length > 9) { + throw new RuntimeException("숫자 길이는 1부터 9까지만 가능합니다."); + } + } +} diff --git a/src/main/java/baseball/model/generator/RandomDigitsGenerator.java b/src/main/java/baseball/model/generator/RandomDigitsGenerator.java new file mode 100644 index 00000000..c1b1a457 --- /dev/null +++ b/src/main/java/baseball/model/generator/RandomDigitsGenerator.java @@ -0,0 +1,25 @@ +package baseball.model.generator; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import baseball.model.Digits; + +public class RandomDigitsGenerator extends AbstractDigitsGenerator { + + @Override + protected Digits doGenerate(int length) { + List numbers = createShuffledNumbers(); + return new Digits(numbers.subList(0, length)); + } + + private List createShuffledNumbers() { + List numbers = new ArrayList<>(); + for (int i = 1; i <= 9; i++) { + numbers.add(i); + } + Collections.shuffle(numbers); + return numbers; + } +} diff --git a/src/main/java/baseball/view/InputView.java b/src/main/java/baseball/view/InputView.java new file mode 100644 index 00000000..f5c7f9da --- /dev/null +++ b/src/main/java/baseball/view/InputView.java @@ -0,0 +1,33 @@ +package baseball.view; + +import java.util.Scanner; + +public class InputView { + + private static final String INPUT_MESSAGE = "숫자를 입력해주세요 : "; + private static final String GAME_COMMAND_MESSAGE = "게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요."; + + private final Scanner scanner; + + public InputView(Scanner scanner) { + this.scanner = scanner; + } + + public String readDigits() { + System.out.print(INPUT_MESSAGE); + return scanner.nextLine(); + } + + public int readGameCommand() { + System.out.println(GAME_COMMAND_MESSAGE); + return parseInteger(scanner.nextLine()); + } + + private int parseInteger(String input) { + try { + return Integer.parseInt(input); + } catch (NumberFormatException exception) { + throw new RuntimeException("1 또는 2를 입력해 주세요."); + } + } +} diff --git a/src/main/java/baseball/view/OutputView.java b/src/main/java/baseball/view/OutputView.java new file mode 100644 index 00000000..b9a46a86 --- /dev/null +++ b/src/main/java/baseball/view/OutputView.java @@ -0,0 +1,33 @@ +package baseball.view; + +import baseball.model.CompareResult; + +public class OutputView { + + private static final String GAME_END_MESSAGE = "숫자를 모두 맞히셨습니다! 게임 끝"; + private static final String ERROR_PREFIX = "[ERROR] "; + + public void printResult(CompareResult result) { + if (result.strike() == 0 && result.ball() == 0) { + System.out.println("낫싱"); + return; + } + if (result.strike() > 0 && result.ball() > 0) { + System.out.println(result.strike() + "스트라이크 " + result.ball() + "볼"); + return; + } + if (result.strike() > 0) { + System.out.println(result.strike() + "스트라이크"); + return; + } + System.out.println(result.ball() + "볼"); + } + + public void printGameEnd(int length) { + System.out.println(length + "개의 " + GAME_END_MESSAGE); + } + + public void printError(String message) { + System.out.println(ERROR_PREFIX + 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/CompareResultTest.java b/src/test/java/baseball/model/CompareResultTest.java new file mode 100644 index 00000000..f1460ff9 --- /dev/null +++ b/src/test/java/baseball/model/CompareResultTest.java @@ -0,0 +1,29 @@ +package baseball.model; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class CompareResultTest { + + @Test + @DisplayName("스트라이크/볼 개수가 음수면 예외가 발생한다") + void rejectNegativeCounts() { + assertThatThrownBy(() -> new CompareResult(3, -1, 0)) + .isInstanceOf(RuntimeException.class); + + assertThatThrownBy(() -> new CompareResult(3, 0, -1)) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("스트라이크/볼 개수가 전체 길이를 초과하면 예외가 발생한다") + void rejectCountsGreaterThanLength() { + assertThatThrownBy(() -> new CompareResult(3, 4, 0)) + .isInstanceOf(RuntimeException.class); + + assertThatThrownBy(() -> new CompareResult(3, 0, 4)) + .isInstanceOf(RuntimeException.class); + } +} diff --git a/src/test/java/baseball/model/DigitsTest.java b/src/test/java/baseball/model/DigitsTest.java new file mode 100644 index 00000000..489b7e04 --- /dev/null +++ b/src/test/java/baseball/model/DigitsTest.java @@ -0,0 +1,51 @@ +package baseball.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class DigitsTest { + + @Test + @DisplayName("1~9 사이를 벗어난 숫자가 포함되면 예외가 발생한다") + void rejectOutOfRangeNumber() { + assertThatThrownBy(() -> new Digits(List.of(0, 2, 3))) + .isInstanceOf(RuntimeException.class); + + assertThatThrownBy(() -> new Digits(List.of(1, 2, 10))) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("중복된 숫자가 포함되면 예외가 발생한다") + void rejectDuplicateNumbers() { + assertThatThrownBy(() -> new Digits(List.of(1, 2, 2))) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("비교 결과로 스트라이크와 볼 개수를 반환한다") + void compareToCountsStrikeAndBall() { + Digits answer = new Digits(List.of(4, 2, 5)); + Digits guess = new Digits(List.of(1, 2, 4)); + + CompareResult result = answer.compareTo(guess); + + assertThat(result.strike()).isEqualTo(1); + assertThat(result.ball()).isEqualTo(1); + } + + @Test + @DisplayName("길이가 다르면 비교할 수 없다") + void compareToRejectDifferentLength() { + Digits answer = new Digits(List.of(1, 2, 3)); + Digits guess = new Digits(List.of(4, 5, 6, 7)); + + assertThatThrownBy(() -> answer.compareTo(guess)) + .isInstanceOf(RuntimeException.class); + } +} diff --git a/src/test/java/baseball/model/GameTest.java b/src/test/java/baseball/model/GameTest.java new file mode 100644 index 00000000..3364494d --- /dev/null +++ b/src/test/java/baseball/model/GameTest.java @@ -0,0 +1,88 @@ +package baseball.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import baseball.model.generator.AbstractDigitsGenerator; + +class GameTest { + + @Test + @DisplayName("게임 자리수가 1~9 범위를 벗어나면 예외가 발생한다") + void rejectInvalidGameLength() { + AbstractDigitsGenerator generator = new QueueDigitsGenerator(new Digits(List.of(1, 2, 3))); + + assertThatThrownBy(() -> new Game(0, generator)) + .isInstanceOf(RuntimeException.class); + assertThatThrownBy(() -> new Game(10, generator)) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("입력 길이가 게임 자리수와 다르면 예외가 발생한다") + void rejectDifferentInputLength() { + AbstractDigitsGenerator generator = new QueueDigitsGenerator(new Digits(List.of(1, 2, 3))); + Game game = new Game(3, generator); + + assertThatThrownBy(() -> game.compare(List.of(1, 2))) + .isInstanceOf(RuntimeException.class); + assertThatThrownBy(() -> game.compare(List.of(1, 2, 3, 4))) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("비교 결과로 스트라이크와 볼 개수를 반환한다") + void compareReturnsStrikeAndBall() { + AbstractDigitsGenerator generator = new QueueDigitsGenerator(new Digits(List.of(4, 2, 5))); + Game game = new Game(3, generator); + + CompareResult result = game.compare(List.of(1, 2, 4)); + + assertThat(result.strike()).isEqualTo(1); + assertThat(result.ball()).isEqualTo(1); + } + + @Test + @DisplayName("게임을 초기화하면 새로운 목표 숫자를 사용한다") + void resetRegeneratesTargetDigits() { + AbstractDigitsGenerator generator = new QueueDigitsGenerator( + new Digits(List.of(1, 2, 3)), + new Digits(List.of(4, 5, 6)) + ); + Game game = new Game(3, generator); + + CompareResult first = game.compare(List.of(1, 2, 3)); + game.reset(); + CompareResult second = game.compare(List.of(1, 2, 3)); + + assertThat(first.strike()).isEqualTo(3); + assertThat(first.ball()).isEqualTo(0); + assertThat(second.strike()).isEqualTo(0); + assertThat(second.ball()).isEqualTo(0); + } + + private static class QueueDigitsGenerator extends AbstractDigitsGenerator { + + private final Deque queue = new ArrayDeque<>(); + + public QueueDigitsGenerator(Digits... digits) { + queue.addAll(Arrays.asList(digits)); + } + + @Override + protected Digits doGenerate(int length) { + if (queue.isEmpty()) { + throw new RuntimeException("테스트용 숫자가 부족합니다."); + } + return queue.removeFirst(); + } + } +} diff --git a/src/test/java/baseball/model/generator/RandomDigitsGeneratorTest.java b/src/test/java/baseball/model/generator/RandomDigitsGeneratorTest.java new file mode 100644 index 00000000..6dea8b31 --- /dev/null +++ b/src/test/java/baseball/model/generator/RandomDigitsGeneratorTest.java @@ -0,0 +1,34 @@ +package baseball.model.generator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import baseball.model.Digits; + +class RandomDigitsGeneratorTest { + + @Test + @DisplayName("요청한 길이의 Digits를 생성한다") + void generateCreatesDigitsWithRequestedLength() { + RandomDigitsGenerator generator = new RandomDigitsGenerator(); + + Digits digits = generator.generate(3); + + assertThat(digits.getLength()).isEqualTo(3); + } + + @Test + @DisplayName("요청 길이가 1~9 범위를 벗어나면 예외가 발생한다") + void rejectInvalidLength() { + RandomDigitsGenerator generator = new RandomDigitsGenerator(); + + assertThatThrownBy(() -> generator.generate(0)) + .isInstanceOf(RuntimeException.class); + + assertThatThrownBy(() -> generator.generate(10)) + .isInstanceOf(RuntimeException.class); + } +}