diff --git a/README.md b/README.md index 9775dda0ae..051fece684 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,298 @@ -# java-janggi +# [장기] 사이클1 - 미션 (보드 초기화 + 기물 이동) -장기 미션 저장소 +## 목표 + +단순히 코드를 작성하는 것이 아니라, **무엇을 개발해야 하는지 먼저 정리하고** 개발을 진행하는 것이 핵심이다. + +- [x] 1.1단계를 시작하기 전에 **어떤 기능을 구현해야 하는지 정리**한다. +- [x] 1.1단계에서 정리한 내용을 README.md에 기능 목록으로 작성한다. +- [x] 1.2단계를 시작하기 전에 **어떤 기능을 구현해야 하는지 정리**한다. +- [x] 1.2단계에서 정리한 내용을 README.md에 기능 목록으로 작성한다. +- [x] 개발 과정에서 **"나는 왜 이렇게 구현할까?"** 라는 고민을 기록한다. + +--- + +## 기능 요구 사항 + +### 1.1단계 - 보드 초기화 + +- 게임 시작 시 장기판과 전체 기물을 올바른 위치에 초기화한다. +- 1.1단계에서는 기물의 이동은 구현하지 않는다. + +### 1.2단계 - 기물 이동 + +- 각 기물의 이동 규칙을 구현한다. +- 기물의 이동 규칙은 직접 요구사항을 분석하여 정의한다. +- **궁성(宮城) 영역은 구현하지 않는다.** (사이클2에서 다룬다) + +--- + +## 기능 목록 (테스트 명세) + +### 1.1단계: 보드 및 기본 도메인 초기화 + +#### 위치 (Position) +- [x] x 좌표가 0 미만이거나 8 초과이면 예외가 발생한다 +- [x] y 좌표가 0 미만이거나 9 초과이면 예외가 발생한다 +- [x] 올바른 x, y 좌표가 주어지면 객체가 정상적으로 생성된다 + +#### 진영 (Side) +- [x] 진영은 초와 한만 가진다 + +#### 플레이어 (Player) +- [x] 턴 상태를 토글하면 현재 턴 여부가 반전된다 +- [x] 플레이어는 상대방의 기물을 선택하면 예외가 발생한다 + +#### 플레이어들 (Players) +- [x] 초기 플레이어 생성 시 두 플레이어의 이름이 같으면 예외가 발생한다 + +#### 기물 공통 (Piece) +- [x] 주어진 진영과 자신의 진영이 같은지 올바르게 판별한다 + +#### 장기판 초기화 (BoardFactory / Board) +- [x] 선택한 포메이션에 맞게 32개의 기물이 초기 위치에 정확히 배치된다 +- [x] 양 진영의 궁이 지정된 궁성 위치에 정상적으로 배치된다 + +### 1.2단계: 기물 이동 및 게임 상태 + +#### 궁 (General) +- [x] 궁은 상 하 좌 우 1칸 이동할 수 있다 (OneStepStrategy) + +#### 사 (Guard) +- [x] 사는 상 하 좌 우 1칸 이동할 수 있다 (OneStepStrategy) + +#### 졸 (Soldier) +- [x] 초나라 졸은 상 좌 우 1칸 이동할 수 있다 (OneStepStrategy) +- [x] 한나라 졸은 하 좌 우 1칸 이동할 수 있다 (OneStepStrategy) + +#### 마 (Horse) +- [x] 마는 직선 1칸 이동 후 대각선 1칸 방향으로 이동할 수 있다 (SequenceStrategy) +- [x] 마는 직선 1칸 경로에 장애물 기물이 존재하면 해당 방향으로 이동할 수 없다 + +#### 상 (Elephant) +- [x] 상은 직선 1칸 이동 후 대각선 2칸 방향으로 이동할 수 있다 (SequenceStrategy) +- [x] 상은 직선 1칸 또는 대각선 1칸 경로 중 하나라도 장애물 기물이 존재하면 이동할 수 없다 + +#### 차 (Chariot) +- [x] 차는 상 하 좌 우 방향으로 장애물을 만날 때까지 연속해서 이동할 수 있다 (ContinuousStrategy) +- [x] 차는 이동 경로 중 아군 기물을 만나면 그 직전 위치까지만 이동할 수 있다 +- [x] 차는 이동 경로 중 적군 기물을 만나면 해당 위치까지 이동하여 잡을 수 있다 + +#### 포 (Cannon) +- [x] 포는 상 하 좌 우 방향으로 기물 하나를 뛰어넘어 빈칸으로 이동할 수 있다 +- [x] 포는 이동 경로에서 포 기물을 뛰어넘을 수 없다 +- [x] 포는 기물을 뛰어넘은 후 적군 기물을 만나면 해당 위치까지 이동하여 잡을 수 있다 (ContinuousStrategy) +- [x] 포는 뛰어넘은 후 만난 적군 기물이 포일 경우 잡을 수 없다 + +#### 장기판 이동 (Board) +- [x] 목적지에 적군 기물이 있으면 보드에서 해당 기물을 제거하고 이동한다 +- [x] 목적지에 아군 기물이 있으면 이동할 수 없다 +- [x] 기물이 존재하지 않는 빈 좌표를 출발지로 입력하면 예외가 발생한다 + +#### 게임 상태 및 흐름 (Game) +- [x] 기물 이동이 완료되면 턴이 상대방 진영으로 변경된다 +- [x] 이동할 수 있는 목적지가 전혀 없는 기물을 선택하면 예외가 발생한다 +- [x] 선택한 기물이 이동할 수 없는 위치를 목적지로 입력하면 예외가 발생한다 +- [x] 보드에 궁이 하나만 남게 되면 게임은 종료 상태가 된다 + +### 1.3단계: 사용자 입력 및 파서 검증 + +#### 플레이어 이름 입력 +- [x] 이름이 2글자 미만이거나 5글자를 초과하면 예외가 발생한다 +- [x] 이름에 빈 값이나 공백만 입력되면 예외가 발생한다 + +#### 포메이션(마, 상) 옵션 입력 +- [x] 포메이션 입력이 1, 2, 3, 4 일 때 정상 동작한다 +- [x] 포메이션 입력이 1, 2, 3, 4 중 하나가 아니면 예외가 발생한다 + +#### 위치 입력 +- [x] 올바른 좌표 형식이 입력되는 경우 정상 동작한다 +- [x] 위치 입력 포맷이 `(x, y)` 형태가 아니면 예외가 발생한다 +- [x] 위치 입력 포맷 내의 좌표에 숫자가 아닌 값이 포함되면 예외가 발생한다 + +## 구조 및 설계 + +- [x] 이동 방향(Direction) 분리: 상, 하, 좌, 우 등 x/y 증감값을 갖는 Enum 구현 +- [x] 이동 전략(Strategy) 분리: `OneStepStrategy`, `ContinuousStrategy`, `SequenceStrategy` 구현 및 각 기물에 주입 +- [x] 플라이웨이트 팩토리(PieceFactory): 위치 상태가 없는 불변 기물 객체를 싱글톤/캐싱하여 반환 +- [x] 보드 캡슐화(BoardReader): 기물이 보드의 맵 자료구조에 직접 접근하지 못하도록 읽기 전용 인터페이스 도입 +- [x] 불변 맵 복사: 보드 이동 시 `Map.copyOf()`를 사용하여 불변 맵 반환 + +--- + +## 입출력예시 +1. 초나라 플레이어의 이름을 입력받는다. +2. 한나라 플레이어 이름을 입력받는다. +3. 초나라 플레이어의 기물 배치를 입력받는다. + + ``` + 기물 배치를 선택하세요. + 1. 상마상마 + 2. 마상마상 + 3. 상마마상 + 4. 마상상마 + ``` + +4. 한나라 플레이어의 기물 배치를 입력받는다. + + ``` + 기물 배치를 선택하세요. + 1. 상마상마 + 2. 마상마상 + 3. 상마마상 + 4. 마상상마 + ``` + +5. 초나라 플레이어부터 한 턴씩 진행 + + 1. 보드판 출력 + + 2. 플레이어가 선택할 수 있는 기물의 위치를 입력받는다. + + ``` + (보드판 출력) + 이동 시킬 기물의 위치를 입력하세요. ex) (0, 3) + ``` + + 3. 선택한 기물의 목적지를 입력받는다. + + ``` + 선택한 기물이 이동할 수 있는 위치입니다. 이동할 위치를 입력하세요. ex) (0, 3) + (x, y), (x, y), ... + ``` + + 4. 왕이 잡히면 게임 종료 + ``` + 제이콥(이/가) 승리했습니다. + ``` + +--- + +## 프로그래밍 요구 사항 + +- [x] 자바 코드 컨벤션을 지키면서 프로그래밍한다. + - [x] 기본적으로[Java Style Guide](https://github.com/woowacourse/woowacourse-docs/tree/master/styleguide/java)을 원칙으로 한다. +- [x] indent(인덴트, 들여쓰기) depth를 2를 넘지 않도록 구현한다. 1까지만 허용한다. + - [x] 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다. + - [x] 힌트: indent(인덴트, 들여쓰기) depth를 줄이는 좋은 방법은 함수(또는 메서드)를 분리하면 된다. +- [x] 3항 연산자를 쓰지 않는다. +- [x] else 예약어를 쓰지 않는다. + - [x] else 예약어를 쓰지 말라고 하니 switch/case로 구현하는 경우가 있는데 switch/case도 허용하지 않는다. + - [x] 힌트: if문에서 값을 반환하는 방식으로 구현하면 else 예약어를 사용하지 않아도 된다. +- [ ] 모든 기능을 TDD로 구현해 단위 테스트가 존재해야 한다. 단, UI(System.out, System.in) 로직은 제외 + - [ ] 핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 구분한다. + - [x] UI 로직을 view.InputView, ResultView와 같은 클래스를 추가해 분리한다. +- [x] 함수(또는 메서드)의 길이가 10라인을 넘어가지 않도록 구현한다. + - [x] 함수(또는 메소드)가 한 가지 일만 하도록 최대한 작게 만들어라. +- [x] 배열 대신 컬렉션을 사용한다. +- [ ] 모든 원시 값과 문자열을 포장한다. +- [x] 줄여 쓰지 않는다(축약 금지). +- [x] 일급 컬렉션을 쓴다. +- [x] 모든 엔티티를 작게 유지한다. +- [x] 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다. + +### 추가된 요구 사항 + +- [x] 도메인의 의존성을 최소한으로 구현한다. +- [ ] 한 줄에 점을 하나만 찍는다. +- [ ] 게터/세터/프로퍼티를 쓰지 않는다. +- [ ] 모든 객체지향 생활 체조 원칙을 잘 지키며 구현한다. +- [ ] [프로그래밍 체크리스트](https://github.com/woowacourse/woowacourse-docs/blob/master/cleancode/pr_checklist.md)의 원칙을 지키면서 프로그래밍한다. + +--- + +## 팀 규칙 + +> [!abstract] ### **1. 상태 위치 규칙** +> +> (If-Then) 만약 기물의 위치가 이동, 잡힘, 배치 변경처럼 게임판 전체 상태 변화와 함께 바뀐다면 +> +> → 위치 정보는 보드가 관리하고, 기물은 자신의 위치를 직접 가지지 않는다 +> +> (기준) 상태 소유 기준: 변경의 책임이 있는 객체가 그 상태를 소유한다 +> +> (금지) 이번 미션에서 보드와 기물이 동시에 위치 상태를 가지지 않는다 +> +> --- +> +> ### **2. 불변 객체 기준** +> +> (If-Then) 만약 기물이 가지는 정보가 종류, 소속 팀, 이동 규칙처럼 게임 도중 바뀌지 않는 값이라면 +> +> → 기물 객체는 불변으로 만든다 +> +> (기준) 불변성 판단 기준: 게임 도중 변하지 않는 정보는 객체 내부에서 변경 불가능해야 한다 +> +> (금지) 이번 미션에서 기물의 종류, 팀, 이동 규칙을 생성 후 변경하지 않는다 +> +> --- +> +> ### **3. 캡슐화 기준** +> +> (If-Then) 만약 어떤 상태를 외부에서 직접 수정 시 규칙 위반이나 정합성 문제가 생긴다면 +> +> → 그 상태는 내부에 캡슐화하고, 책임 있는 객체만 변경할 수 있게 한다 +> +> (If-Then) 만약 보드의 내부 상태가 외부 로직에 그대로 노출되면 +> +> → 보드의 상태는 직접 수정 가능한 형태로 공개하지 않고, 필요한 동작만 메서드로 제공한다 +> +> (기준) 캡슐화 기준: 정합성을 깨뜨릴 수 있는 내부 표현과 수정 권한은 숨긴다 +> +> (금지) 이번 미션에서 보드의 내부 상태를 외부에서 직접 수정하지 않는다 +> +> --- +> +> ### **4. null 사용 기준** +> +> (If-Then) 만약 빈 칸도 보드 위의 의미 있는 상태라면 +> +> → 빈 칸은 null 대신 객체 또는 존재 여부가 드러나는 방식으로 표현한다 +> +> (If-Then) 만약 빈 칸 여부를 자주 검사해야 한다면 +> +> → null 검사에 의존하기보다, 빈 상태를 명시적으로 표현하는 구조를 우선한다 +> +> (기준) null 사용 기준: 없음도 의미 있는 상태라면 명시적으로 표현한다 +> +> (금지) 이번 미션에서 빈 칸을 단순 null 검사에만 의존하여 처리하지 않는다 +> +> --- +> +> ### **5. 조건문 대체 기준** +> +> (If-Then) 만약 조건문이 객체의 종류에 따라 동작을 나누기 위해 사용된다면 +> +> → 조건문 대신 다형성을 우선 고려하고, 각 객체가 자신의 규칙을 직접 구현한다 +> +> (If-Then) 만약 조건문이 보드 범위 확인, 장애물 확인, 같은 팀 여부 확인처럼 객체 종류와 무관한 공통 검증이라면 +> +> → 이런 조건문은 공통 로직으로 유지한다 +> +> (기준) 다형성 적용 기준: 같은 질문에 객체마다 다른 답을 해야 하면 다형성 후보로 본다 +> +> (금지) 이번 미션에서 기물 타입을 문자열, enum, switch/if로 반복 분기하여 이동 규칙을 처리하지 않는다 +> +> --- +> +> ### **6. 역할/인터페이스 설계 기준** +> +> (If-Then) 만약 여러 객체가 같은 행위를 해야 하지만 구현 방식은 서로 다르다면 +> +> → 공통 인터페이스를 정의해 무엇을 할 수 있는가를 통일한다. +> +> (기준) 인터페이스 설계 기준: 호출 방식은 통일하고, 구현 방식은 각 객체에 위임한다. +> +> (금지) 이번 미션에서 호출하는 쪽이 기물 종류를 먼저 판별한 뒤 각 규칙을 직접 실행하지 않는다. +> +> --- +> +> ### **7. 새 타입 추가 시 변경 범위 제한 규칙** +> +> (If-Then) 만약 새 기물이 추가될 때 기존 이동 로직을 여러 곳 수정해야 한다면 +> +> → 기물별 책임 분리가 부족한 것으로 보고 구조를 꼭 필히 다시 점검한다. +> +> (기준) 확장성 판단 기준: 새로운 기물 추가나 규칙 변경 시 기존 코드 수정 범위가 작아야 한다. +> +> (금지) 이번 미션에서 새 기물 추가를 위해 기존 분기문을 계속 늘리는 방식으로 확장하지 않는다 diff --git a/src/main/java/.gitkeep b/src/main/java/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/main/java/Application.java b/src/main/java/Application.java new file mode 100644 index 0000000000..3d47303d23 --- /dev/null +++ b/src/main/java/Application.java @@ -0,0 +1,14 @@ +import application.GameManager; +import java.util.Scanner; +import view.InputView; +import view.OutputView; + +public class Application { + public static void main(String[] args) { + GameManager gameManager = new GameManager( + new InputView(new Scanner(System.in)), + new OutputView() + ); + gameManager.play(); + } +} diff --git a/src/main/java/application/GameManager.java b/src/main/java/application/GameManager.java new file mode 100644 index 0000000000..283ef7eb9e --- /dev/null +++ b/src/main/java/application/GameManager.java @@ -0,0 +1,91 @@ +package application; + +import domain.Game; +import domain.Position; +import domain.Side; +import domain.board.Board; +import domain.board.BoardFactory; +import domain.board.Formation; +import domain.player.Name; +import domain.player.Players; +import java.util.List; +import java.util.function.Supplier; +import parser.InputParser; +import view.InputView; +import view.OutputView; + +public class GameManager { + private final InputView inputView; + private final OutputView outputView; + + public GameManager(InputView inputView, OutputView outputView) { + this.inputView = inputView; + this.outputView = outputView; + } + + public void play() { + Game game = initializeGame(); + outputView.printBoard(game.getBoard()); + while (!game.isOver()) { + playTurn(game); + } + outputView.printWinner(game.getWinner()); + } + + private Game initializeGame() { + Name choName = getPlayerName(Side.CHO); + Players players = retry(() -> { + Name hanName = getPlayerName(Side.HAN); + return Players.createInitial(choName, hanName); + }); + Board board = BoardFactory.create(getFormation(Side.CHO), getFormation(Side.HAN)); + return new Game(board, players); + } + + private Name getPlayerName(Side side) { + return retry(() -> InputParser.parseName(inputView.readPlayerName(side))); + } + + private Formation getFormation(Side side) { + return retry(() -> InputParser.parseFormation(inputView.readFormation(side))); + } + + private void playTurn(Game game) { + Position source = selectPiecePosition(game); + retry(() -> { + Position target = InputParser.parsePosition(inputView.readTargetPosition()); + game.move(source, target); + }); + outputView.printBoard(game.getBoard()); + } + + private Position selectPiecePosition(Game game) { + return retry(() -> { + Position position = InputParser.parsePosition(inputView.readSourcePosition(game.getCurrentSide())); + List destinations = game.selectSource(position).getPositions(); + outputView.printDestinations(destinations); + return position; + }); + } + + private T retry(Supplier supplier) { + while (true) { + try { + return supplier.get(); + } catch (IllegalArgumentException e) { + outputView.printError(e.getMessage()); + } + } + } + + private void retry(Runnable action) { + while (true) { + try { + action.run(); + return; + } catch (IllegalArgumentException e) { + outputView.printError(e.getMessage()); + } + } + } +} diff --git a/src/main/java/domain/Destinations.java b/src/main/java/domain/Destinations.java new file mode 100644 index 0000000000..41566baa40 --- /dev/null +++ b/src/main/java/domain/Destinations.java @@ -0,0 +1,28 @@ +package domain; + +import java.util.List; + +public class Destinations { + private final List positions; + + public Destinations(List positions) { + validate(positions); + this.positions = List.copyOf(positions); + } + + private void validate(List positions) { + if (positions.isEmpty()) { + throw new IllegalArgumentException("이동 가능한 목적지가 없습니다."); + } + } + + public List getPositions() { + return positions; + } + + public void validateDestinations(Position target) { + if (!positions.contains(target)) { + throw new IllegalArgumentException("이동할 수 없는 위치입니다."); + } + } +} diff --git a/src/main/java/domain/Game.java b/src/main/java/domain/Game.java new file mode 100644 index 0000000000..aeaf53f038 --- /dev/null +++ b/src/main/java/domain/Game.java @@ -0,0 +1,53 @@ +package domain; + +import domain.board.Board; +import domain.piece.Piece; +import domain.player.Players; +import java.util.Map; + +public class Game { + private Board board; + private final Players players; + + public Game(Board board, Players players) { + this.board = board; + this.players = players; + } + + public Side getCurrentSide() { + return players.getCurrentSide(); + } + + public Destinations selectSource(Position position) { + Piece piece = board.getPiece(position); + players.getActiveTurnPlayer().validateAlly(piece); + return findDestinations(position); + } + + public void move(Position source, Position target) { + Destinations destinations = selectSource(source); + destinations.validateDestinations(target); + movePiece(source, target); + players.switchPlayer(); + } + + private Destinations findDestinations(Position position) { + return board.findDestinations(position); + } + + private void movePiece(Position source, Position target) { + this.board = board.movePiece(source, target); + } + + public Map getBoard() { + return board.getBoard(); + } + + public boolean isOver() { + return board.isGameOver(); + } + + public String getWinner() { + return players.getInActiveTurnPlayer().getName(); + } +} diff --git a/src/main/java/domain/Position.java b/src/main/java/domain/Position.java new file mode 100644 index 0000000000..86a067df55 --- /dev/null +++ b/src/main/java/domain/Position.java @@ -0,0 +1,77 @@ +package domain; + +import domain.strategy.Direction; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +public class Position { + private static final Map CACHE = new HashMap<>(); + + public static final int MIN = 0; + public static final int MAX_X = 8; + public static final int MAX_Y = 9; + + private final int x; + private final int y; + + static { + for (int x = MIN; x <= MAX_X; x++) { + for (int y = MIN; y <= MAX_Y; y++) { + CACHE.put(generateKey(x, y), new Position(x, y)); + } + } + } + + private Position(int x, int y) { + this.x = x; + this.y = y; + } + + public static Position of(int x, int y) { + validateRange(x, y); + return CACHE.get(generateKey(x, y)); + } + + private static void validateRange(int x, int y) { + if (!isWithinRange(x, y)) { + throw new IllegalArgumentException(String.format("범위를 벗어난 좌표입니다: (%d, %d)", x, y)); + } + } + + public boolean canMove(Direction direction) { + return isWithinRange(this.x + direction.getDx(), this.y + direction.getDy()); + } + + public Position move(Direction direction) { + return Position.of(this.x + direction.getDx(), this.y + direction.getDy()); + } + + public static boolean isWithinRange(int x, int y) { + return x >= MIN && x <= MAX_X && y >= MIN && y <= MAX_Y; + } + + private static int generateKey(int x, int y) { + return x * 31 + y; + } + + public int getX() { return x; } + public int getY() { return y; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Position position)) return false; + return x == position.x && y == position.y; + } + + @Override + public int hashCode() { + return Objects.hash(x, y); + } + + @Override + public String toString() { + return String.format("(%d, %d)", x, y); + } +} diff --git a/src/main/java/domain/Side.java b/src/main/java/domain/Side.java new file mode 100644 index 0000000000..60b09cfafd --- /dev/null +++ b/src/main/java/domain/Side.java @@ -0,0 +1,34 @@ +package domain; + +import domain.strategy.Direction; + +public enum Side { + CHO("초") { + @Override + public Direction soldierForward() { + return Direction.N; + } + }, + HAN("한") { + @Override + public Direction soldierForward() { + return Direction.S; + } + }; + + private final String name; + + Side(String name) { + this.name = name; + } + + public abstract Direction soldierForward(); + + public boolean isAlly(Side other) { + return this.equals(other); + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/domain/board/Board.java b/src/main/java/domain/board/Board.java new file mode 100644 index 0000000000..7da466b301 --- /dev/null +++ b/src/main/java/domain/board/Board.java @@ -0,0 +1,56 @@ +package domain.board; + +import domain.Destinations; +import domain.Position; +import domain.piece.Piece; +import java.util.HashMap; +import java.util.Map; + +public class Board implements BoardReader{ + private final Map board; + + public Board(Map board) { + this.board = Map.copyOf(board); + } + + public Destinations findDestinations(Position position) { + validatePieceExists(position); + Piece piece = board.get(position); + return piece.findDestinations(position, this); + } + + public Board movePiece(Position source, Position target) { + validatePieceExists(source); + Map nextBoardMap = new HashMap<>(this.board); + Piece movingPiece = nextBoardMap.remove(source); + nextBoardMap.put(target, movingPiece); + return new Board(nextBoardMap); + } + + private void validatePieceExists(Position position) { + if (isEmpty(position)) { + throw new IllegalArgumentException("기물이 존재하지 않는 위치입니다."); + } + } + + public boolean isGameOver() { + return board.values().stream() + .filter(Piece::isGeneral) + .count() < 2; + } + + @Override + public boolean isEmpty(Position position) { + return !board.containsKey(position); + } + + @Override + public Piece getPiece(Position position) { + validatePieceExists(position); + return board.get(position); + } + + public Map getBoard() { + return board; + } +} diff --git a/src/main/java/domain/board/BoardFactory.java b/src/main/java/domain/board/BoardFactory.java new file mode 100644 index 0000000000..f3dc4dea41 --- /dev/null +++ b/src/main/java/domain/board/BoardFactory.java @@ -0,0 +1,84 @@ +package domain.board; + +import domain.Position; +import domain.Side; +import domain.piece.Piece; +import domain.piece.PieceFactory; +import domain.piece.PieceType; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class BoardFactory { + private static final BoardLayoutMapper BOARD_LAYOUT_MAPPER = new BoardLayoutMapper(); + + private BoardFactory() { + } + + public static Board create(Formation choFormation, Formation hanFormation) { + Map pieces = new HashMap<>(); + placeFixedPieces(pieces, Side.CHO); + placeFormationPieces(pieces, Side.CHO, choFormation); + placeFixedPieces(pieces, Side.HAN); + placeFormationPieces(pieces, Side.HAN, hanFormation); + return new Board(pieces); + } + + private static void placeFixedPieces(Map pieces, Side side) { + placeGeneral(pieces, side); + placeChariots(pieces, side); + placeCannons(pieces, side); + placeGuards(pieces, side); + placeSoldiers(pieces, side); + } + + private static void placeGeneral(Map pieces, Side side) { + BoardLayout boardLayout = BOARD_LAYOUT_MAPPER.get(side); + pieces.put(Position.of(4, boardLayout.generalY()), PieceFactory.createGeneral(side)); + } + + private static void placeChariots(Map pieces, Side side) { + BoardLayout boardLayout = BOARD_LAYOUT_MAPPER.get(side); + pieces.put(Position.of(0, boardLayout.baseY()), PieceFactory.createChariot(side)); + pieces.put(Position.of(8, boardLayout.baseY()), PieceFactory.createChariot(side)); + } + + private static void placeCannons(Map pieces, Side side) { + BoardLayout boardLayout = BOARD_LAYOUT_MAPPER.get(side); + pieces.put(Position.of(1, boardLayout.cannonY()), PieceFactory.createCannon(side)); + pieces.put(Position.of(7, boardLayout.cannonY()), PieceFactory.createCannon(side)); + } + + private static void placeGuards(Map pieces, Side side) { + BoardLayout boardLayout = BOARD_LAYOUT_MAPPER.get(side); + pieces.put(Position.of(3, boardLayout.baseY()), PieceFactory.createGuard(side)); + pieces.put(Position.of(5, boardLayout.baseY()), PieceFactory.createGuard(side)); + } + + private static void placeSoldiers(Map pieces, Side side) { + BoardLayout boardLayout = BOARD_LAYOUT_MAPPER.get(side); + for (int x = 0; x <= 8; x += 2) { + pieces.put(Position.of(x, boardLayout.soldierY()), PieceFactory.createSoldier(side)); + } + } + + private static void placeFormationPieces(Map pieces, Side side, Formation formation) { + BoardLayout boardLayout = BOARD_LAYOUT_MAPPER.get(side); + List formationX = boardLayout.formationX(); + List orders = formation.getOrders(); + for (int i = 0; i < orders.size(); i++) { + Position position = Position.of(formationX.get(i), boardLayout.baseY()); + pieces.put(position, createFormationPiece(orders.get(i), side)); + } + } + + private static Piece createFormationPiece(PieceType piece, Side side) { + if (piece == PieceType.HORSE) { + return PieceFactory.createHorse(side); + } + if (piece == PieceType.ELEPHANT) { + return PieceFactory.createElephant(side); + } + throw new IllegalArgumentException("지원하지 않는 포메이션 기물입니다."); + } +} diff --git a/src/main/java/domain/board/BoardLayout.java b/src/main/java/domain/board/BoardLayout.java new file mode 100644 index 0000000000..3329d42db0 --- /dev/null +++ b/src/main/java/domain/board/BoardLayout.java @@ -0,0 +1,12 @@ +package domain.board; + +import java.util.List; + +public record BoardLayout( + int baseY, + int generalY, + int cannonY, + int soldierY, + List formationX +) { +} diff --git a/src/main/java/domain/board/BoardLayoutMapper.java b/src/main/java/domain/board/BoardLayoutMapper.java new file mode 100644 index 0000000000..ef6c6e5d29 --- /dev/null +++ b/src/main/java/domain/board/BoardLayoutMapper.java @@ -0,0 +1,20 @@ +package domain.board; + +import domain.Side; +import java.util.List; +import java.util.Map; + +public class BoardLayoutMapper { + private static final Map LAYOUTS = Map.of( + Side.CHO, new BoardLayout(0, 1, 2, 3, List.of(1, 2, 6, 7)), + Side.HAN, new BoardLayout(9, 8, 7, 6, List.of(7, 6, 2, 1)) + ); + + public BoardLayout get(Side side) { + BoardLayout boardLayout = LAYOUTS.get(side); + if (boardLayout == null) { + throw new IllegalArgumentException("지원하지 않는 진영입니다."); + } + return boardLayout; + } +} diff --git a/src/main/java/domain/board/BoardReader.java b/src/main/java/domain/board/BoardReader.java new file mode 100644 index 0000000000..9cd1b992c0 --- /dev/null +++ b/src/main/java/domain/board/BoardReader.java @@ -0,0 +1,9 @@ +package domain.board; + +import domain.Position; +import domain.piece.Piece; + +public interface BoardReader { + boolean isEmpty(Position position); + Piece getPiece(Position position); +} diff --git a/src/main/java/domain/board/Formation.java b/src/main/java/domain/board/Formation.java new file mode 100644 index 0000000000..c6f98f371e --- /dev/null +++ b/src/main/java/domain/board/Formation.java @@ -0,0 +1,30 @@ +package domain.board; + +import domain.piece.PieceType; +import java.util.List; + +public enum Formation { + LEFT_ELEPHANT( + List.of(PieceType.ELEPHANT, PieceType.HORSE, PieceType.ELEPHANT, PieceType.HORSE) + ), + RIGHT_ELEPHANT( + List.of(PieceType.HORSE, PieceType.ELEPHANT, PieceType.HORSE, PieceType.ELEPHANT) + ), + OUTER_ELEPHANT( + List.of(PieceType.ELEPHANT, PieceType.HORSE, PieceType.HORSE, PieceType.ELEPHANT) + ), + INNER_ELEPHANT( + List.of(PieceType.HORSE, PieceType.ELEPHANT, PieceType.ELEPHANT, PieceType.HORSE) + ), + ; + + private final List orders; + + Formation(List orders) { + this.orders = List.copyOf(orders); + } + + public List getOrders() { + return orders; + } +} diff --git a/src/main/java/domain/piece/Cannon.java b/src/main/java/domain/piece/Cannon.java new file mode 100644 index 0000000000..3413e2c759 --- /dev/null +++ b/src/main/java/domain/piece/Cannon.java @@ -0,0 +1,96 @@ +package domain.piece; + +import domain.Position; +import domain.Side; +import domain.board.BoardReader; +import domain.strategy.MovementStrategy; +import domain.strategy.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class Cannon extends Piece { + private final PieceType pieceType = PieceType.CANNON; + + public Cannon(Side side, MovementStrategy movementStrategy) { + super(side, movementStrategy); + } + + @Override + protected List filterValidPositions(Position current, List paths, BoardReader board) { + return paths.stream() + .flatMap(path -> collectJumpPathPositions(path, board).stream()) + .toList(); + } + + private List collectJumpPathPositions(Path path, BoardReader board) { + List positions = path.getPositions(); + int bridgeIndex = findBridgeIndex(positions, board); + + if (isInvalidBridge(bridgeIndex, positions, board)) { + return List.of(); + } + + return collectValidDestinations(positions, bridgeIndex + 1, board); + } + + private int findBridgeIndex(List positions, BoardReader board) { + int index = 0; + while (index < positions.size() && board.isEmpty(positions.get(index))) { + index++; + } + return findValidIndex(index, positions.size()); + } + + private int findValidIndex(int index, int size) { + if (index == size) { + return -1; + } + return index; + } + + private boolean isInvalidBridge(int index, List positions, BoardReader board) { + if (index == -1) { + return true; + } + Piece bridge = board.getPiece(positions.get(index)); + return bridge.isCannon(); + } + + private List collectValidDestinations(List positions, int startIndex, BoardReader board) { + int obstacleIndex = findObstacleIndex(positions, startIndex, board); + List valid = new ArrayList<>(positions.subList(startIndex, obstacleIndex)); + findCatchablePosition(positions, obstacleIndex, board).ifPresent(valid::add); + return valid; + } + + private int findObstacleIndex(List positions, int startIndex, BoardReader board) { + int index = startIndex; + while (index < positions.size() && board.isEmpty(positions.get(index))) { + index++; + } + return index; + } + + private Optional findCatchablePosition(List positions, int index, BoardReader board) { + if (index >= positions.size()) { + return Optional.empty(); + } + Position position = positions.get(index); + Piece target = board.getPiece(position); + if (!target.isCannon() && !target.isAlly(getSide())) { + return Optional.of(position); + } + return Optional.empty(); + } + + @Override + public boolean isCannon() { + return true; + } + + @Override + public String getName() { + return pieceType.getName(); + } +} diff --git a/src/main/java/domain/piece/Chariot.java b/src/main/java/domain/piece/Chariot.java new file mode 100644 index 0000000000..111a74cfb5 --- /dev/null +++ b/src/main/java/domain/piece/Chariot.java @@ -0,0 +1,58 @@ +package domain.piece; + +import domain.Position; +import domain.Side; +import domain.board.BoardReader; +import domain.strategy.MovementStrategy; +import domain.strategy.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class Chariot extends Piece { + private final PieceType pieceType = PieceType.CHARIOT; + + public Chariot(Side side, MovementStrategy movementStrategy) { + super(side, movementStrategy); + } + + @Override + protected List filterValidPositions(Position current, List paths, BoardReader board) { + return paths.stream() + .flatMap(path -> collectPathPositions(path, board).stream()) + .toList(); + } + + private List collectPathPositions(Path path, BoardReader board) { + List positions = path.getPositions(); + int obstacleIndex = findObstacleIndex(positions, board); + List valid = new ArrayList<>(positions.subList(0, obstacleIndex)); + findCapturePosition(positions, obstacleIndex, board).ifPresent(valid::add); + return valid; + } + + private int findObstacleIndex(List positions, BoardReader board) { + int index = 0; + while (index < positions.size() && board.isEmpty(positions.get(index))) { + index++; + } + return index; + } + + private Optional findCapturePosition(List positions, int index, BoardReader board) { + if (index >= positions.size()) { + return Optional.empty(); + } + Position position = positions.get(index); + Piece target = board.getPiece(position); + if (!target.isAlly(getSide())) { + return Optional.of(position); + } + return Optional.empty(); + } + + @Override + public String getName() { + return pieceType.getName(); + } +} diff --git a/src/main/java/domain/piece/Elephant.java b/src/main/java/domain/piece/Elephant.java new file mode 100644 index 0000000000..6375d40f43 --- /dev/null +++ b/src/main/java/domain/piece/Elephant.java @@ -0,0 +1,26 @@ +package domain.piece; + +import domain.Position; +import domain.Side; +import domain.board.BoardReader; +import domain.strategy.MovementStrategy; +import domain.strategy.Path; +import java.util.List; + +public class Elephant extends Piece { + private final PieceType pieceType = PieceType.ELEPHANT; + + public Elephant(Side side, MovementStrategy movementStrategy) { + super(side, movementStrategy); + } + + @Override + protected List filterValidPositions(Position current, List paths, BoardReader board) { + return filterStandardPaths(paths, board); + } + + @Override + public String getName() { + return pieceType.getName(); + } +} diff --git a/src/main/java/domain/piece/General.java b/src/main/java/domain/piece/General.java new file mode 100644 index 0000000000..014a69f09e --- /dev/null +++ b/src/main/java/domain/piece/General.java @@ -0,0 +1,31 @@ +package domain.piece; + +import domain.Position; +import domain.Side; +import domain.board.BoardReader; +import domain.strategy.MovementStrategy; +import domain.strategy.Path; +import java.util.List; + +public class General extends Piece { + private final PieceType pieceType = PieceType.GENERAL; + + public General(Side side, MovementStrategy movementStrategy) { + super(side, movementStrategy); + } + + @Override + protected List filterValidPositions(Position current, List paths, BoardReader board) { + return filterStandardPaths(paths, board); + } + + @Override + public boolean isGeneral() { + return true; + } + + @Override + public String getName() { + return pieceType.getName(); + } +} diff --git a/src/main/java/domain/piece/Guard.java b/src/main/java/domain/piece/Guard.java new file mode 100644 index 0000000000..f8d6c13b92 --- /dev/null +++ b/src/main/java/domain/piece/Guard.java @@ -0,0 +1,26 @@ +package domain.piece; + +import domain.Position; +import domain.Side; +import domain.board.BoardReader; +import domain.strategy.MovementStrategy; +import domain.strategy.Path; +import java.util.List; + +public class Guard extends Piece { + private final PieceType pieceType = PieceType.GUARD; + + public Guard(Side side, MovementStrategy movementStrategy) { + super(side, movementStrategy); + } + + @Override + protected List filterValidPositions(Position current, List paths, BoardReader board) { + return filterStandardPaths(paths, board); + } + + @Override + public String getName() { + return pieceType.getName(); + } +} diff --git a/src/main/java/domain/piece/Horse.java b/src/main/java/domain/piece/Horse.java new file mode 100644 index 0000000000..9df4dcb332 --- /dev/null +++ b/src/main/java/domain/piece/Horse.java @@ -0,0 +1,26 @@ +package domain.piece; + +import domain.Position; +import domain.Side; +import domain.board.BoardReader; +import domain.strategy.MovementStrategy; +import domain.strategy.Path; +import java.util.List; + +public class Horse extends Piece { + private final PieceType pieceType = PieceType.HORSE; + + public Horse(Side side, MovementStrategy movementStrategy) { + super(side, movementStrategy); + } + + @Override + protected List filterValidPositions(Position current, List paths, BoardReader board) { + return filterStandardPaths(paths, board); + } + + @Override + public String getName() { + return pieceType.getName(); + } +} diff --git a/src/main/java/domain/piece/Piece.java b/src/main/java/domain/piece/Piece.java new file mode 100644 index 0000000000..a346426952 --- /dev/null +++ b/src/main/java/domain/piece/Piece.java @@ -0,0 +1,65 @@ +package domain.piece; + +import domain.Destinations; +import domain.Position; +import domain.Side; +import domain.board.BoardReader; +import domain.strategy.MovementStrategy; +import domain.strategy.Path; +import java.util.List; + +public abstract class Piece { + private final Side side; + private final MovementStrategy movementStrategy; + + protected Piece(Side side, MovementStrategy movementStrategy) { + this.side = side; + this.movementStrategy = movementStrategy; + } + + public Destinations findDestinations(Position current, BoardReader board) { + List paths = movementStrategy.generatePaths(current); + List validDestinations = filterValidPositions(current, paths, board); + return new Destinations(validDestinations); + } + + protected abstract List filterValidPositions(Position current, List paths, BoardReader board); + + protected List filterStandardPaths(List paths, BoardReader board) { + return paths.stream() + .filter(path -> isMovablePath(path, board)) + .map(Path::getDestination) + .toList(); + } + + private boolean isMovablePath(Path path, BoardReader board) { + return isObstaclesClear(path, board) && isValidDestination(path.getDestination(), board); + } + + private boolean isObstaclesClear(Path path, BoardReader board) { + return path.getObstacles().stream() + .allMatch(board::isEmpty); + } + + protected boolean isValidDestination(Position destination, BoardReader board) { + return board.isEmpty(destination) || !board.getPiece(destination).isAlly(side); + } + + public boolean isAlly(Side other) { + return this.side.isAlly(other); + } + + public Side getSide() { + return side; + } + + public boolean isGeneral() { + return false; + } + + public boolean isCannon() { + return false; + } + + public abstract String getName(); +} diff --git a/src/main/java/domain/piece/PieceFactory.java b/src/main/java/domain/piece/PieceFactory.java new file mode 100644 index 0000000000..09aabe8093 --- /dev/null +++ b/src/main/java/domain/piece/PieceFactory.java @@ -0,0 +1,59 @@ +package domain.piece; + +import domain.Side; +import domain.strategy.ContinuousStrategy; +import domain.strategy.Direction; +import domain.strategy.MovementStrategy; +import domain.strategy.OneStepStrategy; +import domain.strategy.SequenceStrategy; +import java.util.Map; + +public class PieceFactory { + private static final MovementStrategy LINEAR_ONE_STEP = new OneStepStrategy(Direction.linear()); + private static final MovementStrategy HORSE_STRATEGY = new SequenceStrategy(Direction.horseSequences()); + private static final MovementStrategy ELEPHANT_STRATEGY = new SequenceStrategy(Direction.elephantSequences()); + private static final MovementStrategy CONTINUOUS_STRATEGY = new ContinuousStrategy(Direction.linear()); + + private static final Map GENERALS = Map.of( + Side.CHO, new General(Side.CHO, LINEAR_ONE_STEP), + Side.HAN, new General(Side.HAN, LINEAR_ONE_STEP) + ); + private static final Map GUARDS = Map.of( + Side.CHO, new Guard(Side.CHO, LINEAR_ONE_STEP), + Side.HAN, new Guard(Side.HAN, LINEAR_ONE_STEP) + ); + private static final Map SOLDIERS = Map.of( + Side.CHO, new Soldier(Side.CHO, soldierStrategy(Side.CHO)), + Side.HAN, new Soldier(Side.HAN, soldierStrategy(Side.HAN)) + ); + private static final Map HORSES = Map.of( + Side.CHO, new Horse(Side.CHO, HORSE_STRATEGY), + Side.HAN, new Horse(Side.HAN, HORSE_STRATEGY) + ); + private static final Map ELEPHANTS = Map.of( + Side.CHO, new Elephant(Side.CHO, ELEPHANT_STRATEGY), + Side.HAN, new Elephant(Side.HAN, ELEPHANT_STRATEGY) + ); + private static final Map CHARIOTS = Map.of( + Side.CHO, new Chariot(Side.CHO, CONTINUOUS_STRATEGY), + Side.HAN, new Chariot(Side.HAN, CONTINUOUS_STRATEGY) + ); + private static final Map CANNONS = Map.of( + Side.CHO, new Cannon(Side.CHO, CONTINUOUS_STRATEGY), + Side.HAN, new Cannon(Side.HAN, CONTINUOUS_STRATEGY) + ); + + private PieceFactory() {} + + public static General createGeneral(Side side) { return GENERALS.get(side); } + public static Guard createGuard(Side side) { return GUARDS.get(side); } + public static Horse createHorse(Side side) { return HORSES.get(side); } + public static Elephant createElephant(Side side) { return ELEPHANTS.get(side); } + public static Chariot createChariot(Side side) { return CHARIOTS.get(side); } + public static Cannon createCannon(Side side) { return CANNONS.get(side); } + public static Soldier createSoldier(Side side) { return SOLDIERS.get(side); } + + private static MovementStrategy soldierStrategy(Side side) { + return new OneStepStrategy(Direction.soldier(side)); + } +} diff --git a/src/main/java/domain/piece/PieceType.java b/src/main/java/domain/piece/PieceType.java new file mode 100644 index 0000000000..3c08f23884 --- /dev/null +++ b/src/main/java/domain/piece/PieceType.java @@ -0,0 +1,22 @@ +package domain.piece; + +public enum PieceType { + GENERAL("궁"), + CHARIOT("차"), + CANNON("포"), + HORSE("마"), + ELEPHANT("상"), + GUARD("사"), + SOLDIER("졸"), + ; + + private final String name; + + PieceType(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/domain/piece/Soldier.java b/src/main/java/domain/piece/Soldier.java new file mode 100644 index 0000000000..2fd6d32402 --- /dev/null +++ b/src/main/java/domain/piece/Soldier.java @@ -0,0 +1,26 @@ +package domain.piece; + +import domain.Position; +import domain.Side; +import domain.board.BoardReader; +import domain.strategy.MovementStrategy; +import domain.strategy.Path; +import java.util.List; + +public class Soldier extends Piece { + private final PieceType pieceType = PieceType.SOLDIER; + + public Soldier(Side side, MovementStrategy movementStrategy) { + super(side, movementStrategy); + } + + @Override + protected List filterValidPositions(Position current, List paths, BoardReader board) { + return filterStandardPaths(paths, board); + } + + @Override + public String getName() { + return pieceType.getName(); + } +} diff --git a/src/main/java/domain/player/Name.java b/src/main/java/domain/player/Name.java new file mode 100644 index 0000000000..fb043114a1 --- /dev/null +++ b/src/main/java/domain/player/Name.java @@ -0,0 +1,18 @@ +package domain.player; + +public record Name(String name) { + + public Name { + validate(name); + } + + public void validate(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("이름은 빈 값이 될 수 없습니다."); + } + + if (name.length() < 2 || name.length() > 5) { + throw new IllegalArgumentException("이름은 2~5글자 사이여야 합니다."); + } + } +} diff --git a/src/main/java/domain/player/Player.java b/src/main/java/domain/player/Player.java new file mode 100644 index 0000000000..07b7e43176 --- /dev/null +++ b/src/main/java/domain/player/Player.java @@ -0,0 +1,39 @@ +package domain.player; + +import domain.Side; +import domain.piece.Piece; +import domain.state.TurnState; + +public class Player { + private final Name name; + private final Side side; + private TurnState turnState; + + public Player(Name name, Side side, TurnState turnState) { + this.name = name; + this.side = side; + this.turnState = turnState; + } + + public void validateAlly(Piece piece) { + if (!piece.getSide().equals(side)) { + throw new IllegalArgumentException("상대방의 기물은 움직일 수 없습니다."); + } + } + + public boolean isCurrentTurn() { + return turnState.isCurrent(); + } + + public void toggleTurn() { + turnState = turnState.next(); + } + + public Side getSide() { + return side; + } + + public String getName() { + return name.name(); + } +} diff --git a/src/main/java/domain/player/Players.java b/src/main/java/domain/player/Players.java new file mode 100644 index 0000000000..a08a51fb25 --- /dev/null +++ b/src/main/java/domain/player/Players.java @@ -0,0 +1,54 @@ +package domain.player; + +import domain.Side; +import domain.state.ActiveTurn; +import domain.state.InactiveTurn; +import java.util.List; + +public class Players { + private final List players; + + private Players(Player cho, Player han) { + this.players = java.util.List.of(cho, han); + } + + public static Players createInitial(Name choName, Name hanName) { + validateDuplicateName(choName, hanName); + return new Players( + new Player(choName, Side.CHO, new ActiveTurn()), + new Player(hanName, Side.HAN, new InactiveTurn()) + ); + } + + private static void validateDuplicateName(Name choName, Name hanName) { + if (choName.equals(hanName)) { + throw new IllegalArgumentException("동일한 플레이어 이름을 사용할 수 없습니다."); + } + } + + public Player getActiveTurnPlayer() { + return players.stream() + .filter(Player::isCurrentTurn) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("현재 턴인 플레이어가 없습니다.")); + } + + public Player getInActiveTurnPlayer() { + return players.stream() + .filter(player -> !player.isCurrentTurn()) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("현재 턴인 플레이어가 없습니다.")); + } + + public Side getCurrentSide() { + return players.stream() + .filter(Player::isCurrentTurn) + .map(Player::getSide) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("현재 턴인 플레이어의 진영이 존재하지 않습니다.")); + } + + public void switchPlayer() { + players.forEach(Player::toggleTurn); + } +} diff --git a/src/main/java/domain/state/ActiveTurn.java b/src/main/java/domain/state/ActiveTurn.java new file mode 100644 index 0000000000..cc7b256287 --- /dev/null +++ b/src/main/java/domain/state/ActiveTurn.java @@ -0,0 +1,14 @@ +package domain.state; + +public class ActiveTurn implements TurnState { + + @Override + public boolean isCurrent() { + return true; + } + + @Override + public TurnState next() { + return new InactiveTurn(); + } +} diff --git a/src/main/java/domain/state/InactiveTurn.java b/src/main/java/domain/state/InactiveTurn.java new file mode 100644 index 0000000000..b836d5515a --- /dev/null +++ b/src/main/java/domain/state/InactiveTurn.java @@ -0,0 +1,14 @@ +package domain.state; + +public class InactiveTurn implements TurnState { + + @Override + public boolean isCurrent() { + return false; + } + + @Override + public TurnState next() { + return new ActiveTurn(); + } +} diff --git a/src/main/java/domain/state/TurnState.java b/src/main/java/domain/state/TurnState.java new file mode 100644 index 0000000000..36d5fdc1f3 --- /dev/null +++ b/src/main/java/domain/state/TurnState.java @@ -0,0 +1,6 @@ +package domain.state; + +public interface TurnState { + boolean isCurrent(); + TurnState next(); +} diff --git a/src/main/java/domain/strategy/ContinuousStrategy.java b/src/main/java/domain/strategy/ContinuousStrategy.java new file mode 100644 index 0000000000..79172c3667 --- /dev/null +++ b/src/main/java/domain/strategy/ContinuousStrategy.java @@ -0,0 +1,36 @@ +package domain.strategy; + +import domain.Position; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class ContinuousStrategy implements MovementStrategy { + private final List directions; + + public ContinuousStrategy(List directions) { + this.directions = directions; + } + + @Override + public List generatePaths(Position current) { + return directions.stream() + .map(direction -> createPath(current, direction)) + .flatMap(Optional::stream) + .toList(); + } + + private Optional createPath(Position current, Direction direction) { + List positions = new ArrayList<>(); + Position position = current; + + while (position.canMove(direction)) { + position = position.move(direction); + positions.add(position); + } + if (positions.isEmpty()) { + return Optional.empty(); + } + return Optional.of(new Path(positions)); + } +} diff --git a/src/main/java/domain/strategy/Direction.java b/src/main/java/domain/strategy/Direction.java new file mode 100644 index 0000000000..aed0fc10e7 --- /dev/null +++ b/src/main/java/domain/strategy/Direction.java @@ -0,0 +1,57 @@ +package domain.strategy; + +import domain.Side; +import java.util.List; + +public enum Direction { + N(0, 1), + S(0, -1), + E(1, 0), + W(-1, 0), + NE(1, 1), + NW(-1, 1), + SE(1, -1), + SW(-1, -1); + + private final int dx; + private final int dy; + + Direction(int dx, int dy) { + this.dx = dx; + this.dy = dy; + } + + public static List linear() { + return List.of(N, S, E, W); + } + + public static List> horseSequences() { + return List.of( + List.of(N, NW), List.of(N, NE), + List.of(S, SW), List.of(S, SE), + List.of(E, NE), List.of(E, SE), + List.of(W, NW), List.of(W, SW) + ); + } + + public static List> elephantSequences() { + return List.of( + List.of(N, NW, NW), List.of(N, NE, NE), + List.of(S, SW, SW), List.of(S, SE, SE), + List.of(E, NE, NE), List.of(E, SE, SE), + List.of(W, NW, NW), List.of(W, SW, SW) + ); + } + + public static List soldier(Side side) { + return List.of(E, W, side.soldierForward()); + } + + public int getDx() { + return dx; + } + + public int getDy() { + return dy; + } +} diff --git a/src/main/java/domain/strategy/MovementStrategy.java b/src/main/java/domain/strategy/MovementStrategy.java new file mode 100644 index 0000000000..10f7867a99 --- /dev/null +++ b/src/main/java/domain/strategy/MovementStrategy.java @@ -0,0 +1,8 @@ +package domain.strategy; + +import domain.Position; +import java.util.List; + +public interface MovementStrategy { + List generatePaths(Position current); +} diff --git a/src/main/java/domain/strategy/OneStepStrategy.java b/src/main/java/domain/strategy/OneStepStrategy.java new file mode 100644 index 0000000000..4b397613d2 --- /dev/null +++ b/src/main/java/domain/strategy/OneStepStrategy.java @@ -0,0 +1,21 @@ +package domain.strategy; + +import domain.Position; +import java.util.List; +import java.util.stream.Collectors; + +public class OneStepStrategy implements MovementStrategy { + private final List directions; + + public OneStepStrategy(List directions) { + this.directions = directions; + } + + @Override + public List generatePaths(Position current) { + return directions.stream() + .filter(current::canMove) + .map(direction -> new Path(List.of(current.move(direction)))) + .toList(); + } +} diff --git a/src/main/java/domain/strategy/Path.java b/src/main/java/domain/strategy/Path.java new file mode 100644 index 0000000000..59f3ed6769 --- /dev/null +++ b/src/main/java/domain/strategy/Path.java @@ -0,0 +1,27 @@ +package domain.strategy; + +import domain.Position; +import java.util.List; + +public class Path { + private final List positions; + + public Path(List positions) { + if (positions == null || positions.isEmpty()) { + throw new IllegalArgumentException("불가능한 경로입니다."); + } + this.positions = List.copyOf(positions); + } + + public Position getDestination() { + return positions.getLast(); + } + + public List getObstacles() { + return positions.subList(0, positions.size() - 1); + } + + public List getPositions() { + return positions; + } +} diff --git a/src/main/java/domain/strategy/SequenceStrategy.java b/src/main/java/domain/strategy/SequenceStrategy.java new file mode 100644 index 0000000000..7da194a4db --- /dev/null +++ b/src/main/java/domain/strategy/SequenceStrategy.java @@ -0,0 +1,41 @@ +package domain.strategy; + +import domain.Position; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class SequenceStrategy implements MovementStrategy { + private final List> sequences; + + public SequenceStrategy(List> sequences) { + this.sequences = sequences; + } + + @Override + public List generatePaths(Position current) { + return sequences.stream() + .map(sequence -> createPath(current, sequence)) + .flatMap(Optional::stream) + .toList(); + } + + private Optional createPath(Position current, List sequence) { + List positions = new ArrayList<>(); + Position position = current; + int index = 0; + while (index < sequence.size() && position.canMove(sequence.get(index))) { + position = position.move(sequence.get(index)); + positions.add(position); + index++; + } + if (isInvalidPath(sequence, index)) { + return Optional.empty(); + } + return Optional.of(new Path(positions)); + } + + private boolean isInvalidPath(List sequence, int index) { + return index < sequence.size(); + } +} diff --git a/src/main/java/parser/FormationCommand.java b/src/main/java/parser/FormationCommand.java new file mode 100644 index 0000000000..cba6d7a1ee --- /dev/null +++ b/src/main/java/parser/FormationCommand.java @@ -0,0 +1,31 @@ +package parser; + +import domain.board.Formation; +import java.util.Arrays; + +public enum FormationCommand { + FIRST("1", Formation.LEFT_ELEPHANT), + SECOND("2", Formation.RIGHT_ELEPHANT), + THIRD("3", Formation.OUTER_ELEPHANT), + FOURTH("4", Formation.INNER_ELEPHANT), + ; + + private final String input; + private final Formation formation; + + FormationCommand(String input, Formation formation) { + this.input = input; + this.formation = formation; + } + + public static FormationCommand from(String input) { + return Arrays.stream(values()) + .filter(command -> command.input.equals(input.strip())) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("올바른 배치가 아닙니다.")); + } + + public Formation toFormation() { + return formation; + } +} diff --git a/src/main/java/parser/InputParser.java b/src/main/java/parser/InputParser.java new file mode 100644 index 0000000000..6f94922c05 --- /dev/null +++ b/src/main/java/parser/InputParser.java @@ -0,0 +1,51 @@ +package parser; + + +import domain.Position; +import domain.board.Formation; +import domain.player.Name; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Pattern; + +public class InputParser { + private static final Pattern COORDINATE_CSV_PATTERN = Pattern.compile(" *\\d+ *, *\\d+ *"); + + public static Name parseName(String input) { + validateNullOrBlank(input); + return new Name(input.strip()); + } + + public static Position parsePosition(String input) { + validateNullOrBlank(input); + validateInputFormat(input); + + return getPosition(input); + } + + public static Formation parseFormation(String input) { + validateNullOrBlank(input); + return FormationCommand.from(input).toFormation(); + } + + private static Position getPosition(String input) { + List coordinate = Arrays.stream(input.strip().split(",")) + .map(String::strip) + .map(Integer::parseInt) + .toList(); + + return Position.of(coordinate.get(0), coordinate.get(1)); + } + + private static void validateNullOrBlank(String input) { + if (input == null || input.isBlank()) { + throw new IllegalArgumentException("빈 값은 입력할 수 없습니다."); + } + } + + private static void validateInputFormat(String input) { + if (!COORDINATE_CSV_PATTERN.matcher(input).matches()) { + throw new IllegalArgumentException("질못된 입력 형식입니다."); + } + } +} diff --git a/src/main/java/view/InputView.java b/src/main/java/view/InputView.java new file mode 100644 index 0000000000..07a51bdb7f --- /dev/null +++ b/src/main/java/view/InputView.java @@ -0,0 +1,39 @@ +package view; + +import domain.Side; +import java.util.Scanner; + +public class InputView { + private final Scanner scanner; + + public InputView(Scanner scanner) { + this.scanner = scanner; + } + + public String readPlayerName(Side side) { + System.out.printf("%s나라 플레이어 이름 입력: ", side.getName()); + return scanner.nextLine(); + } + + public String readFormation(Side side) { + String message = String.format(""" + %s나라 플레이어 포메이션 입력 + 1. 상마상마 + 2. 마상마상 + 3. 상마마상 + 4. 마상상마""", side.getName()); + + System.out.println(message); + return scanner.nextLine(); + } + + public String readSourcePosition(Side side) { + System.out.println(side.getName() + "나라 플레이어 차례입니다. 이동 시킬 기물의 위치를 입력하세요. 예) 1,3"); + return scanner.nextLine(); + } + + public String readTargetPosition() { + System.out.println("선택한 기물이 이동할 수 있는 위치입니다. 이동할 위치를 입력하세요. 예) 1,3"); + return scanner.nextLine(); + } +} diff --git a/src/main/java/view/OutputView.java b/src/main/java/view/OutputView.java new file mode 100644 index 0000000000..9ca0973c11 --- /dev/null +++ b/src/main/java/view/OutputView.java @@ -0,0 +1,67 @@ +package view; + +import domain.Position; +import domain.Side; +import domain.piece.Piece; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +public class OutputView { + + private static final String RED = "\u001B[31m"; + private static final String BLUE = "\u001B[34m"; + private static final String RESET = "\u001B[0m"; + private static final String EMPTY = "\uFF0B"; + private static final String SPACE = "\u3000"; + private static final List NUMBERS = List.of( + "\uFF10", "\uFF11", "\uFF12", "\uFF13", "\uFF14", + "\uFF15", "\uFF16", "\uFF17", "\uFF18", "\uFF19" + ); + + public void printBoard(Map board) { + System.out.println(); + for (int y = 9; y >= 0; y--) { + System.out.println(buildRow(board, y)); + } + System.out.println(buildHeader()); + } + + private String buildHeader() { + return SPACE.repeat(3) + String.join(SPACE, IntStream.range(0, 9) + .mapToObj(NUMBERS::get) + .toList()) + SPACE; + } + + private String buildRow(Map board, int y) { + return SPACE + NUMBERS.get(y) + SPACE + IntStream.rangeClosed(0, 8) + .mapToObj(x -> formatCell(board.get(Position.of(x, y)))) + .reduce("", String::concat); + } + + private String formatCell(Piece piece) { + if (piece == null) { + return EMPTY + SPACE; + } + return getColor(piece.getSide()) + piece.getName() + SPACE + RESET; + } + + private String getColor(Side side) { + if (side == Side.HAN) { + return RED; + } + return BLUE; + } + + public void printError(String message) { + System.out.println(message); + } + + public void printDestinations(List destinations) { + System.out.println(String.join(", ", destinations.stream().map(Position::toString).toList())); + } + + public void printWinner(String winner) { + System.out.printf("%s(이/가) 승리했습니다.%n", winner); + } +} diff --git a/src/test/java/.gitkeep b/src/test/java/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/test/java/domain/GameTest.java b/src/test/java/domain/GameTest.java new file mode 100644 index 0000000000..5428eaf944 --- /dev/null +++ b/src/test/java/domain/GameTest.java @@ -0,0 +1,80 @@ +package domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import domain.board.Board; +import domain.board.BoardFactory; +import domain.board.Formation; +import domain.piece.Piece; +import domain.piece.PieceFactory; +import domain.player.Name; +import domain.player.Players; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class GameTest { + private Players players; + private Game game; + + @BeforeEach + void setUp() { + players = Players.createInitial(new Name("cho"), new Name("han")); + Board board = BoardFactory.create(Formation.LEFT_ELEPHANT, Formation.LEFT_ELEPHANT); + game = new Game(board, players); + } + + @Test + void 기물_이동이_완료되면_턴이_상대방_진영으로_변경된다() { + // Given: 초나라 졸(0,3)을 (0,4)로 전진 + Position source = Position.of(0, 3); + Position target = Position.of(0, 4); + + // When + game.move(source, target); + + // Then + assertThat(game.getCurrentSide()).isEqualTo(Side.HAN); + } + + @Test + void 이동할_수_있는_목적지가_전혀_없는_기물을_선택하면_예외가_발생한다() { + // Given: 사(Guard)를 기물들로 사방을 포위하여 이동 경로가 0개인 상황 연출 + Position guardPos = Position.of(4, 0); + Map blockedMap = Map.of( + guardPos, PieceFactory.createGuard(Side.CHO), + Position.of(3, 0), PieceFactory.createChariot(Side.CHO), + Position.of(5, 0), PieceFactory.createChariot(Side.CHO), + Position.of(4, 1), PieceFactory.createChariot(Side.CHO) + ); + Game blockedGame = new Game(new Board(blockedMap), players); + + // When & Then: selectSource 내부에서 movablePositions.isEmpty() 체크 시 예외 발생 + assertThatThrownBy(() -> blockedGame.selectSource(guardPos)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 선택한_기물이_이동할_수_없는_위치를_목적지로_입력하면_예외가_발생한다() { + // Given: 초나라 졸(0,3) 선택 (졸은 대각선 이동 불가) + Position source = Position.of(0, 3); + Position invalidTo = Position.of(1, 4); + + // When & Then: validateDestinations(to) 호출 시 예외 발생 + assertThatThrownBy(() -> game.move(source, invalidTo)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 보드에_궁이_하나만_남게_되면_게임은_종료_상태가_된다() { + // Given: 한나라 궁(General)이 잡히고 초나라 궁만 남은 보드 상황 + Map oneGeneralMap = Map.of( + Position.of(4, 1), PieceFactory.createGeneral(Side.CHO) + ); + Game gameOverGame = new Game(new Board(oneGeneralMap), players); + + // When & Then + assertThat(gameOverGame.isOver()).isTrue(); + } +} diff --git a/src/test/java/domain/PositionTest.java b/src/test/java/domain/PositionTest.java new file mode 100644 index 0000000000..c4178aa13f --- /dev/null +++ b/src/test/java/domain/PositionTest.java @@ -0,0 +1,45 @@ +package domain; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class PositionTest { + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2, 3, 4, 5, 6, 7, 8}) + void x의_값은_0_이상_8_이하여야_한다(int validX) { + int y = 0; + assertThatCode(() -> Position.of(validX, y)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}) + void y의_값은_0_이상_9_이하여야_한다(int validY) { + int x = 0; + assertThatCode(() -> Position.of(x, validY)) + .doesNotThrowAnyException(); + } + + + @ParameterizedTest + @ValueSource(ints = {-1, -2, 9, 10, 100}) + void x_좌표가_0_미만이거나_8_초과이면_예외가_발생한다(int invalidX) { + int y = 0; + assertThatThrownBy(() -> Position.of(invalidX, y)) + .isInstanceOf(IllegalArgumentException.class); + + } + + @ParameterizedTest + @ValueSource(ints = {-1, -2, 10, 11, 100}) + void y_좌표가_0_미만이거나_9_초과이면_예외가_발생한다(int invalidY) { + int x = 0; + assertThatThrownBy(() -> Position.of(x, invalidY)) + .isInstanceOf(IllegalArgumentException.class); + + } +} diff --git a/src/test/java/domain/SideTest.java b/src/test/java/domain/SideTest.java new file mode 100644 index 0000000000..b5aead2322 --- /dev/null +++ b/src/test/java/domain/SideTest.java @@ -0,0 +1,16 @@ +package domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +class SideTest { + + @Test + void 진영은_초와_한만_가진다() { + List sides = Arrays.stream(Side.values()).toList(); + assertThat(sides).containsExactlyInAnyOrder(Side.CHO, Side.HAN); + } +} diff --git a/src/test/java/domain/board/BoardTest.java b/src/test/java/domain/board/BoardTest.java new file mode 100644 index 0000000000..3541d13772 --- /dev/null +++ b/src/test/java/domain/board/BoardTest.java @@ -0,0 +1,127 @@ +package domain.board; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import domain.Destinations; +import domain.Position; +import domain.Side; +import domain.piece.Chariot; +import domain.piece.Elephant; +import domain.piece.General; +import domain.piece.Horse; +import domain.piece.Piece; +import domain.piece.PieceFactory; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class BoardTest { + + @Test + void 선택한_포메이션에_맞게_32개의_기물이_초기_위치에_정확히_배치된다() { + // LEFT(상마상마), RIGHT(마상마상) 포메이션으로 초기화 + Board board = BoardFactory.create(Formation.LEFT_ELEPHANT, Formation.RIGHT_ELEPHANT); + Map actual = board.getBoard(); + + assertThat(actual).hasSize(32); + + // 궁성 중앙 (4,1), (4,8)에 궁(General) 배치 확인 + assertThat(actual.get(Position.of(4, 1))).isInstanceOf(General.class); + assertThat(actual.get(Position.of(4, 8))).isInstanceOf(General.class); + + // 네 귀퉁이에 차(Chariot) 배치 확인 + assertThat(actual.get(Position.of(0, 0))).isInstanceOf(Chariot.class); + assertThat(actual.get(Position.of(8, 0))).isInstanceOf(Chariot.class); + assertThat(actual.get(Position.of(0, 9))).isInstanceOf(Chariot.class); + assertThat(actual.get(Position.of(8, 9))).isInstanceOf(Chariot.class); + + // 초나라(y=0) LEFT_ELEPHANT: 상(1), 마(2), 상(6), 마(7) + assertThat(actual.get(Position.of(1, 0))).isInstanceOf(Elephant.class); + assertThat(actual.get(Position.of(2, 0))).isInstanceOf(Horse.class); + assertThat(actual.get(Position.of(6, 0))).isInstanceOf(Elephant.class); + assertThat(actual.get(Position.of(7, 0))).isInstanceOf(Horse.class); + + // 한나라(y=9) RIGHT_ELEPHANT: 마(2), 상(1), 마(7), 상(6) + assertThat(actual.get(Position.of(2, 9))).isInstanceOf(Horse.class); + assertThat(actual.get(Position.of(1, 9))).isInstanceOf(Elephant.class); + assertThat(actual.get(Position.of(7, 9))).isInstanceOf(Horse.class); + assertThat(actual.get(Position.of(6, 9))).isInstanceOf(Elephant.class); + } + + @Test + void 양쪽_장군이_모두_있으면_게임은_종료되지_않는다() { + Map pieces = new HashMap<>(); + pieces.put(Position.of(4, 1), PieceFactory.createGeneral(Side.CHO)); + pieces.put(Position.of(4, 8), PieceFactory.createGeneral(Side.HAN)); + Board board = new Board(pieces); + + assertThat(board.isGameOver()).isFalse(); + } + + @Test + void 장군이_하나만_남으면_게임이_종료된다() { + Map pieces = new HashMap<>(); + pieces.put(Position.of(4, 1), PieceFactory.createGeneral(Side.CHO)); + Board board = new Board(pieces); + + assertThat(board.isGameOver()).isTrue(); + } + + @Test + @DisplayName("목적지에 적군 기물이 있으면 보드에서 해당 기물을 제거하고 이동한다") + void captureEnemy() { + // Given: (0, 3)에 초나라 졸, (0, 4)에 한나라 졸 배치 + Position source = Position.of(0, 3); + Position target = Position.of(0, 4); + Piece choSoldier = PieceFactory.createSoldier(Side.CHO); + Piece hanSoldier = PieceFactory.createSoldier(Side.HAN); + + Board board = new Board(Map.of( + source, choSoldier, + target, hanSoldier + )); + + // When: (0, 3)의 초나라 졸이 (0, 4)의 한나라 졸을 잡음 + Board movedBoard = board.movePiece(source, target); + + // Then: 출발지는 비어있고, 도착지에는 초나라 졸이 위치함 + assertThat(movedBoard.isEmpty(source)).isTrue(); + assertThat(movedBoard.getPiece(target)).isSameAs(choSoldier); + } + + @Test + @DisplayName("기물이 존재하지 않는 빈 좌표를 출발지로 입력하면 예외가 발생한다") + void moveEmptySource() { + // Given: 비어있는 보드 + Board board = new Board(Map.of()); + Position emptyPos = Position.of(0, 0); + Position target = Position.of(0, 1); + + // When & Then: 빈 좌표를 선택하여 이동을 시도하거나 이동 가능한 위치를 찾을 때 예외 발생 + assertThatThrownBy(() -> board.findDestinations(emptyPos)) + .isInstanceOf(IllegalArgumentException.class); + + assertThatThrownBy(() -> board.movePiece(emptyPos, target)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("목적지에 아군 기물이 있는지는 이동 가능 경로 탐색 단계에서 필터링된다") + void validateAllyAtDestination() { + // Given: (0, 0)에 초나라 차, (0, 1)에 초나라 졸 배치 + Position chariotPos = Position.of(0, 0); + Position allyPos = Position.of(0, 1); + Board board = new Board(Map.of( + chariotPos, PieceFactory.createChariot(Side.CHO), + allyPos, PieceFactory.createSoldier(Side.CHO) + )); + + // When: 차(Chariot)의 이동 가능 위치 탐색 + Destinations destinations = board.findDestinations(chariotPos); + + // Then: 아군이 있는 (0, 1)은 목적지 목록에 포함되지 않음 + assertThat(destinations.getPositions()).doesNotContain(allyPos); + } +} diff --git a/src/test/java/domain/board/FormationTest.java b/src/test/java/domain/board/FormationTest.java new file mode 100644 index 0000000000..a29797be72 --- /dev/null +++ b/src/test/java/domain/board/FormationTest.java @@ -0,0 +1,22 @@ +package domain.board; + +import static org.assertj.core.api.Assertions.assertThat; + +import domain.piece.PieceType; +import java.util.List; +import org.junit.jupiter.api.Test; + +class FormationTest { + + @Test + void 포메이션은_기물_배치_순서를_가진다() { + assertThat(Formation.LEFT_ELEPHANT.getOrders()) + .isEqualTo(List.of(PieceType.ELEPHANT, PieceType.HORSE, PieceType.ELEPHANT, PieceType.HORSE)); + assertThat(Formation.RIGHT_ELEPHANT.getOrders()) + .isEqualTo(List.of(PieceType.HORSE, PieceType.ELEPHANT, PieceType.HORSE, PieceType.ELEPHANT)); + assertThat(Formation.OUTER_ELEPHANT.getOrders()) + .isEqualTo(List.of(PieceType.ELEPHANT, PieceType.HORSE, PieceType.HORSE, PieceType.ELEPHANT)); + assertThat(Formation.INNER_ELEPHANT.getOrders()) + .isEqualTo(List.of(PieceType.HORSE, PieceType.ELEPHANT, PieceType.ELEPHANT, PieceType.HORSE)); + } +} diff --git a/src/test/java/domain/piece/CannonTest.java b/src/test/java/domain/piece/CannonTest.java new file mode 100644 index 0000000000..51a048eef5 --- /dev/null +++ b/src/test/java/domain/piece/CannonTest.java @@ -0,0 +1,97 @@ +package domain.piece; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import domain.Destinations; +import domain.Position; +import domain.Side; +import domain.board.Board; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class CannonTest { + + @Test + @DisplayName("포는 상하좌우 방향으로 기물 하나를 뛰어넘어 빈칸으로 이동할 수 있다") + void jumpOverBridge() { + // Given: (1, 1)에 포, (1, 3)에 다리(졸) 배치 + Position current = Position.of(1, 1); + Position bridge = Position.of(1, 3); + Map pieces = Map.of( + current, PieceFactory.createCannon(Side.CHO), + bridge, PieceFactory.createSoldier(Side.CHO) + ); + Board board = new Board(pieces); + + // When + Destinations movable = board.findDestinations(current); + + // Then: 다리(1, 3) 이전인 (1, 2)는 못 가고, 다리 너머인 (1, 4)부터 끝까지 이동 가능 + assertThat(movable.getPositions()).doesNotContain(Position.of(1, 2)); + assertThat(movable.getPositions()).contains(Position.of(1, 4), Position.of(1, 9)); + } + + @Test + @DisplayName("포는 이동 경로에서 포 기물을 뛰어넘을 수 없다") + void cannotJumpOverAnotherCannon() { + // Given: (1, 1)에 포, (1, 3)에 다른 포(다리 역할 시도) 배치 + Position current = Position.of(1, 1); + Position cannonBridge = Position.of(1, 3); + Map pieces = Map.of( + current, PieceFactory.createCannon(Side.CHO), + cannonBridge, PieceFactory.createCannon(Side.HAN) + ); + Board board = new Board(pieces); + + // When & Then 이동할 목적지가 없어서 예외가 발생 + assertThatThrownBy(() -> board.findDestinations(current)) + .isInstanceOf(IllegalArgumentException.class); + + } + + @Test + @DisplayName("포는 기물을 뛰어넘은 후 적군 기물을 만나면 해당 위치까지 이동하여 잡을 수 있다") + void captureEnemy() { + // Given: (1, 1)에 포, (1, 3)에 다리, (1, 5)에 적군(졸) 배치 + Position current = Position.of(1, 1); + Position bridge = Position.of(1, 3); + Position enemy = Position.of(1, 5); + Map pieces = Map.of( + current, PieceFactory.createCannon(Side.CHO), + bridge, PieceFactory.createSoldier(Side.CHO), + enemy, PieceFactory.createSoldier(Side.HAN) + ); + Board board = new Board(pieces); + + // When + Destinations movable = board.findDestinations(current); + + // Then: 적군(1, 5)까지는 갈 수 있지만, 그 너머(1, 6)는 갈 수 없음 + assertThat(movable.getPositions()).contains(Position.of(1, 4), Position.of(1, 5)); + assertThat(movable.getPositions()).doesNotContain(Position.of(1, 6)); + } + + @Test + @DisplayName("포는 뛰어넘은 후 만난 적군 기물이 포일 경우 잡을 수 없다") + void cannotCaptureEnemyCannon() { + // Given: (1, 1)에 포, (1, 3)에 다리, (1, 5)에 적군 포 배치 + Position current = Position.of(1, 1); + Position bridge = Position.of(1, 3); + Position enemyCannon = Position.of(1, 5); + Map pieces = Map.of( + current, PieceFactory.createCannon(Side.CHO), + bridge, PieceFactory.createSoldier(Side.CHO), + enemyCannon, PieceFactory.createCannon(Side.HAN) + ); + Board board = new Board(pieces); + + // When + Destinations movable = board.findDestinations(current); + + // Then: 적군 포(1, 5) 직전인 (1, 4)까지만 갈 수 있고 (1, 5)는 포함되지 않음 + assertThat(movable.getPositions()).contains(Position.of(1, 4)); + assertThat(movable.getPositions()).doesNotContain(Position.of(1, 5)); + } +} diff --git a/src/test/java/domain/piece/ChariotTest.java b/src/test/java/domain/piece/ChariotTest.java new file mode 100644 index 0000000000..8ffa222965 --- /dev/null +++ b/src/test/java/domain/piece/ChariotTest.java @@ -0,0 +1,70 @@ +package domain.piece; + +import static org.assertj.core.api.Assertions.assertThat; + +import domain.Destinations; +import domain.Position; +import domain.Side; +import domain.board.Board; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ChariotTest { + + @Test + @DisplayName("차는 상하좌우 방향으로 장애물을 만날 때까지 연속해서 이동할 수 있다") + void move() { + // Given: 빈 보드의 (0, 0)에 차 배치 + Position current = Position.of(0, 0); + Map pieces = Map.of(current, PieceFactory.createChariot(Side.CHO)); + Board board = new Board(pieces); + + // When + Destinations movable = board.findDestinations(current); + + // Then: 같은 행(0, 1~9)과 같은 열(1~8, 0)의 모든 위치가 포함되어야 함 (총 9+8=17개) + assertThat(movable.getPositions()).hasSize(17); + assertThat(movable.getPositions()).contains(Position.of(0, 9), Position.of(8, 0)); + } + + @Test + @DisplayName("차는 이동 경로 중 아군 기물을 만나면 그 직전 위치까지만 이동할 수 있다") + void allyObstacle() { + // Given: (0, 0)에 차, (0, 3)에 아군 배치 + Position current = Position.of(0, 0); + Position ally = Position.of(0, 3); + Map pieces = Map.of( + current, PieceFactory.createChariot(Side.CHO), + ally, PieceFactory.createSoldier(Side.CHO) + ); + Board board = new Board(pieces); + + // When + Destinations movable = board.findDestinations(current); + + // Then: (0, 1), (0, 2)는 가능하지만 (0, 3)과 그 너머(0, 4)는 불가능해야 함 + assertThat(movable.getPositions()).contains(Position.of(0, 1), Position.of(0, 2)); + assertThat(movable.getPositions()).doesNotContain(Position.of(0, 3), Position.of(0, 4)); + } + + @Test + @DisplayName("차는 이동 경로 중 적군 기물을 만나면 해당 위치까지 이동하여 잡을 수 있다") + void enemyCapture() { + // Given: (0, 0)에 차, (5, 0)에 적군 배치 + Position current = Position.of(0, 0); + Position enemy = Position.of(5, 0); + Map pieces = Map.of( + current, PieceFactory.createChariot(Side.CHO), + enemy, PieceFactory.createSoldier(Side.HAN) + ); + Board board = new Board(pieces); + + // When + Destinations movable = board.findDestinations(current); + + // Then: 적군이 있는 (5, 0)까지는 이동 가능하지만, 그 너머(6, 0)는 불가능해야 함 + assertThat(movable.getPositions()).contains(Position.of(1, 0), Position.of(4, 0), Position.of(5, 0)); + assertThat(movable.getPositions()).doesNotContain(Position.of(6, 0)); + } +} diff --git a/src/test/java/domain/piece/ElephantTest.java b/src/test/java/domain/piece/ElephantTest.java new file mode 100644 index 0000000000..528e83f58e --- /dev/null +++ b/src/test/java/domain/piece/ElephantTest.java @@ -0,0 +1,68 @@ +package domain.piece; + +import static org.assertj.core.api.Assertions.assertThat; + +import domain.Destinations; +import domain.Position; +import domain.Side; +import domain.board.Board; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ElephantTest { + + @Test + @DisplayName("상은 직선 1칸 이동 후 같은 방향 대각선으로 2칸 이동할 수 있다") + void move() { + // Given: (4, 4)에 상 배치 (장애물 없는 상태) + Position current = Position.of(4, 4); + Map pieces = Map.of(current, PieceFactory.createElephant(Side.CHO)); + Board board = new Board(pieces); + + // When + Destinations movable = board.findDestinations(current); + + // Then: 8방향의 최종 목적지 확인 + assertThat(movable.getPositions()).containsExactlyInAnyOrder( + Position.of(6, 7), Position.of(2, 7), // 북쪽 기반 + Position.of(7, 6), Position.of(7, 2), // 동쪽 기반 + Position.of(6, 1), Position.of(2, 1), // 남쪽 기반 + Position.of(1, 6), Position.of(1, 2) // 서쪽 기반 + ); + } + + @Test + @DisplayName("상은 직선 1칸 또는 대각선 1칸 경로(멱) 중 하나라도 기물이 존재하면 이동할 수 없다") + void bridge() { + // Given: (4, 4)에 상 배치 + Position current = Position.of(4, 4); + + // 1. 직선 1칸 멱(4, 5)에 장애물 배치 -> 북쪽 기반 (6, 7)과 (2, 7) 경로 차단 + // 2. 대각선 1칸 멱(5, 2)에 장애물 배치 -> 남동쪽 목적지 (6, 1) 경로 차단 + Map pieces = Map.of( + current, PieceFactory.createElephant(Side.CHO), + Position.of(4, 5), PieceFactory.createSoldier(Side.HAN), // 북쪽 직진 멱 + Position.of(5, 2), PieceFactory.createSoldier(Side.HAN) // 남동쪽 대각선 멱 (수정됨) + ); + Board board = new Board(pieces); + + // When + Destinations movable = board.findDestinations(current); + + // Then + // 1. 북쪽 직진 멱이 막혔으므로 (6, 7)과 (2, 7)은 없어야 함 + assertThat(movable.getPositions()).doesNotContain( + Position.of(6, 7), + Position.of(2, 7) + ); + + // 2. 대각선 멱(5, 2)이 막혔으므로 (6, 1)은 없어야 함 + assertThat(movable.getPositions()).doesNotContain( + Position.of(6, 1) + ); + + // 장애물이 없는 다른 방향(예: 서쪽 기반 1, 6 등)은 유지되어야 함 + assertThat(movable.getPositions()).contains(Position.of(1, 6)); + } +} diff --git a/src/test/java/domain/piece/GeneralTest.java b/src/test/java/domain/piece/GeneralTest.java new file mode 100644 index 0000000000..8d9fcc82ff --- /dev/null +++ b/src/test/java/domain/piece/GeneralTest.java @@ -0,0 +1,31 @@ +package domain.piece; + +import static org.assertj.core.api.Assertions.assertThat; + +import domain.Destinations; +import domain.Position; +import domain.Side; +import domain.board.Board; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GeneralTest { + @Test + @DisplayName("궁은 상하좌우 1칸 이동하며, 범위를 벗어나거나 아군이 있으면 이동할 수 없다") + void move() { + // (4,1)에 궁, (4,2)에 아군 사 배치 -> (4,2) 이동 불가, (4,0), (3,1), (5,1) 이동 가능 + Position current = Position.of(4, 1); + Map pieces = Map.of( + current, PieceFactory.createGeneral(Side.CHO), + Position.of(4, 2), PieceFactory.createGuard(Side.CHO) + ); + Board board = new Board(pieces); + + Destinations movable = board.findDestinations(current); + + assertThat(movable.getPositions()).containsExactlyInAnyOrder( + Position.of(4, 0), Position.of(3, 1), Position.of(5, 1) + ); + } +} diff --git a/src/test/java/domain/piece/GuardTest.java b/src/test/java/domain/piece/GuardTest.java new file mode 100644 index 0000000000..c64bafb7a7 --- /dev/null +++ b/src/test/java/domain/piece/GuardTest.java @@ -0,0 +1,30 @@ +package domain.piece; + +import static org.assertj.core.api.Assertions.assertThat; + +import domain.Destinations; +import domain.Position; +import domain.Side; +import domain.board.Board; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GuardTest { + @Test + @DisplayName("사는 상하좌우 1칸 이동하며 아군 기물이 있으면 이동할 수 없다") + void move() { + Position current = Position.of(3, 1); + Map pieces = Map.of( + current, PieceFactory.createGuard(Side.CHO), + Position.of(3, 2), PieceFactory.createChariot(Side.CHO) + ); + Board board = new Board(pieces); + + Destinations movable = board.findDestinations(current); + + assertThat(movable.getPositions()).containsExactlyInAnyOrder( + Position.of(4, 1), Position.of(2, 1), Position.of(3,0) + ); + } +} diff --git a/src/test/java/domain/piece/HorseTest.java b/src/test/java/domain/piece/HorseTest.java new file mode 100644 index 0000000000..bd13cc6889 --- /dev/null +++ b/src/test/java/domain/piece/HorseTest.java @@ -0,0 +1,59 @@ +package domain.piece; + +import static org.assertj.core.api.Assertions.assertThat; + +import domain.Destinations; +import domain.Position; +import domain.Side; +import domain.board.Board; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class HorseTest { + + @Test + @DisplayName("마는 직선 1칸 이동 후 대각선 1칸 방향으로 이동할 수 있다") + void move() { + // Given: (4, 4)에 마 배치 (장애물 없는 상태) + Position current = Position.of(4, 4); + Map pieces = Map.of(current, PieceFactory.createHorse(Side.CHO)); + Board board = new Board(pieces); + + // When + Destinations movable = board.findDestinations(current); + + // Then: 8방향의 L자형 목적지 확인 + assertThat(movable.getPositions()).containsExactlyInAnyOrder( + Position.of(3, 6), Position.of(5, 6), // 북쪽 방향 2개 + Position.of(6, 5), Position.of(6, 3), // 동쪽 방향 2개 + Position.of(5, 2), Position.of(3, 2), // 남쪽 방향 2개 + Position.of(2, 3), Position.of(2, 5) // 서쪽 방향 2개 + ); + } + + @Test + @DisplayName("마는 직선 1칸 경로(멱)에 기물이 존재하면 해당 방향으로 이동할 수 없다") + void bridge() { + // Given: (4, 4)에 마 배치, 북쪽 멱(4, 5)에 장애물 배치 + Position current = Position.of(4, 4); + Position northBridge = Position.of(4, 5); + Map pieces = Map.of( + current, PieceFactory.createHorse(Side.CHO), + northBridge, PieceFactory.createSoldier(Side.HAN) + ); + Board board = new Board(pieces); + + // When + Destinations movable = board.findDestinations(current); + + // Then: 북쪽 멱이 막혔으므로 북쪽 대각선 목적지인 (3, 6)과 (5, 6)은 제외되어야 함 + assertThat(movable.getPositions()).doesNotContain( + Position.of(3, 6), + Position.of(5, 6) + ); + + // 나머지 6개 방향은 정상 이동 가능해야 함 + assertThat(movable.getPositions()).hasSize(6); + } +} diff --git a/src/test/java/domain/piece/PieceTest.java b/src/test/java/domain/piece/PieceTest.java new file mode 100644 index 0000000000..7b7751dab8 --- /dev/null +++ b/src/test/java/domain/piece/PieceTest.java @@ -0,0 +1,32 @@ +package domain.piece; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import domain.Side; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class PieceTest { + + @ParameterizedTest + @MethodSource("provideSideCase") + void 주어진_진영과_자신의_진영이_같은지_올바르게_판별한다(Side side, Side other, boolean expected) { + Piece piece = PieceFactory.createSoldier(side); + + boolean actual = piece.isAlly(other); + + assertThat(actual).isEqualTo(expected); + } + + static Stream provideSideCase() { + return Stream.of( + arguments(Side.CHO, Side.CHO, true), + arguments(Side.HAN, Side.HAN, true), + arguments(Side.CHO, Side.HAN, false), + arguments(Side.HAN, Side.CHO, false) + ); + } +} diff --git a/src/test/java/domain/piece/SoldierTest.java b/src/test/java/domain/piece/SoldierTest.java new file mode 100644 index 0000000000..e44412358d --- /dev/null +++ b/src/test/java/domain/piece/SoldierTest.java @@ -0,0 +1,30 @@ +package domain.piece; + +import static org.assertj.core.api.Assertions.assertThat; + +import domain.Position; +import domain.Side; +import domain.board.Board; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class SoldierTest { + @Test + @DisplayName("초나라 졸은 상/좌/우 이동이 가능하고 한나라 졸은 하/좌/우 이동이 가능하다") + void moveBySide() { + Position choPos = Position.of(4, 3); + Position hanPos = Position.of(4, 6); + Board board = new Board(Map.of( + choPos, PieceFactory.createSoldier(Side.CHO), + hanPos, PieceFactory.createSoldier(Side.HAN) + )); + + assertThat(board.findDestinations(choPos).getPositions()).containsExactlyInAnyOrder( + Position.of(4, 4), Position.of(3, 3), Position.of(5, 3) + ); + assertThat(board.findDestinations(hanPos).getPositions()).containsExactlyInAnyOrder( + Position.of(4, 5), Position.of(3, 6), Position.of(5, 6) + ); + } +} diff --git a/src/test/java/domain/player/NameTest.java b/src/test/java/domain/player/NameTest.java new file mode 100644 index 0000000000..c0abff4945 --- /dev/null +++ b/src/test/java/domain/player/NameTest.java @@ -0,0 +1,34 @@ +package domain.player; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class NameTest { + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void 이름은_빈값이나_공백이_될수없다(String input) { + assertThatThrownBy(() -> new Name(input)) + .isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"12", "12345"}) + void 이름이_2글자_이상이거나_5글자를_이하이면_정상_생성된다(String input) { + assertThatCode(() -> new Name(input)) + .doesNotThrowAnyException(); + + + } + @ParameterizedTest + @ValueSource(strings = {"1", "123456"}) + void 이름이_2글자_미만이거나_5글자를_초과하면_예외가_발생한다(String input) { + assertThatThrownBy(() -> new Name(input)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/domain/player/PlayerTest.java b/src/test/java/domain/player/PlayerTest.java new file mode 100644 index 0000000000..4dcac94a08 --- /dev/null +++ b/src/test/java/domain/player/PlayerTest.java @@ -0,0 +1,53 @@ +package domain.player; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import domain.Side; +import domain.piece.Piece; +import domain.piece.PieceFactory; +import domain.state.ActiveTurn; +import org.junit.jupiter.api.Test; + +class PlayerTest { + + @Test + void 턴_상태를_토글하면_현재_턴_여부가_반전된다() { + // Given: 초나라 플레이어가 자신의 턴(ActiveTurn)인 상태로 생성 + Player player = new Player(new Name("cho"), Side.CHO, new ActiveTurn()); + + // When: 턴을 한 번 토글 (Active -> Waiting) + player.toggleTurn(); + // Then + assertThat(player.isCurrentTurn()).isFalse(); + + // When: 턴을 다시 토글 (Waiting -> Active) + player.toggleTurn(); + // Then + assertThat(player.isCurrentTurn()).isTrue(); + } + + @Test + void 플레이어는_자신의_기물이_아닌_상대방의_기물을_검증하면_예외가_발생한다() { + // Given: 초나라 플레이어와 한나라 졸(Soldier) + Player choPlayer = new Player(new Name("cho"), Side.CHO, new ActiveTurn()); + + // PieceFactory를 사용하여 실제 도메인과 동일한 기물 생성 (전략 주입 포함) + Piece hanPiece = PieceFactory.createSoldier(Side.HAN); + + // When & Then: 초나라 플레이어가 한나라 기물을 validateAlly 할 때 예외 발생 + assertThatThrownBy(() -> choPlayer.validateAlly(hanPiece)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("상대방의 기물은 움직일 수 없습니다."); + } + + @Test + void 플레이어는_자신의_기물을_검증하면_예외가_발생하지_않는다() { + // Given: 초나라 플레이어와 초나라 졸(Soldier) + Player choPlayer = new Player(new Name("cho"), Side.CHO, new ActiveTurn()); + Piece choPiece = PieceFactory.createSoldier(Side.CHO); + + // When & Then: 예외 없이 통과해야 함 + choPlayer.validateAlly(choPiece); + } +} diff --git a/src/test/java/domain/player/PlayersTest.java b/src/test/java/domain/player/PlayersTest.java new file mode 100644 index 0000000000..7303fac791 --- /dev/null +++ b/src/test/java/domain/player/PlayersTest.java @@ -0,0 +1,14 @@ +package domain.player; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class PlayersTest { + + @Test + void 초기_플레이어_생성_시_두_플레이어의_이름이_같으면_예외가_발생한다() { + assertThatThrownBy(() -> Players.createInitial(new Name("whale"), new Name("whale"))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/parser/FormationCommandTest.java b/src/test/java/parser/FormationCommandTest.java new file mode 100644 index 0000000000..64b1d48210 --- /dev/null +++ b/src/test/java/parser/FormationCommandTest.java @@ -0,0 +1,33 @@ +package parser; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import domain.board.Formation; +import org.junit.jupiter.api.Test; + +class FormationCommandTest { + + @Test + void 포메이션_입력이_1_2_3_4_일때_정상_동작한다() { + assertThat(FormationCommand.from("1").toFormation()).isEqualTo(Formation.LEFT_ELEPHANT); + assertThat(FormationCommand.from("2").toFormation()).isEqualTo(Formation.RIGHT_ELEPHANT); + assertThat(FormationCommand.from("3").toFormation()).isEqualTo(Formation.OUTER_ELEPHANT); + assertThat(FormationCommand.from("4").toFormation()).isEqualTo(Formation.INNER_ELEPHANT); + } + + @Test + void 포메이션_입력에_공백이_포함되어도_정상_동작한다() { + assertThat(FormationCommand.from("1 ").toFormation()).isEqualTo(Formation.LEFT_ELEPHANT); + assertThat(FormationCommand.from(" 2").toFormation()).isEqualTo(Formation.RIGHT_ELEPHANT); + assertThat(FormationCommand.from(" 3 ").toFormation()).isEqualTo(Formation.OUTER_ELEPHANT); + } + + @Test + void 포메이션_입력이_1_2_3_4_중_하나가_아니면_예외가_발생한다() { + assertThatThrownBy(() -> FormationCommand.from("0")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> FormationCommand.from("5")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/parser/InputParserTest.java b/src/test/java/parser/InputParserTest.java new file mode 100644 index 0000000000..e30f4879db --- /dev/null +++ b/src/test/java/parser/InputParserTest.java @@ -0,0 +1,45 @@ +package parser; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import domain.board.Formation; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.api.Test; + +class InputParserTest { + + @ParameterizedTest + @ValueSource(strings = {"0,3", "3, 9", " 8 ,0 "}) + void 올바른_좌표_형식이_입력되는_경우_정상_동작한다(String input) { + assertThatCode(() -> InputParser.parsePosition(input)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "a,b", "(0,3)", "(a,b)", "()", "(,)", "1", "1,2,3"}) + void 위치_입력_포맷이_올바른_형태가_아니면_예외가_발생한다(String input) { + assertThatThrownBy(() -> InputParser.parsePosition(input)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 포메이션_입력을_도메인_포메이션으로_파싱한다() { + assertThat(InputParser.parseFormation("1")).isEqualTo(Formation.LEFT_ELEPHANT); + assertThat(InputParser.parseFormation("2")).isEqualTo(Formation.RIGHT_ELEPHANT); + assertThat(InputParser.parseFormation("3")).isEqualTo(Formation.OUTER_ELEPHANT); + assertThat(InputParser.parseFormation("4")).isEqualTo(Formation.INNER_ELEPHANT); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "0", "5", "a"}) + void 잘못된_포메이션_입력이면_예외가_발생한다(String input) { + assertThatThrownBy(() -> InputParser.parseFormation(input)) + .isInstanceOf(IllegalArgumentException.class); + } +}