diff --git a/docs/README.md b/docs/README.md index e69de29..3941799 100644 --- a/docs/README.md +++ b/docs/README.md @@ -0,0 +1,206 @@ +# ⚾  Precourse-Week1 Mission **[숫자 야구]** + +## 💌  목차 + +- [📦  패키지 구조](#패키지-구조) +- [✨  기능 구현 목록](#기능-구현-목록) +- [🎨  구현 간 고민했던 내용들](#구현-간-고민했던-내용들) + +--- + +## 📦  패키지 구조 + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PackageClassDescription
🕹  controllerGame게임 로직을 메인으로 동작하는 컨트롤러 클래스
💻  domainNumber사용자에게 입력받는 숫자와, 컴퓨터가 생성하는 숫자 클래스 +
ResultBall Count와 Strike Count에 대한 결과 클래스
    ↘️  / constantsResultType각 결과에 따라 다른 출력 방법에 대해 정의된 Enum
📃  globalGameConfig전역으로 작용하는 설정과 제약조건에 대한 Enum
    ↘️  / exceptionBaseballException전역으로 처리하는 예외상황에 대한 Exception 클래스
ErrorMessage각 예외 상황에서 전역으로 던져질 예외의 메세지 Enum
✅  validatorInputValidator사용자가 입력하는 숫자에 대한 제약조건 클래스
💬  viewInputView사용자 요청을 처리하는 클래스
OutputView사용자에게 응답을 출력하는 클래스
    ↘️  / constantsStaticNotice사용자에게 응답할 정적 메세지를 담은 열거형 클래스
