diff --git a/README.md b/README.md index 8d7e8aee..9ff4ac9b 100644 --- a/README.md +++ b/README.md @@ -1 +1,55 @@ -# java-baseball-precourse \ No newline at end of file +# java-baseball-precourse + +## 프로그램 설계 + +본 숫자 야구 게임은 MVC 패턴을 기반으로 구현하도록 한다. + +### Model + +게임의 핵심 도메인 로직을 담당한다. + +- 봇(컴퓨터)의 랜덤 숫자 생성 +- 플레이어(사용자)의 입력 검증 +- 스트라이크 / 볼 / 낫싱 판정 +- 게임 종료 조건 판단 + +### View + +사용자와의 입출력을 담당하며 게임 로직을 포함하지 않는다. + +- 게임 시작 안내 출력 +- 사용자 입력 요청 +- 판정 결과 출력 +- 게임 종료 및 재시작 안내 + +### Controller + +Model과 View를 연결하며 게임의 전체 흐름을 제어한다. + +- 게임 시작 +- 입력 처리 +- 판정 요청 +- 종료 및 재시작 여부 결정 + +## 기능 목록 (커밋 단위) + +- [ ] 코드 컨벤션 툴 세팅 + - [링크](https://naver.github.io/hackday-conventions-java/)의 코드 컨벤션을 따르도록 인텔리제이 환경 세팅 +- [ ] 봇의 숫자 생성 로직 구현 + - 봇(컴퓨터)가 1~9 사이의 서로 다른 수로 이루어진 3자리 숫자를 생성한다. +- [ ] 봇의 숫자 생성 로직 구현 단위 테스트 +- [ ] 플레이어 입력 검증 로직 구현 + - 플레이어(사용자)는 1~9 사이의 3자리 숫자를 입력할 수 있다. + - 입력값이 유효하지 않으면 예외를 발생시킨다. +- [ ] 플레이어 입력 검증 로직 구현 단위 테스트 +- [ ] '스트라이크' 개수 카운팅 로직 구현 + - 스트라이크는 숫자도 맞고 위치도 맞은 경우 +- [ ] '스트라이크' 개수 카운팅 로직 구현 단위 테스트 +- [ ] '볼' 개수 카운팅 로직 구현 + - 볼은 숫자는 맞지만 위치는 틀린 경우 +- [ ] '볼' 개수 카운팅 로직 구현 단위 테스트 +- [ ] 플레이어 입력 판정 로직 구현 +- [ ] 플레이어 입력 판정 로직 구현 단위 테스트 +- [ ] 게임 진행 및 종료 뷰 구현 +- [ ] 게임 진행 및 종료 컨트롤러 구현 +- [ ] 게임 구동 main 함수 구현 diff --git a/config/checkstyle/naver-checkstyle-rules.xml b/config/checkstyle/naver-checkstyle-rules.xml new file mode 100644 index 00000000..dafbb4d1 --- /dev/null +++ b/config/checkstyle/naver-checkstyle-rules.xml @@ -0,0 +1,433 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/checkstyle/naver-checkstyle-suppressions.xml b/config/checkstyle/naver-checkstyle-suppressions.xml new file mode 100644 index 00000000..3f11e0cd --- /dev/null +++ b/config/checkstyle/naver-checkstyle-suppressions.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/config/checkstyle/naver-intellij-formatter.xml b/config/checkstyle/naver-intellij-formatter.xml new file mode 100644 index 00000000..658fc659 --- /dev/null +++ b/config/checkstyle/naver-intellij-formatter.xml @@ -0,0 +1,62 @@ + + + diff --git a/src/main/java/Application.java b/src/main/java/Application.java new file mode 100644 index 00000000..cba0effe --- /dev/null +++ b/src/main/java/Application.java @@ -0,0 +1,8 @@ +import controller.GameController; + +public class Application { + public static void main(String[] args) { + GameController controller = new GameController(); + controller.run(); + } +} diff --git a/src/main/java/controller/GameController.java b/src/main/java/controller/GameController.java new file mode 100644 index 00000000..05e68e77 --- /dev/null +++ b/src/main/java/controller/GameController.java @@ -0,0 +1,48 @@ +package controller; + +import model.ComputerNumber; +import model.Judge; +import model.PlayerNumber; +import model.object.Result; +import view.GameView; + +public class GameController { + + private final GameView view = new GameView(); + private final Judge judge = new Judge(); + + private Result round(ComputerNumber computer) { + try { + PlayerNumber player = new PlayerNumber(view.readPlayerNumber()); + return judge.judge(computer, player); + } catch (IllegalArgumentException e) { + view.printErrorMessage(e.getMessage()); + return round(computer); + } + } + + private boolean isRestart() { + return view.readRestartCommand() == 1; + } + + public void run() { + do { + playGame(); + } while (isRestart()); + } + + private void playGame() { + ComputerNumber computer = new ComputerNumber(); + + while (true) { + Result result = round(computer); + view.printResult(result); + + if (result.isThreeStrike()) { + view.printGamClear(); + return; + } + } + } + +} diff --git a/src/main/java/model/BallCounter.java b/src/main/java/model/BallCounter.java new file mode 100644 index 00000000..b0fa09ab --- /dev/null +++ b/src/main/java/model/BallCounter.java @@ -0,0 +1,23 @@ +package model; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class BallCounter { + + private final StrikeCounter strikeCounter = new StrikeCounter(); + + public List extractCommonNumber(List a, List b) { + Set common = new HashSet<>(a); + common.retainAll(b); + return new ArrayList<>(common); + } + + public int count(ComputerNumber computer, PlayerNumber player) { + List commonNumbers = extractCommonNumber(computer.getNumbers(), player.getNumbers()); + return commonNumbers.size() - strikeCounter.count(computer, player); + } + +} diff --git a/src/main/java/model/ComputerNumber.java b/src/main/java/model/ComputerNumber.java new file mode 100644 index 00000000..2d51f6e5 --- /dev/null +++ b/src/main/java/model/ComputerNumber.java @@ -0,0 +1,37 @@ +package model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class ComputerNumber { + + private static final int SIZE = 3; + private static final int MIN = 1; + private static final int MAX = 9; + + private final List numbers; + + public ComputerNumber(List numbers) { + this.numbers = numbers; + } + + public ComputerNumber() { + this.numbers = generate(); + } + + private List generate() { + List pool = new ArrayList<>(); + for (int i = MIN; i <= MAX; i++) { + pool.add(i); + } + + Collections.shuffle(pool); + return pool.subList(0, SIZE); + } + + public List getNumbers() { + return numbers; + } + +} diff --git a/src/main/java/model/Judge.java b/src/main/java/model/Judge.java new file mode 100644 index 00000000..f4a4c8be --- /dev/null +++ b/src/main/java/model/Judge.java @@ -0,0 +1,21 @@ +package model; + +import model.object.Result; + +public class Judge { + + private final StrikeCounter strikeCounter; + private final BallCounter ballCounter; + + public Judge() { + this.strikeCounter = new StrikeCounter(); + this.ballCounter = new BallCounter(); + } + + public Result judge(ComputerNumber computerNumber, PlayerNumber playerNumber) { + int strike = strikeCounter.count(computerNumber, playerNumber); + int ball = ballCounter.count(computerNumber, playerNumber); + + return new Result(strike, ball); + } +} diff --git a/src/main/java/model/PlayerNumber.java b/src/main/java/model/PlayerNumber.java new file mode 100644 index 00000000..ab4e2a68 --- /dev/null +++ b/src/main/java/model/PlayerNumber.java @@ -0,0 +1,60 @@ +package model; + +import java.util.ArrayList; +import java.util.List; + +public class PlayerNumber { + + private static final int NUMBER_COUNT = 3; + private static final int MIN = 1; + private static final int MAX = 9; + + private final List numbers; + + public PlayerNumber(String input) { + validate(input); + this.numbers = parse(input); + } + + private void validate(String input) { + validateLength(input); + validateAllDigits(input); + validateRange(input); + } + + private void validateLength(String input) { + if (input.length() != NUMBER_COUNT) { + throw new IllegalArgumentException("입력은 3자리 숫자여야 합니다."); + } + } + + private void validateAllDigits(String input) { + for (char c : input.toCharArray()) { + if (!Character.isDigit(c)) { + throw new IllegalArgumentException("숫자 이외의 입력은 불가능합니다."); + } + } + } + + private void validateRange(String input) { + for (char c : input.toCharArray()) { + int number = c - '0'; + if (number < MIN || number > MAX) { + throw new IllegalArgumentException("1 ~ 9까지의 숫자만 입력 가능합니다."); + } + } + } + + private List parse(String input) { + List result = new ArrayList<>(); + for (char c : input.toCharArray()) { + result.add(c - '0'); + } + return result; + } + + public List getNumbers() { + return numbers; + } + +} diff --git a/src/main/java/model/StrikeCounter.java b/src/main/java/model/StrikeCounter.java new file mode 100644 index 00000000..b84a7efd --- /dev/null +++ b/src/main/java/model/StrikeCounter.java @@ -0,0 +1,19 @@ +package model; + +import java.util.List; + +public class StrikeCounter { + + public int count(ComputerNumber computerNumber, PlayerNumber playerNumber) { + List computer = computerNumber.getNumbers(); + List player = playerNumber.getNumbers(); + + int strike = 0; + for (int i = 0; i < computer.size(); i++) { + if (computer.get(i).equals(player.get(i))) { + strike++; + } + } + return strike; + } +} diff --git a/src/main/java/model/object/Result.java b/src/main/java/model/object/Result.java new file mode 100644 index 00000000..c6606441 --- /dev/null +++ b/src/main/java/model/object/Result.java @@ -0,0 +1,57 @@ +package model.object; + +public class Result { + + private final int strike; + private final int ball; + + public Result(int strike, int ball) { + validate(strike, ball); + this.strike = strike; + this.ball = ball; + } + + private void validate(int strike, int ball) { + if (strike < 0 || ball < 0) { + throw new IllegalArgumentException("스트라이크와 볼은 음수가 될 수 없습니다."); + } + if (strike + ball > 3) { + throw new IllegalArgumentException("스트라이크와 볼의 합은 3을 넘을 수 없습니다."); + } + } + + public int getStrike() { + return this.strike; + } + + public int getBall() { + return this.ball; + } + + public boolean isThreeStrike() { + return this.strike == 3 && this.ball == 0; + } + + @Override + public String toString() { + if (strike == 0 && ball == 0) { + return "낫싱"; + } + + StringBuilder result = new StringBuilder(); + + if (strike > 0) { + result.append(strike).append("스트라이크"); + } + + if (strike > 0 && ball > 0) { + result.append(" "); + } + + if (ball > 0) { + result.append(ball).append("볼"); + } + + return result.toString(); + } +} diff --git a/src/main/java/view/GameView.java b/src/main/java/view/GameView.java new file mode 100644 index 00000000..23800841 --- /dev/null +++ b/src/main/java/view/GameView.java @@ -0,0 +1,32 @@ +package view; + +import java.util.Scanner; + +import model.object.Result; + +public class GameView { + + private final Scanner scanner = new Scanner(System.in); + + public String readPlayerNumber() { + System.out.print("숫자를 입력해주세요 : "); + return scanner.nextLine(); + } + + public void printResult(Result result) { + System.out.println(result.toString()); + } + + public void printGamClear() { + System.out.println("3개의 숫자를 모두 맞히셨습니다!"); + } + + public int readRestartCommand() { + System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요."); + return Integer.parseInt(scanner.nextLine()); + } + + public void printErrorMessage(String message) { + System.out.println("[ERROR] " + message); + } +} diff --git a/src/test/java/model/BallCounterTest.java b/src/test/java/model/BallCounterTest.java new file mode 100644 index 00000000..e38b5ab8 --- /dev/null +++ b/src/test/java/model/BallCounterTest.java @@ -0,0 +1,53 @@ +package model; + +import static org.assertj.core.api.AssertionsForClassTypes.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import model.object.TestComputerNumber; + +public class BallCounterTest { + + private final BallCounter ballCounter = new BallCounter(); + + @Test + @DisplayName("같은 숫자가 다른 위치에 있으면 볼로 계산한다") + void countBallWhenNumberMatchesButPositionDiffers() { + ComputerNumber computer = TestComputerNumber.of(1, 2, 3); + PlayerNumber player = new PlayerNumber("312"); + + int ball = ballCounter.count(computer, player); + assertThat(ball).isEqualTo(3); + } + + @Test + @DisplayName("같은 위치의 숫자는 볼로 계산하지 않는다") + void doNotCountBallWhenPositionMatches() { + ComputerNumber computer = TestComputerNumber.of(1, 2, 3); + PlayerNumber player = new PlayerNumber("129"); + + int ball = ballCounter.count(computer, player); + assertThat(ball).isEqualTo(0); + } + + @Test + @DisplayName("플레이어의 숫자에서 중복된 수가 있는 경우 스트라이크를 볼보다 우선시 한다") + void countBallOnceEvenIfPlayerHasDuplicates() { + ComputerNumber computer = TestComputerNumber.of(1, 2, 3); + PlayerNumber player = new PlayerNumber("124"); + + int ball = ballCounter.count(computer, player); + assertThat(ball).isEqualTo(0); + } + + @Test + @DisplayName("스트라이크로 사용된 숫자는 볼로 계산되지 않는다") + void doNotCountBallForStrikeNumbers() { + ComputerNumber computer = TestComputerNumber.of(4, 2, 7); + PlayerNumber player = new PlayerNumber("223"); + + int ball = ballCounter.count(computer, player); + assertThat(ball).isEqualTo(0); + } +} diff --git a/src/test/java/model/ComputerNumberTest.java b/src/test/java/model/ComputerNumberTest.java new file mode 100644 index 00000000..d466c773 --- /dev/null +++ b/src/test/java/model/ComputerNumberTest.java @@ -0,0 +1,39 @@ +package model; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.*; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class ComputerNumberTest { + + @Test + @DisplayName("봇(컴퓨터)는 항상 3자리인 숫자를 생성한다") + void generateThreeDigitNumber() { + ComputerNumber computerNumber = new ComputerNumber(); + List numbers = computerNumber.getNumbers(); + assertThat(numbers).hasSize(3); + } + + @Test + @DisplayName("봇(컴퓨터)이 생성한 숫자는 1에서 9 사이의 값이다") + void generateNumberWithinRange() { + ComputerNumber computerNumber = new ComputerNumber(); + assertThat(computerNumber.getNumbers()) + .allSatisfy(number -> + assertThat(number).isBetween(1, 9)); + } + + @Test + @DisplayName("봇(컴퓨터)이 생성한 숫자는 서로 중복되지 않는다") + void generateDistinctDigits() { + ComputerNumber computerNumber = new ComputerNumber(); + Set numbers = new HashSet<>(computerNumber.getNumbers()); + assertThat(numbers).hasSize(3); + } + +} diff --git a/src/test/java/model/JudgeTest.java b/src/test/java/model/JudgeTest.java new file mode 100644 index 00000000..d35baca1 --- /dev/null +++ b/src/test/java/model/JudgeTest.java @@ -0,0 +1,50 @@ +package model; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import model.object.Result; +import model.object.TestComputerNumber; + +public class JudgeTest { + + private final Judge judge = new Judge(); + + @Test + @DisplayName("스트라이크와 볼을 종합하여 Result를 생성한다") + void judgeReturnsCorrectResult() { + ComputerNumber computer = TestComputerNumber.of(1, 2, 3); + PlayerNumber player = new PlayerNumber("132"); + + Result result = judge.judge(computer, player); + assertThat(result.getStrike()).isEqualTo(1); + assertThat(result.getBall()).isEqualTo(2); + assertThat(result.toString()).isEqualTo("1스트라이크 2볼"); + } + + @Test + @DisplayName("스트라이크와 볼이 모두 0이면 낫싱이다") + void judgeReturnsNothing() { + ComputerNumber computer = TestComputerNumber.of(1, 2, 3); + PlayerNumber player = new PlayerNumber("456"); + + Result result = judge.judge(computer, player); + assertThat(result.getStrike()).isZero(); + assertThat(result.getBall()).isZero(); + assertThat(result.toString()).isEqualTo("낫싱"); + } + + @Test + @DisplayName("플레이어 숫자가 중복되어도 규칙에 맞게 판정한다") + void judgeHandlesDuplicatePlayerNumber() { + ComputerNumber computer = TestComputerNumber.of(4, 2, 7); + PlayerNumber player = new PlayerNumber("223"); + + Result result = judge.judge(computer, player); + assertThat(result.getStrike()).isEqualTo(1); + assertThat(result.getBall()).isZero(); + assertThat(result.toString()).isEqualTo("1스트라이크"); + } +} diff --git a/src/test/java/model/PlayerNumberTest.java b/src/test/java/model/PlayerNumberTest.java new file mode 100644 index 00000000..af4cdfab --- /dev/null +++ b/src/test/java/model/PlayerNumberTest.java @@ -0,0 +1,38 @@ +package model; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class PlayerNumberTest { + + @Test + @DisplayName("플레이어는 1~9 사이의 숫자 3자리를 입력할 수 있다") + void createPlayerNumberWithValidInput() { + PlayerNumber playerNumber = new PlayerNumber("123"); + assertThat(playerNumber.getNumbers()) + .containsExactly(1, 2, 3); + } + + @Test + @DisplayName("플레이어가 입력한 숫자가 3자리가 아니면 예외가 발생한다") + void throwExceptionWhenLengthIsNot3() { + assertThatThrownBy(() -> new PlayerNumber("12")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("플레이어가 입력한 값에 숫자가 아닌 문자가 포함되면 예외가 발생한다.") + void throwExceptionWhenContainsNonDigit() { + assertThatThrownBy(() -> new PlayerNumber("12a")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("플레이어가 입력한 값에 0이 포함되면 예외가 발생한다.") + void throwExceptionWhenContainsZero() { + assertThatThrownBy(() -> new PlayerNumber("120")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/model/StrikeCounterTest.java b/src/test/java/model/StrikeCounterTest.java new file mode 100644 index 00000000..01984294 --- /dev/null +++ b/src/test/java/model/StrikeCounterTest.java @@ -0,0 +1,43 @@ +package model; + +import static org.assertj.core.api.AssertionsForClassTypes.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import model.object.TestComputerNumber; + +public class StrikeCounterTest { + + private final StrikeCounter strikeCounter = new StrikeCounter(); + + @Test + @DisplayName("같은 숫자가 같은 위치에 있으면 스트라이크로 계산한다") + void countStrikeWhenNumberAndPositionMatch() { + ComputerNumber computer = TestComputerNumber.of(1, 2, 3); + PlayerNumber player = new PlayerNumber("123"); + + int strike = strikeCounter.count(computer, player); + assertThat(strike).isEqualTo(3); + } + + @Test + @DisplayName("숫자가 같아도 위치가 다르면 스트라이크가 아니다") + void doNotCountStrikeWhenPositionIsDifferent() { + ComputerNumber computer = TestComputerNumber.of(1, 2, 3); + PlayerNumber player = new PlayerNumber("231"); + + int strike = strikeCounter.count(computer, player); + assertThat(strike).isEqualTo(0); + } + + @Test + @DisplayName("일부 숫자만 같은 위치에 있으면 해당 개수만 스트라이크다") + void countPartialStrike() { + ComputerNumber computer = TestComputerNumber.of(1, 2, 3); + PlayerNumber player = new PlayerNumber("129"); + + int strike = strikeCounter.count(computer, player); + assertThat(strike).isEqualTo(2); + } +} diff --git a/src/test/java/model/object/TestComputerNumber.java b/src/test/java/model/object/TestComputerNumber.java new file mode 100644 index 00000000..2782a661 --- /dev/null +++ b/src/test/java/model/object/TestComputerNumber.java @@ -0,0 +1,16 @@ +package model.object; + +import java.util.List; + +import model.ComputerNumber; + +public class TestComputerNumber extends ComputerNumber { + + private TestComputerNumber(List numbers) { + super(numbers); + } + + public static ComputerNumber of(int a, int b, int c) { + return new TestComputerNumber(List.of(a, b, c)); + } +}