Package Structure Overview
+
+ +--- + +## ✨  기능 구현 목록 + +### + +- ✅ `a ~ b` 사이의 서로 값이 다른 `n자리`의 정수를 랜덤으로 생성한다. + - Default Setting : `1 ~ 9`사이의 서로 값이 다른 `3자리`의 정수 +- ✅ 게임 시작 문구 출력 +- ✅ 사용자에게 `a ~ b 사이의 서로 값이 다른 n자리의 정수`를 입력 받는다. + - 입력받은 input이 비어있을 경우 예외처리 + - 입력받은 input이 숫자가 아닌 문자가 포함될 경우 예외처리 + - 입력받은 input에 중복된 숫자가 있을 경우 예외처리 +- ✅ 사용자 input 숫자와 랜덤 생성 정수의 자리수를 비교해 출력할 힌트를 계산한다. + - 숫자의 값은 같지만 자리수가 다른 경우의 수 n개 : `n볼` + - 숫자의 값과 자리수가 모두 같은 경우의 수 m개 : `n스트라이크` +- ✅계산된 힌트를 아래 양식으로 출력한다 + - 볼 n개, 스트라이크 0개가 존재할 때 : `n볼` + - 볼 0개, 스트라이크 n개가 존재할 때 : `n스트라이크` + - 볼 n개, 스트라이크 m개가 존재할 때 : `n볼 m스트라이크` + - 볼 0개, 스트라이크 0개가 존재할 때 : `낫싱` + +- ✅ 게임 클리어 여부 판단 + - `n스트라이크가 아니라면`, 다시 사용자에게 입력을 숫자를 받고, 힌트를 출력한다. + - `n스트라이크를 맞추었다면`, 아래 메세지를 출력하고 사용자에게 플래그를 입력받는다. + - `n개의 숫자를 모두 맞히셨습니다! 게임 종료` + - `게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.` + - 입력받은 input이 1과 2가 아닌 숫자일 경우 예외처리 + - 입력값에 따라 게임을 재시작하거나 종료한다. + +-------------------------------------------------------- + +## 🎨  구현 간 고민했던 내용들 + +### 1️⃣   확장에는 열려있고, 변경에는 닫혀있는 OCP 설계 + +- input 숫자의 범위가 변하더라도(1~9), 자리수가 변하더라도 대응이 손쉽게 가능해야 한다.
+ `GameConfig` 파일에서 `NUMBER_LENGTH` 변수의 value를 변경해 손쉽게 변경이 가능하다.
+ 개발 요구사항에서 자릿수 요청까지 처리하는 문제였다면, 더욱 OCP를 준수하는 코드 작성이 가능했을 것 같다. + +
+ + + + + + + + + + + + +
숫자 3자리숫자 4자리숫자 5자리
+ + +-------------------------------------------------------- + +### 2️⃣ 4번의 대규모 리팩토링, 그리고 얻어낸 값진 `Number` + +- 영감을 얻게 해줬던 한 마디 + ```bash + 객체는 '자율적인 존재'라는 점을 명심하라. + < 중략 > + 객체는 스스로의 행동에 의해서만 상태가 변경되는 것을 보장함으로써 객체의 자율성을 보장한다. + + - 객체지향의 사실과 오해 中 + ``` + +- First-class collection + Static Factory Method 활용 + ```java + public class Number { + private final List numbers; + + // Player Input Number Constructor + private Number(String input) { + validateEmpty(input); + validateNumberLength(input); + validateContainOnlyNumber(input); + validateContainDuplicatedNumber(input); + + this.numbers = convertInputNumber(input); + } + + // Computer Generated Number Constructor + private Number(List computerNumber) { + this.numbers = computerNumber; + } + } + ``` + +- 일급 컬렉션과 생성자 검증을 사용해 `numbers`에 유효하게 검증이 끝난 숫자만 들어오도록 설계 +- playerNumber와, computerNumber의 생성자는 서로 다른 파라미터를 지니기 때문에, 개발자가 사용 간 혼동 가능
+ 해당 문제를 해결하기 위해, 생성자를 `private`으로 제한하고, 의미있는 메소드로만 생성자를 호출하도록 설계 + + ```java + // Computer Generated Number Constructor + public static Number generateRandomNumbers() { + List randomNumbers = new ArrayList<>(); + while (randomNumbers.size() < NUMBER_LENGTH.getValue()) { + int number = pickNumberInRange(RANDOM_NUMBER_MINIMUM.getValue(), RANDOM_NUMBER_MAXIMUM.getValue()); + if (!hasDuplicatedNumber(randomNumbers, number)) { + randomNumbers.add(number); + } + } + return new Number(randomNumbers); + } + + // Player Number Static Factory Method + public static Number inputPlayerNumbers() { + String playerNumbers = InputView.askPlayerNumbers(); + return new Number(playerNumbers); + } + ``` +- 정적 팩토리 메소드 명에 의미를 부여하고, 개발자가 직관적으로 해석할 수 있도록 했고,
+ 일급 컬렉션을 활용해 검증이 끝난 유효한 값만 리스트에 담을 수 있게 되었다! + +- + diff --git a/src/main/java/baseball/Application.java b/src/main/java/baseball/Application.java index dd95a34..0443ab5 100644 --- a/src/main/java/baseball/Application.java +++ b/src/main/java/baseball/Application.java @@ -1,7 +1,10 @@ package baseball; +import baseball.controller.Game; + public class Application { public static void main(String[] args) { - // TODO: 프로그램 구현 + Game game = new Game(); + game.start(); } } diff --git a/src/main/java/baseball/controller/Game.java b/src/main/java/baseball/controller/Game.java new file mode 100644 index 0000000..3ef9dec --- /dev/null +++ b/src/main/java/baseball/controller/Game.java @@ -0,0 +1,36 @@ +package baseball.controller; + +import baseball.domain.Number; +import baseball.domain.Result; +import baseball.validator.InputValidator; +import baseball.view.InputView; +import baseball.view.OutputView; + +import static baseball.view.OutputView.printStaticNotice; +import static baseball.view.constants.StaticNotice.GAME_START; + +public class Game { + public void start() { + printStaticNotice(GAME_START); + do { + Number computerNumber = Number.generateRandomNumbers(); + play(computerNumber); + } while (!askRestartOrExit()); + } + + private void play(Number computerNumber) { + while (true) { + Number playerNumber = Number.inputPlayerNumbers(); + Result result = Result.create(playerNumber, computerNumber); + OutputView.printMessage(result.createResultMessage()); + if (result.checkGameOver()) { + break; + } + } + } + + private boolean askRestartOrExit() { + String input = InputView.askRestartOrExit(); + return InputValidator.isValidRestartFlag(input); + } +} diff --git a/src/main/java/baseball/domain/Number.java b/src/main/java/baseball/domain/Number.java new file mode 100644 index 0000000..df619ae --- /dev/null +++ b/src/main/java/baseball/domain/Number.java @@ -0,0 +1,80 @@ +package baseball.domain; + +import baseball.view.InputView; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.IntStream; + +import static baseball.global.GameConfig.*; +import static baseball.validator.InputValidator.*; +import static camp.nextstep.edu.missionutils.Randoms.pickNumberInRange; + +public class Number { + private final List numbers; + + // Player Number Constructor + private Number(String input) { + validateEmpty(input); + validateNumberLength(input); + validateContainOnlyNumber(input); + validateContainDuplicatedNumber(input); + + this.numbers = convertInputNumber(input); + } + + // Computer Number Constructor + private Number(List computerNumber) { + this.numbers = computerNumber; + } + + // Player Number Static Factory Method + public static Number inputPlayerNumbers() { + String playerNumbers = InputView.askPlayerNumbers(); + return new Number(playerNumbers); + } + + // Computer Number Static Factory Method + public static Number generateRandomNumbers() { + List randomNumbers = new ArrayList<>(); + while (randomNumbers.size() < NUMBER_LENGTH.getValue()) { + int number = pickNumberInRange(RANDOM_NUMBER_MINIMUM.getValue(), RANDOM_NUMBER_MAXIMUM.getValue()); + if (!hasDuplicatedNumber(randomNumbers, number)) { + randomNumbers.add(number); + } + } + return new Number(randomNumbers); + } + + private static boolean hasDuplicatedNumber(List randomNumbers, int number) { + return randomNumbers.contains(number); + } + + private List convertInputNumber(String input) { + return input.chars() + .mapToObj(Character::getNumericValue) + .toList(); + } + + public int countBallCount(final Number comparableNumber) { + return (int) IntStream.range(0, numbers.size()) + .filter(i -> comparableNumber.isBall(numbers.get(i), i)) + .count(); + } + + public int countStrikeCount(final Number comparableNumber) { + return (int) IntStream.range(0, numbers.size()) + .filter(i -> comparableNumber.isStrike(numbers.get(i), i)) + .count(); + } + + // Ball : Contain their number at other position + private boolean isBall(int number, int digit) { + return !isStrike(number, digit) && numbers.contains(number); + } + + // Strike : Contain their number at same digit + private boolean isStrike(int number, int digit) { + return number == numbers.get(digit); + } +} diff --git a/src/main/java/baseball/domain/Result.java b/src/main/java/baseball/domain/Result.java new file mode 100644 index 0000000..8d5195d --- /dev/null +++ b/src/main/java/baseball/domain/Result.java @@ -0,0 +1,73 @@ +package baseball.domain; + +import baseball.domain.constants.ResultType; +import baseball.global.exception.BaseballException; + +import static baseball.domain.constants.ResultType.*; +import static baseball.global.GameConfig.NUMBER_LENGTH; +import static baseball.global.exception.ErrorMessage.SYSTEM_ERROR; +import static java.lang.String.format; + +public class Result { + private final int ballCount; + private final int strikeCount; + + private Result(final Number playerNumber, final Number computerNumber) { + ballCount = playerNumber.countBallCount(computerNumber); + strikeCount = playerNumber.countStrikeCount(computerNumber); + } + + public static Result create(final Number playerNumber, final Number computerNumber) { + return new Result(playerNumber, computerNumber); + } + + private ResultType inspectResultType() { + if (isNothing()) { + return NOTHING; + } + if (hasBallAndStrike()) { + return BALL_AND_STRIKE; + } + if (hasBall()) { + return ONLY_BALL; + } + if (hasStrike()) { + return ONLY_STRIKE; + } + throw BaseballException.of(SYSTEM_ERROR); + } + + private boolean hasBallAndStrike() { + return hasBall() && hasStrike(); + } + + private boolean isNothing() { + return !hasBall() && !hasStrike(); + } + + private String generateResultMessage(ResultType resultType) { + return switch (resultType) { + case NOTHING -> resultType.getValue(); + case BALL_AND_STRIKE -> format(BALL_AND_STRIKE.getValue(), ballCount, strikeCount); + case ONLY_BALL -> format(ONLY_BALL.getValue(), ballCount); + case ONLY_STRIKE -> format(ONLY_STRIKE.getValue(), strikeCount); + }; + } + + public String createResultMessage() { + ResultType resultType = inspectResultType(); + return generateResultMessage(resultType); + } + + public boolean hasBall() { + return ballCount > 0; + } + + public boolean hasStrike() { + return strikeCount > 0; + } + + public boolean checkGameOver() { + return strikeCount == NUMBER_LENGTH.getValue(); + } +} diff --git a/src/main/java/baseball/domain/constants/ResultType.java b/src/main/java/baseball/domain/constants/ResultType.java new file mode 100644 index 0000000..7f17806 --- /dev/null +++ b/src/main/java/baseball/domain/constants/ResultType.java @@ -0,0 +1,19 @@ +package baseball.domain.constants; + +public enum ResultType { + + NOTHING("낫싱"), + ONLY_BALL("%d볼"), + ONLY_STRIKE("%d스트라이크"), + BALL_AND_STRIKE("%d볼 %d스트라이크"); + + private final String value; + + ResultType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/baseball/global/GameConfig.java b/src/main/java/baseball/global/GameConfig.java new file mode 100644 index 0000000..6952f68 --- /dev/null +++ b/src/main/java/baseball/global/GameConfig.java @@ -0,0 +1,25 @@ +package baseball.global; + +public enum GameConfig { + NUMBER_LENGTH(3), + RANDOM_NUMBER_MINIMUM(1), + RANDOM_NUMBER_MAXIMUM(9), + + EXIT_FLAG(1), + RESTART_FLAG(2); + + private final int value; + + GameConfig(int value) { + this.value = value; + } + + @Override + public String toString() { + return String.valueOf(value); + } + + public int getValue() { + return value; + } +} diff --git a/src/main/java/baseball/global/exception/BaseballException.java b/src/main/java/baseball/global/exception/BaseballException.java new file mode 100644 index 0000000..77c784a --- /dev/null +++ b/src/main/java/baseball/global/exception/BaseballException.java @@ -0,0 +1,11 @@ +package baseball.global.exception; + +public class BaseballException extends IllegalArgumentException { + private BaseballException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + } + + public static BaseballException of(ErrorMessage errorMessage) { + return new BaseballException(errorMessage); + } +} diff --git a/src/main/java/baseball/global/exception/ErrorMessage.java b/src/main/java/baseball/global/exception/ErrorMessage.java new file mode 100644 index 0000000..30e4f0b --- /dev/null +++ b/src/main/java/baseball/global/exception/ErrorMessage.java @@ -0,0 +1,20 @@ +package baseball.global.exception; + +public enum ErrorMessage { + INVALID_LENGTH("The input length cannot be different from length configured by the game."), + DUPLICATED_NUMBER("The input cannot contain duplicated numbers."), + CONTAIN_LETTER("The input cannot contain letters."), + EMPTY_NUMBER("The input cannot be empty."), + INVALID_FLAG("The input cannot be different flag from configured by the game."), + SYSTEM_ERROR("The game system has crashed"); + + private final String message; + + ErrorMessage(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/baseball/validator/InputValidator.java b/src/main/java/baseball/validator/InputValidator.java new file mode 100644 index 0000000..12d7cd5 --- /dev/null +++ b/src/main/java/baseball/validator/InputValidator.java @@ -0,0 +1,76 @@ +package baseball.validator; + + +import baseball.global.exception.BaseballException; + +import static baseball.global.GameConfig.*; +import static baseball.global.exception.ErrorMessage.*; + +public class InputValidator { + protected InputValidator() { + } + + public static boolean isValidRestartFlag(String flag) { + validateGameFlag(flag); + return flag.equals(RESTART_FLAG.toString()); + } + + private static void validateGameFlag(final String flag) { + if (!isExitFlag(flag) && !isRestartFlag(flag)) { + throw BaseballException.of(INVALID_FLAG); + } + } + + private static boolean isExitFlag(String flag) { + return flag.equals(EXIT_FLAG.toString()); + } + + private static boolean isRestartFlag(String flag) { + return flag.equals(RESTART_FLAG.toString()); + } + + + public static void validateNumberLength(final String number) { + if (!isValidLength(number)) { + throw BaseballException.of(INVALID_LENGTH); + } + } + + public static void validateContainDuplicatedNumber(final String number) { + if (!isUniqueNumber(number)) { + throw BaseballException.of(DUPLICATED_NUMBER); + } + } + + public static void validateContainOnlyNumber(final String number) { + if (!isValidNumber(number)) { + throw BaseballException.of(CONTAIN_LETTER); + } + } + + public static void validateEmpty(final String number) { + if (isEmpty(number)) { + throw BaseballException.of(EMPTY_NUMBER); + } + } + + private static boolean isEmpty(String number) { + return number.isEmpty(); + } + + private static boolean isValidLength(final String number) { + return number.length() == NUMBER_LENGTH.getValue(); + } + + private static boolean isValidNumber(final String number) { + return number + .chars() + .allMatch(c -> Character.isDigit(c) && c >= '1' && c <= '9'); + } + + private static boolean isUniqueNumber(final String number) { + return number.chars() + .distinct() + .count() == number.length(); + } +} diff --git a/src/main/java/baseball/view/InputView.java b/src/main/java/baseball/view/InputView.java new file mode 100644 index 0000000..a4216b1 --- /dev/null +++ b/src/main/java/baseball/view/InputView.java @@ -0,0 +1,18 @@ +package baseball.view; + +import static baseball.view.OutputView.printStaticNotice; +import static baseball.view.constants.StaticNotice.*; +import static camp.nextstep.edu.missionutils.Console.readLine; + +public class InputView { + public static String askPlayerNumbers() { + printStaticNotice(ASK_PLAYER_NUMBER); + return readLine(); + } + + public static String askRestartOrExit() { + printStaticNotice(GAME_OVER); + printStaticNotice(ASK_RESTART_OR_EXIT); + return readLine(); + } +} diff --git a/src/main/java/baseball/view/OutputView.java b/src/main/java/baseball/view/OutputView.java new file mode 100644 index 0000000..cdf550e --- /dev/null +++ b/src/main/java/baseball/view/OutputView.java @@ -0,0 +1,13 @@ +package baseball.view; + +import baseball.view.constants.StaticNotice; + +public class OutputView { + public static void printStaticNotice(StaticNotice notice) { + System.out.print(notice.getMessage()); + } + + public static void printMessage(String message) { + System.out.println(message); + } +} diff --git a/src/main/java/baseball/view/constants/StaticNotice.java b/src/main/java/baseball/view/constants/StaticNotice.java new file mode 100644 index 0000000..6bc269d --- /dev/null +++ b/src/main/java/baseball/view/constants/StaticNotice.java @@ -0,0 +1,21 @@ +package baseball.view.constants; + +import static baseball.global.GameConfig.NUMBER_LENGTH; +import static java.lang.String.format; + +public enum StaticNotice { + GAME_START("숫자 야구 게임을 시작합니다.\n"), + ASK_PLAYER_NUMBER("숫자를 입력해주세요 : "), + ASK_RESTART_OR_EXIT("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.\n"), + GAME_OVER(format("%d개의 숫자를 모두 맞히셨습니다! 게임 종료%n", NUMBER_LENGTH.getValue())); + + private final String message; + + StaticNotice(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } +} diff --git a/src/test/java/baseball/ApplicationTest.java b/src/test/java/baseball/ApplicationTest.java index 3fa29fa..fcd4cde 100644 --- a/src/test/java/baseball/ApplicationTest.java +++ b/src/test/java/baseball/ApplicationTest.java @@ -1,13 +1,13 @@ package baseball; -import camp.nextstep.edu.missionutils.test.NsTest; -import org.junit.jupiter.api.Test; - import static camp.nextstep.edu.missionutils.test.Assertions.assertRandomNumberInRangeTest; import static camp.nextstep.edu.missionutils.test.Assertions.assertSimpleTest; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import camp.nextstep.edu.missionutils.test.NsTest; +import org.junit.jupiter.api.Test; + class ApplicationTest extends NsTest { @Test void 게임종료_후_재시작() {