diff --git a/README.md b/README.md index 9775dda0ae..14232d76c0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,343 @@ -# 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) 만약 새 기물이 추가될 때 기존 이동 로직을 여러 곳 수정해야 한다면 +> +> → 기물별 책임 분리가 부족한 것으로 보고 구조를 꼭 필히 다시 점검한다. +> +> (기준) 확장성 판단 기준: 새로운 기물 추가나 규칙 변경 시 기존 코드 수정 범위가 작아야 한다. +> +> (금지) 이번 미션에서 새 기물 추가를 위해 기존 분기문을 계속 늘리는 방식으로 확장하지 않는다 + + +### 👨‍💻 고민과 결정 (나는 왜 이렇게 구현할까?) + +**1. 기물과 보드의 협력 설계: 내부 자료구조 노출 방지와 책임 할당** + +- **고민:** 기물이 이동 가능 위치를 찾으려면 보드의 상태(빈칸, 아군/적군 위치)를 알아야 한다. 기존처럼 `Map`를 기물에게 직접 넘기면 보드의 내부 구현이 노출되고 캡슐화가 깨진다. 반대로, 기물이 보드 상태를 아예 모른 채 '이동 경로(Route)' 데이터만 반환하고 외부 심판(Validator)이 이를 판정하게 만드는 것이 맞을까? + +- **결정:** 보드 내부 구조를 추상화한 `BoardReader` 인터페이스를 기물에게 주입하는 방식을 선택했다. + +- **근거:** 첫째, 기물이 `Map`이라는 보드의 세부 구현에 강하게 의존하는 구조를 탈피하고 싶었다. 둘째, 외부 심판(Validator) 방식을 도입하기에는 마(馬), 상(象)처럼 기물마다 확인해야 할 경유지(멱)의 개수가 다르고 포(包)처럼 예외적인 규칙도 많다. 이 모든 경우의 수를 외부 심판 하나에 위임하면 심판 객체의 책임이 너무 무거워지고 비대해진다. `BoardReader`를 도입하면 보드의 실제 자료구조는 완벽히 숨기면서도, 각 기물이 자신의 고유한 이동 규칙을 객체지향적으로 응집도 있게 캡슐화할 수 있다 + +**2. 상태 변경과 불변성: `move` 시 가변 Map 갱신 vs 새로운 Board 반환** + +- **고민:** 기물이 이동할 때 기존 `Board` 내부의 Map을 `put/remove`로 갱신하는 것이 직관적이다. 하지만 상태가 가변적이면 사이드 이펙트가 우려된다. + +- **결정:** `Map.copyOf()`를 활용하여 기존 상태와 연결고리가 끊어진 불변 맵을 만들고, 이를 가진 새로운 Board 객체를 반환하도록 구현한다. + +- **근거:** 가변 상태를 사용할 경우 부수효과(Side-effect)로 인해 예상치 못한 곳에서 데이터가 변경되는 버그가 발생하거나, 동시성 문제까지 신경 써야 하는 복잡성이 생긴다. 상태가 변할 때마다 기존 상태를 수정하는 대신 새로운 불변 상태를 생성하여 반환하도록 설계하면, 동작의 예측 가능성이 높아지고 이런 부작용에 대한 우려를 원천적으로 차단할 수 있다. + +**3. 기물 객체의 라이프사이클: 매번 `new` 생성 vs 기물 인스턴스 캐싱 재사용** + +- **고민:** 게임 초기화 시 32개의 기물을 배치할 때, `new Horse(Side.CHO)` 형태로 매번 인스턴스를 생성하는 것이 자원 낭비는 아닐까? + +- **결정:** `PieceFactory`를 도입하여 기물 인스턴스를 캐싱하고 재사용한다. + +- **근거:** 팀 규칙에 따라 기물은 자신의 '위치(Position)'를 상태로 갖지 않는 완벽한 불변 객체다. 즉, 보드판 위에 있는 2개의 '초나라 마'는 메모리 상에서 완전히 동일한 상태를 갖는다. 이를 싱글톤처럼 1개만 생성하여 공유함으로써 메모리를 절약한다. + + +**4. 다채로운 이동 규칙의 중복 제거: 하드코딩 vs 전략(Strategy) 패턴** + +- **고민:** 기물 내부에 상, 하, 좌, 우 방향에 따라 `if`문과 `while`문이 산재해 있어 코드 중복이 매우 심하다. 이 반복적이고 고정적인 로직들을 어떻게 관리해야 재사용성과 응집도를 높일 수 있을까? + +- **결정:** 상, 하, 좌, 우 등의 좌표 증감값을 `Direction` Enum으로 도출하고, 고정적인 공통 탐색 알고리즘을 `OneStepStrategy`, `SlideStrategy`, `JumpSlideStrategy`, `SequenceStrategy`로 추상화하여 기물에 주입한다. + +- **근거:** 상하좌우 방향마다 고정되고 반복적으로 발생하는 탐색 로직(`if`, `while`)을 다형성으로 추상화하면 코드의 재사용성이 크게 향상된다. 기물은 '자신이 갈 수 있는 방향'이라는 순수 도메인 지식만 가지고, 실제 보드를 탐색하는 복잡한 절차는 전략 객체가 담당하게 역할을 분리함으로써, 규칙 변경이나 확장 시 해당 전략만 수정하면 되는 높은 응집도를 확보할 수 있다. + + +**5. 다형성의 활용: 상태 패턴(State) vs 전략 패턴(Strategy)의 분리 적용** + +- **고민:** `if/else` 분기문을 제거하고 다형성을 활용해야 하는 상황이 많다. 기물마다 이동하는 규칙이 다르고, 플레이어의 턴(현재 턴, 대기 턴) 상태도 다르다. 이들을 구조가 비슷하다는 이유로 모두 동일한 디자인 패턴으로 묶어서 처리하는 것이 맞을까? + +- **결정:** 런타임에 빈번하게 교체되는 플레이어의 턴 관리에는 상태 패턴(State Pattern)을 적용하고, 객체 생성 시 한 번 정해지면 변하지 않는 기물의 이동 규칙에는 전략 패턴(Strategy Pattern)을 적용하여 용도를 분리했다. + +- **근거:** 두 패턴은 클래스 다이어그램 구조가 거의 동일하지만 설계의 '의도(Intent)'가 다르다. 턴 상태(`ActiveTurn`, `InactiveTurn`)는 게임 진행에 따라 매 턴마다 동적으로 상태가 교체되며 스스로 책임을 넘기는 행위가 중요하므로 상태 패턴이 적합하다. 반면, 기물의 이동 규칙(`OneStepStrategy`, `SlideStrategy`)은 기물 생성 시 알고리즘이 한 번 주입되면 게임이 끝날 때까지 변경될 일이 없으므로 전략 패턴이 부합한다. '상태 변경의 빈도'와 '의도'를 기준으로 패턴을 다르게 적용하여 객체지향적 설계의 목적을 코드에 명확히 드러냈다. 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..e09a442db5 --- /dev/null +++ b/src/main/java/Application.java @@ -0,0 +1,14 @@ +import controller.JanggiConsoleController; +import java.util.Scanner; +import view.InputView; +import view.OutputView; + +public class Application { + public static void main(String[] args) { + JanggiConsoleController janggiConsoleController = new JanggiConsoleController( + new InputView(new Scanner(System.in)), + new OutputView() + ); + janggiConsoleController.play(); + } +} diff --git a/src/main/java/controller/JanggiConsoleController.java b/src/main/java/controller/JanggiConsoleController.java new file mode 100644 index 0000000000..0e97f2d216 --- /dev/null +++ b/src/main/java/controller/JanggiConsoleController.java @@ -0,0 +1,96 @@ +package controller; + +import domain.Game; +import domain.MoveCandidate; +import domain.Position; +import domain.Side; +import domain.board.Board; +import domain.board.BoardFactory; +import domain.board.Formation; +import domain.board.FormationCommand; +import domain.player.Name; +import domain.player.Players; +import dto.BoardDto; +import dto.DestinationDto; +import dto.PositionDto; +import java.util.List; +import java.util.function.Supplier; +import view.InputParser; +import view.InputView; +import view.OutputView; + +public class JanggiConsoleController { + private final InputView inputView; + private final OutputView outputView; + + public JanggiConsoleController(InputView inputView, OutputView outputView) { + this.inputView = inputView; + this.outputView = outputView; + } + + public void play() { + Game game = initializeGame(); + outputView.printBoard(BoardDto.from(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(() -> Formation.from(InputParser.parseFormation(inputView.readFormation(side)))); + } + + private void playTurn(Game game) { + MoveCandidate moveCandidate = selectPiecePosition(game); + retry(() -> { + Position target = InputParser.parsePosition(inputView.readTargetPosition()); + game.move(moveCandidate, target); + }); + outputView.printBoard(BoardDto.from(game.getBoard())); + } + + private MoveCandidate selectPiecePosition(Game game) { + return retry(() -> { + Position position = InputParser.parsePosition(inputView.readSourcePosition(game.getCurrentSide())); + MoveCandidate moveCandidate = game.selectSource(position); + outputView.printDestinations(DestinationDto.from(moveCandidate)); + return moveCandidate; + }); + } + + 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..229e1066c8 --- /dev/null +++ b/src/main/java/domain/Game.java @@ -0,0 +1,54 @@ +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 MoveCandidate selectSource(Position source) { + Piece selectedPiece = board.getPiece(source); + players.validateAlly(selectedPiece); + Destinations destinations = findDestinations(source); + return new MoveCandidate(source, destinations); + } + + public void move(MoveCandidate moveCandidate, Position target) { + moveCandidate.validate(target); + movePiece(moveCandidate.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() { + Side winnerSide = board.getWinnerSide(); + return players.getPlayerNameBySide(winnerSide); + } +} diff --git a/src/main/java/domain/MoveCandidate.java b/src/main/java/domain/MoveCandidate.java new file mode 100644 index 0000000000..af6f37d08e --- /dev/null +++ b/src/main/java/domain/MoveCandidate.java @@ -0,0 +1,15 @@ +package domain; + +import java.util.Objects; + +public record MoveCandidate(Position source, Destinations destinations) { + + public MoveCandidate { + Objects.requireNonNull(source, "출발 지점은 필수입니다."); + Objects.requireNonNull(destinations, "목적지 정보는 필수입니다."); + } + + public void validate(Position target) { + destinations.validateDestinations(target); + } +} diff --git a/src/main/java/domain/Position.java b/src/main/java/domain/Position.java new file mode 100644 index 0000000000..75b2913f75 --- /dev/null +++ b/src/main/java/domain/Position.java @@ -0,0 +1,76 @@ +package domain; + +import domain.strategy.Direction; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +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.getDeltaX(), this.y + direction.getDeltaY()); + } + + public boolean canMove(List sequence) { + if (sequence.isEmpty()) { + return true; + } + + Direction direction = sequence.getFirst(); + if (!canMove(direction)) { + return false; + } + + return move(direction).canMove(sequence.subList(1, sequence.size())); + } + + public Position move(Direction direction) { + return Position.of(this.x + direction.getDeltaX(), this.y + direction.getDeltaY()); + } + + 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 getY() { + return y; + } + + public int getX() { + return x; + } +} diff --git a/src/main/java/domain/Side.java b/src/main/java/domain/Side.java new file mode 100644 index 0000000000..9855418d47 --- /dev/null +++ b/src/main/java/domain/Side.java @@ -0,0 +1,21 @@ +package domain; + +public enum Side { + CHO("초"), + HAN("한"), + ; + + private final String name; + + Side(String name) { + this.name = name; + } + + 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..faad2729e1 --- /dev/null +++ b/src/main/java/domain/board/Board.java @@ -0,0 +1,76 @@ +package domain.board; + +import domain.Destinations; +import domain.Position; +import domain.Side; +import domain.piece.Piece; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Board implements BoardReader{ + public static final int MINIMUM_VITAL_PIECES_COUNT = 2; + private final Map board; + + public Board(Map board) { + this.board = Map.copyOf(board); + } + + public Destinations findDestinations(Position position) { + Piece piece = getPiece(position); + return piece.findDestinations(position, this); + } + + public Board movePiece(Position source, Position target) { + Map nextBoardMap = new HashMap<>(this.board); + Piece movingPiece = nextBoardMap.remove(source); + if (movingPiece == null) { + throw new IllegalArgumentException("출발지에 기물이 존재하지 않습니다."); + } + nextBoardMap.put(target, movingPiece); + return new Board(nextBoardMap); + } + + public boolean isGameOver() { + return getVitalSides().size() < MINIMUM_VITAL_PIECES_COUNT; + } + + public Side getWinnerSide() { + List vitalSides = getVitalSides(); + if (vitalSides.size() != 1) { + throw new IllegalStateException("승리한 진영을 확정할 수 없는 상태입니다."); + } + return vitalSides.getFirst(); + } + + private List getVitalSides() { + return board.values().stream() + .filter(Piece::isVital) + .map(Piece::getSide) + .distinct() + .toList(); + } + + public Map getBoard() { + return board; + } + + @Override + public boolean isEmpty(Position position) { + return !board.containsKey(position); + } + + @Override + public boolean isAlly(Position position, Side side) { + return getPiece(position).isAlly(side); + } + + @Override + public Piece getPiece(Position position) { + Piece piece = board.get(position); + if (piece == null) { + throw new IllegalArgumentException("기물이 존재하지 않는 위치입니다."); + } + return piece; + } +} diff --git a/src/main/java/domain/board/BoardFactory.java b/src/main/java/domain/board/BoardFactory.java new file mode 100644 index 0000000000..c1ad84a495 --- /dev/null +++ b/src/main/java/domain/board/BoardFactory.java @@ -0,0 +1,65 @@ +package domain.board; + +import domain.Position; +import domain.Side; +import domain.piece.Piece; +import domain.piece.PieceFactory; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class BoardFactory { + + private BoardFactory() { + } + + public static Board create(Formation choFormation, Formation hanFormation) { + Map allPieces = new HashMap<>(); + allPieces.putAll(createSidePieces(Side.CHO, choFormation)); + allPieces.putAll(createSidePieces(Side.HAN, hanFormation)); + return new Board(allPieces); + } + + private static Map createSidePieces(Side side, Formation formation) { + SideLayout layout = SideLayout.from(side); + Map sidePieces = new HashMap<>(); + + sidePieces.putAll(getFixedPieces(side, layout)); + sidePieces.putAll(getFormationPieces(side, formation, layout)); + + return sidePieces; + } + + private static Map getFixedPieces(Side side, SideLayout layout) { + Map fixedPieces = new HashMap<>(); + fixedPieces.put(Position.of(4, layout.getGeneralY()), PieceFactory.createGeneral(side)); + + int baseY = layout.getBaseY(); + fixedPieces.put(Position.of(0, baseY), PieceFactory.createChariot(side)); + fixedPieces.put(Position.of(8, baseY), PieceFactory.createChariot(side)); + fixedPieces.put(Position.of(3, baseY), PieceFactory.createGuard(side)); + fixedPieces.put(Position.of(5, baseY), PieceFactory.createGuard(side)); + + int cannonY = layout.getCannonY(); + fixedPieces.put(Position.of(1, cannonY), PieceFactory.createCannon(side)); + fixedPieces.put(Position.of(7, cannonY), PieceFactory.createCannon(side)); + + int soldierY = layout.getSoldierY(); + for (int x = 0; x <= 8; x += 2) { + fixedPieces.put(Position.of(x, soldierY), PieceFactory.createSoldier(side)); + } + return fixedPieces; + } + + private static Map getFormationPieces(Side side, Formation formation, SideLayout layout) { + List orders = formation.getPieceOrders(side); + List xCoordinates = layout.getFormationX(); + int y = layout.getBaseY(); + + Map formationPieces = new HashMap<>(); + for (int i = 0; i < orders.size(); i++) { + formationPieces.put(Position.of(xCoordinates.get(i), y), orders.get(i)); + } + return formationPieces; + } +} diff --git a/src/main/java/domain/board/BoardReader.java b/src/main/java/domain/board/BoardReader.java new file mode 100644 index 0000000000..b09b1764c9 --- /dev/null +++ b/src/main/java/domain/board/BoardReader.java @@ -0,0 +1,11 @@ +package domain.board; + +import domain.Position; +import domain.Side; +import domain.piece.Piece; + +public interface BoardReader { + boolean isEmpty(Position position); + boolean isAlly(Position position, Side side); + 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..5223ca2029 --- /dev/null +++ b/src/main/java/domain/board/Formation.java @@ -0,0 +1,70 @@ +package domain.board; + +import domain.Side; +import domain.piece.Piece; +import domain.piece.PieceFactory; +import java.util.Arrays; +import java.util.List; + +public enum Formation { + LEFT_ELEPHANT(FormationCommand.FIRST) { + @Override + public List getPieceOrders(Side side) { + return List.of( + PieceFactory.createElephant(side), + PieceFactory.createHorse(side), + PieceFactory.createElephant(side), + PieceFactory.createHorse(side) + ); + } + }, + RIGHT_ELEPHANT(FormationCommand.SECOND) { + @Override + public List getPieceOrders(Side side) { + return List.of( + PieceFactory.createHorse(side), + PieceFactory.createElephant(side), + PieceFactory.createHorse(side), + PieceFactory.createElephant(side) + ); + } + }, + OUTER_ELEPHANT(FormationCommand.THIRD) { + @Override + public List getPieceOrders(Side side) { + return List.of( + PieceFactory.createElephant(side), + PieceFactory.createHorse(side), + PieceFactory.createHorse(side), + PieceFactory.createElephant(side) + ); + } + }, + INNER_ELEPHANT(FormationCommand.FOURTH) { + @Override + public List getPieceOrders(Side side) { + return List.of( + PieceFactory.createHorse(side), + PieceFactory.createElephant(side), + PieceFactory.createElephant(side), + PieceFactory.createHorse(side) + ); + } + }, + ; + + private final FormationCommand formationCommand; + + Formation(FormationCommand formationCommand) { + this.formationCommand = formationCommand; + } + + public static Formation from(FormationCommand formationCommand) { + return Arrays.stream(values()) + .filter(formation -> formation.formationCommand.equals(formationCommand)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("올바른 배치가 아닙니다.")); + } + + public abstract List getPieceOrders(Side side); +} diff --git a/src/main/java/domain/board/FormationCommand.java b/src/main/java/domain/board/FormationCommand.java new file mode 100644 index 0000000000..86f4a6b327 --- /dev/null +++ b/src/main/java/domain/board/FormationCommand.java @@ -0,0 +1,9 @@ +package domain.board; + +public enum FormationCommand { + FIRST, + SECOND, + THIRD, + FOURTH, + ; +} diff --git a/src/main/java/domain/board/SideLayout.java b/src/main/java/domain/board/SideLayout.java new file mode 100644 index 0000000000..953d72e689 --- /dev/null +++ b/src/main/java/domain/board/SideLayout.java @@ -0,0 +1,36 @@ +package domain.board; + +import domain.Side; +import java.util.List; +import java.util.Map; + +public class SideLayout { + private static final Map LAYOUTS = Map.of( + Side.CHO, new SideLayout(0, 1, 2, 3, List.of(1, 2, 6, 7)), + Side.HAN, new SideLayout(9, 8, 7, 6, List.of(7, 6, 2, 1)) + ); + + private final int baseY; + private final int generalY; + private final int cannonY; + private final int soldierY; + private final List formationX; + + private SideLayout(int baseY, int generalY, int cannonY, int soldierY, List formationX) { + this.baseY = baseY; + this.generalY = generalY; + this.cannonY = cannonY; + this.soldierY = soldierY; + this.formationX = formationX; + } + + public static SideLayout from(Side side) { + return LAYOUTS.get(side); + } + + public int getBaseY() { return baseY; } + public int getGeneralY() { return generalY; } + public int getCannonY() { return cannonY; } + public int getSoldierY() { return soldierY; } + public List getFormationX() { return formationX; } +} diff --git a/src/main/java/domain/piece/Cannon.java b/src/main/java/domain/piece/Cannon.java new file mode 100644 index 0000000000..4f484e428a --- /dev/null +++ b/src/main/java/domain/piece/Cannon.java @@ -0,0 +1,25 @@ +package domain.piece; + +import domain.Side; +import domain.strategy.MovementStrategy; + +public class Cannon extends Piece { + public Cannon(Side side, MovementStrategy movementStrategy) { + super(side, movementStrategy); + } + + @Override + public boolean canBeBridge() { + return false; + } + + @Override + public boolean canBeCapturedByJump() { + return false; + } + + @Override + public PieceType getType() { + return PieceType.CANNON; + } +} diff --git a/src/main/java/domain/piece/Chariot.java b/src/main/java/domain/piece/Chariot.java new file mode 100644 index 0000000000..500b573864 --- /dev/null +++ b/src/main/java/domain/piece/Chariot.java @@ -0,0 +1,15 @@ +package domain.piece; + +import domain.Side; +import domain.strategy.MovementStrategy; + +public class Chariot extends Piece { + public Chariot(Side side, MovementStrategy movementStrategy) { + super(side, movementStrategy); + } + + @Override + public PieceType getType() { + return PieceType.CHARIOT; + } +} diff --git a/src/main/java/domain/piece/Elephant.java b/src/main/java/domain/piece/Elephant.java new file mode 100644 index 0000000000..d68276ab6c --- /dev/null +++ b/src/main/java/domain/piece/Elephant.java @@ -0,0 +1,15 @@ +package domain.piece; + +import domain.Side; +import domain.strategy.MovementStrategy; + +public class Elephant extends Piece { + public Elephant(Side side, MovementStrategy movementStrategy) { + super(side, movementStrategy); + } + + @Override + public PieceType getType() { + return PieceType.ELEPHANT; + } +} diff --git a/src/main/java/domain/piece/General.java b/src/main/java/domain/piece/General.java new file mode 100644 index 0000000000..aa4431e4d2 --- /dev/null +++ b/src/main/java/domain/piece/General.java @@ -0,0 +1,20 @@ +package domain.piece; + +import domain.Side; +import domain.strategy.MovementStrategy; + +public class General extends Piece { + public General(Side side, MovementStrategy movementStrategy) { + super(side, movementStrategy); + } + + @Override + public boolean isVital() { + return true; + } + + @Override + public PieceType getType() { + return PieceType.GENERAL; + } +} diff --git a/src/main/java/domain/piece/Guard.java b/src/main/java/domain/piece/Guard.java new file mode 100644 index 0000000000..aaedd0c2a7 --- /dev/null +++ b/src/main/java/domain/piece/Guard.java @@ -0,0 +1,15 @@ +package domain.piece; + +import domain.Side; +import domain.strategy.MovementStrategy; + +public class Guard extends Piece { + public Guard(Side side, MovementStrategy movementStrategy) { + super(side, movementStrategy); + } + + @Override + public PieceType getType() { + return PieceType.GUARD; + } +} diff --git a/src/main/java/domain/piece/Horse.java b/src/main/java/domain/piece/Horse.java new file mode 100644 index 0000000000..e1e3667dfe --- /dev/null +++ b/src/main/java/domain/piece/Horse.java @@ -0,0 +1,15 @@ +package domain.piece; + +import domain.Side; +import domain.strategy.MovementStrategy; + +public class Horse extends Piece { + public Horse(Side side, MovementStrategy movementStrategy) { + super(side, movementStrategy); + } + + @Override + public PieceType getType() { + return PieceType.HORSE; + } +} diff --git a/src/main/java/domain/piece/Piece.java b/src/main/java/domain/piece/Piece.java new file mode 100644 index 0000000000..210e5773fe --- /dev/null +++ b/src/main/java/domain/piece/Piece.java @@ -0,0 +1,43 @@ +package domain.piece; + +import domain.Destinations; +import domain.Position; +import domain.Side; +import domain.board.BoardReader; +import domain.strategy.MovementStrategy; + +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 abstract PieceType getType(); + + public Destinations findDestinations(Position current, BoardReader board) { + return new Destinations(movementStrategy.getMovablePositions(current, board, side)); + } + + public boolean isAlly(Side other) { + return this.side.isAlly(other); + } + + public boolean isVital() { + return false; + } + + public boolean canBeBridge() { + return true; + } + + public boolean canBeCapturedByJump() { + return true; + } + + public Side getSide() { + return side; + } +} diff --git a/src/main/java/domain/piece/PieceFactory.java b/src/main/java/domain/piece/PieceFactory.java new file mode 100644 index 0000000000..407cf9ef12 --- /dev/null +++ b/src/main/java/domain/piece/PieceFactory.java @@ -0,0 +1,59 @@ +package domain.piece; + +import domain.Side; +import domain.strategy.JumpStrategy; +import domain.strategy.MovePattern; +import domain.strategy.MovementStrategy; +import domain.strategy.OneStepStrategy; +import domain.strategy.SequenceStrategy; +import domain.strategy.SlideStrategy; +import java.util.Map; + +public class PieceFactory { + private static final MovementStrategy LINEAR_ONE_STEP = new OneStepStrategy(MovePattern.LINEAR); + private static final MovementStrategy CHO_SOLDIER_STRATEGY = new OneStepStrategy(MovePattern.CHO_SOLDIER); + private static final MovementStrategy HAN_SOLDIER_STRATEGY = new OneStepStrategy(MovePattern.HAN_SOLDIER); + private static final MovementStrategy HORSE_STRATEGY = new SequenceStrategy(MovePattern.HORSE); + private static final MovementStrategy ELEPHANT_STRATEGY = new SequenceStrategy(MovePattern.ELEPHANT); + private static final MovementStrategy CONTINUOUS_STRATEGY = new SlideStrategy(MovePattern.LINEAR); + private static final MovementStrategy JUMP_STRATEGY = new JumpStrategy(MovePattern.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, CHO_SOLDIER_STRATEGY), + Side.HAN, new Soldier(Side.HAN, HAN_SOLDIER_STRATEGY) + ); + 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, JUMP_STRATEGY), + Side.HAN, new Cannon(Side.HAN, JUMP_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); } +} diff --git a/src/main/java/domain/piece/PieceType.java b/src/main/java/domain/piece/PieceType.java new file mode 100644 index 0000000000..7ec48d3daa --- /dev/null +++ b/src/main/java/domain/piece/PieceType.java @@ -0,0 +1,12 @@ +package domain.piece; + +public enum PieceType { + SOLDIER, + CHARIOT, + HORSE, + ELEPHANT, + GUARD, + CANNON, + GENERAL, + ; +} diff --git a/src/main/java/domain/piece/Soldier.java b/src/main/java/domain/piece/Soldier.java new file mode 100644 index 0000000000..1884235e22 --- /dev/null +++ b/src/main/java/domain/piece/Soldier.java @@ -0,0 +1,15 @@ +package domain.piece; + +import domain.Side; +import domain.strategy.MovementStrategy; + +public class Soldier extends Piece { + public Soldier(Side side, MovementStrategy movementStrategy) { + super(side, movementStrategy); + } + + @Override + public PieceType getType() { + return PieceType.SOLDIER; + } +} 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..6f0ec9b044 --- /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() != 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..9535973c5c --- /dev/null +++ b/src/main/java/domain/player/Players.java @@ -0,0 +1,67 @@ +package domain.player; + +import domain.Side; +import domain.piece.Piece; +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 = 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 getCurrentPlayer() { + return players.stream() + .filter(Player::isCurrentTurn) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("현재 턴인 플레이어가 없습니다.")); + } + + public Player getWaitingPlayer() { + 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); + } + + public String getPlayerNameBySide(Side side) { + return players.stream() + .filter(player -> player.getSide() == side) + .map(Player::getName) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("해당 진영의 플레이어가 없습니다.")); + } + + public void validateAlly(Piece piece) { + getCurrentPlayer().validateAlly(piece); + } +} diff --git a/src/main/java/domain/state/ActiveTurn.java b/src/main/java/domain/state/ActiveTurn.java new file mode 100644 index 0000000000..c595bc9cc0 --- /dev/null +++ b/src/main/java/domain/state/ActiveTurn.java @@ -0,0 +1,15 @@ +package domain.state; + +public class ActiveTurn implements TurnState { + public static final TurnState INSTANCE = new ActiveTurn(); + + @Override + public boolean isCurrent() { + return true; + } + + @Override + public TurnState next() { + return TurnState.INACTIVE; + } +} diff --git a/src/main/java/domain/state/InactiveTurn.java b/src/main/java/domain/state/InactiveTurn.java new file mode 100644 index 0000000000..8bdefd884a --- /dev/null +++ b/src/main/java/domain/state/InactiveTurn.java @@ -0,0 +1,15 @@ +package domain.state; + +public class InactiveTurn implements TurnState { + public static final TurnState INSTANCE = new InactiveTurn(); + + @Override + public boolean isCurrent() { + return false; + } + + @Override + public TurnState next() { + return TurnState.ACTIVE; + } +} diff --git a/src/main/java/domain/state/TurnState.java b/src/main/java/domain/state/TurnState.java new file mode 100644 index 0000000000..31c00dcf8d --- /dev/null +++ b/src/main/java/domain/state/TurnState.java @@ -0,0 +1,9 @@ +package domain.state; + +public interface TurnState { + TurnState ACTIVE = ActiveTurn.INSTANCE; + TurnState INACTIVE = InactiveTurn.INSTANCE; + + boolean isCurrent(); + TurnState next(); +} diff --git a/src/main/java/domain/strategy/Direction.java b/src/main/java/domain/strategy/Direction.java new file mode 100644 index 0000000000..47f16899ce --- /dev/null +++ b/src/main/java/domain/strategy/Direction.java @@ -0,0 +1,28 @@ +package domain.strategy; + +public enum Direction { + NORTH(0, 1), + SOUTH(0, -1), + EAST(1, 0), + WEST(-1, 0), + NORTH_EAST(1, 1), + NORTH_WEST(-1, 1), + SOUTH_EAST(1, -1), + SOUTH_WEST(-1, -1); + + private final int deltaX; + private final int deltaY; + + Direction(int deltaX, int deltaY) { + this.deltaX = deltaX; + this.deltaY = deltaY; + } + + public int getDeltaX() { + return deltaX; + } + + public int getDeltaY() { + return deltaY; + } +} diff --git a/src/main/java/domain/strategy/JumpStrategy.java b/src/main/java/domain/strategy/JumpStrategy.java new file mode 100644 index 0000000000..22badb3fad --- /dev/null +++ b/src/main/java/domain/strategy/JumpStrategy.java @@ -0,0 +1,54 @@ +package domain.strategy; + +import domain.Position; +import domain.Side; +import domain.board.BoardReader; +import domain.piece.Piece; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class JumpStrategy implements MovementStrategy { + private final List defaultDirections; + + public JumpStrategy(List defaultDirections) { + this.defaultDirections = defaultDirections; + } + + @Override + public List generatePaths(Position current) { + return defaultDirections.stream() + .filter(current::canMove) + .map(direction -> createPath(current, direction)) + .toList(); + } + + @Override + public List getMovablePositions(Position current, BoardReader board, Side side) { + return generatePaths(current).stream() + .flatMap(path -> getReachablePositions(path, board, side).stream()) + .toList(); + } + + private List getReachablePositions(Path path, BoardReader board, Side side) { + return path.findFirst(pos -> !board.isEmpty(pos)) + .filter(bridge -> board.getPiece(bridge).canBeBridge()) + .map(path::after) + .map(afterBridge -> collectAfterBridge(afterBridge, board, side)) + .orElse(List.of()); + } + + private List collectAfterBridge(Path afterBridge, BoardReader board, Side side) { + Path emptyPath = afterBridge.takeWhile(board::isEmpty); + Optional target = afterBridge.findFirst(pos -> !board.isEmpty(pos)); + List reachable = new ArrayList<>(emptyPath.toList()); + target.filter(position -> isCatchable(position, board, side)) + .ifPresent(reachable::add); + return reachable; + } + + private boolean isCatchable(Position position, BoardReader board, Side side) { + Piece piece = board.getPiece(position); + return !piece.isAlly(side) && piece.canBeCapturedByJump(); + } +} diff --git a/src/main/java/domain/strategy/MovePattern.java b/src/main/java/domain/strategy/MovePattern.java new file mode 100644 index 0000000000..e11dd40c6e --- /dev/null +++ b/src/main/java/domain/strategy/MovePattern.java @@ -0,0 +1,36 @@ +package domain.strategy; + +import java.util.List; + +public class MovePattern { + public static final List LINEAR = List.of( + Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST + ); + + public static final List EVERY = List.of( + Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST, + Direction.NORTH_EAST, Direction.NORTH_WEST, Direction.SOUTH_EAST, Direction.SOUTH_WEST + ); + + public static final List CHO_SOLDIER = List.of( + Direction.NORTH, Direction.EAST, Direction.WEST + ); + + public static final List HAN_SOLDIER = List.of( + Direction.SOUTH, Direction.EAST, Direction.WEST + ); + + public static final List> HORSE = List.of( + List.of(Direction.NORTH, Direction.NORTH_WEST), List.of(Direction.NORTH, Direction.NORTH_EAST), + List.of(Direction.SOUTH, Direction.SOUTH_WEST), List.of(Direction.SOUTH, Direction.SOUTH_EAST), + List.of(Direction.EAST, Direction.NORTH_EAST), List.of(Direction.EAST, Direction.SOUTH_EAST), + List.of(Direction.WEST, Direction.NORTH_WEST), List.of(Direction.WEST, Direction.SOUTH_WEST) + ); + + public static final List> ELEPHANT = List.of( + List.of(Direction.NORTH, Direction.NORTH_WEST, Direction.NORTH_WEST), List.of(Direction.NORTH, Direction.NORTH_EAST, Direction.NORTH_EAST), + List.of(Direction.SOUTH, Direction.SOUTH_WEST, Direction.SOUTH_WEST), List.of(Direction.SOUTH, Direction.SOUTH_EAST, Direction.SOUTH_EAST), + List.of(Direction.EAST, Direction.NORTH_EAST, Direction.NORTH_EAST), List.of(Direction.EAST, Direction.SOUTH_EAST, Direction.SOUTH_EAST), + List.of(Direction.WEST, Direction.NORTH_WEST, Direction.NORTH_WEST), List.of(Direction.WEST, Direction.SOUTH_WEST, Direction.SOUTH_WEST) + ); +} diff --git a/src/main/java/domain/strategy/MovementStrategy.java b/src/main/java/domain/strategy/MovementStrategy.java new file mode 100644 index 0000000000..eba4bbba24 --- /dev/null +++ b/src/main/java/domain/strategy/MovementStrategy.java @@ -0,0 +1,22 @@ +package domain.strategy; + +import domain.Position; +import domain.Side; +import domain.board.BoardReader; +import java.util.ArrayList; +import java.util.List; + +public interface MovementStrategy { + List generatePaths(Position current); + List getMovablePositions(Position current, BoardReader board, Side side); + + default Path createPath(Position current, Direction direction) { + List positions = new ArrayList<>(); + Position position = current; + while (position.canMove(direction)) { + position = position.move(direction); + positions.add(position); + } + return new Path(positions); + } +} diff --git a/src/main/java/domain/strategy/OneStepStrategy.java b/src/main/java/domain/strategy/OneStepStrategy.java new file mode 100644 index 0000000000..2600692037 --- /dev/null +++ b/src/main/java/domain/strategy/OneStepStrategy.java @@ -0,0 +1,30 @@ +package domain.strategy; + +import domain.Position; +import domain.Side; +import domain.board.BoardReader; +import java.util.List; + +public class OneStepStrategy implements MovementStrategy { + private final List defaultDirections; + + public OneStepStrategy(List defaultDirections) { + this.defaultDirections = defaultDirections; + } + + @Override + public List getMovablePositions(Position current, BoardReader board, Side side) { + return generatePaths(current).stream() + .map(Path::getDestination) + .filter(destination -> board.isEmpty(destination) || !board.isAlly(destination, side)) + .toList(); + } + + @Override + public List generatePaths(Position current) { + return defaultDirections.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..3e1e4e3550 --- /dev/null +++ b/src/main/java/domain/strategy/Path.java @@ -0,0 +1,60 @@ +package domain.strategy; + +import domain.Position; +import domain.board.BoardReader; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; + +public class Path { + public static final int NOT_FOUND = -1; + private static final int SINGLE_STEP_SIZE = 1; + public static final int START_INDEX = 0; + public static final int LAST_INDEX_OFFSET = 1; + private final List steps; + + public Path(List steps) { + this.steps = List.copyOf(steps); + } + + public Path takeWhile(Predicate condition) { + List result = steps.stream() + .takeWhile(condition) + .toList(); + return new Path(result); + } + + public Optional findFirst(Predicate condition) { + return steps.stream() + .filter(condition) + .findFirst(); + } + + public Path after(Position target) { + int index = steps.indexOf(target); + if (index == NOT_FOUND || index == getLastIndex()) { + return new Path(List.of()); + } + return new Path(steps.subList(index + LAST_INDEX_OFFSET, steps.size())); + } + + public boolean isBlocked(BoardReader board) { + if (steps.size() <= SINGLE_STEP_SIZE) { + return false; + } + return steps.subList(START_INDEX, getLastIndex()).stream() + .anyMatch(pos -> !board.isEmpty(pos)); + } + + public Position getDestination() { + return steps.getLast(); + } + + public List toList() { + return steps; + } + + private int getLastIndex() { + return steps.size() - LAST_INDEX_OFFSET; + } +} diff --git a/src/main/java/domain/strategy/SequenceStrategy.java b/src/main/java/domain/strategy/SequenceStrategy.java new file mode 100644 index 0000000000..a9ffe7eec8 --- /dev/null +++ b/src/main/java/domain/strategy/SequenceStrategy.java @@ -0,0 +1,42 @@ +package domain.strategy; + +import domain.Position; +import domain.Side; +import domain.board.BoardReader; +import java.util.ArrayList; +import java.util.List; + +public class SequenceStrategy implements MovementStrategy { + private final List> defaultSequences; + + public SequenceStrategy(List> defaultSequences) { + this.defaultSequences = defaultSequences; + } + + @Override + public List getMovablePositions(Position current, BoardReader board, Side side) { + return generatePaths(current).stream() + .filter(path -> !path.isBlocked(board)) + .map(Path::getDestination) + .filter(dest -> board.isEmpty(dest) || board.isAlly(dest, side)) + .toList(); + } + + @Override + public List generatePaths(Position current) { + return defaultSequences.stream() + .filter(current::canMove) + .map(sequence -> createPath(current, sequence)) + .toList(); + } + + private Path createPath(Position current, List sequence) { + List positions = new ArrayList<>(); + Position position = current; + for (Direction direction : sequence) { + position = position.move(direction); + positions.add(position); + } + return new Path(positions); + } +} diff --git a/src/main/java/domain/strategy/SlideStrategy.java b/src/main/java/domain/strategy/SlideStrategy.java new file mode 100644 index 0000000000..2835a7eb9a --- /dev/null +++ b/src/main/java/domain/strategy/SlideStrategy.java @@ -0,0 +1,40 @@ +package domain.strategy; + +import domain.Position; +import domain.Side; +import domain.board.BoardReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class SlideStrategy implements MovementStrategy { + private final List defaultDirections; + + public SlideStrategy(List defaultDirections) { + this.defaultDirections = defaultDirections; + } + + @Override + public List getMovablePositions(Position current, BoardReader board, Side side) { + return generatePaths(current).stream() + .flatMap(path -> getReachablePositions(path, board, side).stream()) + .toList(); + } + + @Override + public List generatePaths(Position current) { + return defaultDirections.stream() + .filter(current::canMove) + .map(direction -> createPath(current, direction)) + .toList(); + } + + private List getReachablePositions(Path path, BoardReader board, Side side) { + List reachable = new ArrayList<>(path.takeWhile(board::isEmpty).toList()); + Optional obstacle = path.findFirst(pos -> !board.isEmpty(pos)); + + obstacle.filter(pos -> !board.getPiece(pos).isAlly(side)) + .ifPresent(reachable::add); + return reachable; + } +} diff --git a/src/main/java/dto/BoardDto.java b/src/main/java/dto/BoardDto.java new file mode 100644 index 0000000000..37fe6edef0 --- /dev/null +++ b/src/main/java/dto/BoardDto.java @@ -0,0 +1,17 @@ +package dto; + +import domain.Position; +import domain.piece.Piece; +import java.util.Map; +import java.util.stream.Collectors; + +public record BoardDto(Map board) { + public static BoardDto from(Map board) { + Map dtoMap = board.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + entry -> PositionDto.from(entry.getKey()), + entry -> PieceDto.from(entry.getValue()) + )); + return new BoardDto(dtoMap); + } +} diff --git a/src/main/java/dto/DestinationDto.java b/src/main/java/dto/DestinationDto.java new file mode 100644 index 0000000000..e9b8887a01 --- /dev/null +++ b/src/main/java/dto/DestinationDto.java @@ -0,0 +1,17 @@ +package dto; + +import domain.MoveCandidate; +import java.util.List; + +public record DestinationDto(List positions) { + + public static DestinationDto from(MoveCandidate moveCandidate) { + return new DestinationDto( + moveCandidate.destinations() + .getPositions() + .stream() + .map(PositionDto::from) + .toList() + ); + } +} diff --git a/src/main/java/dto/PieceDto.java b/src/main/java/dto/PieceDto.java new file mode 100644 index 0000000000..4892f4bc6e --- /dev/null +++ b/src/main/java/dto/PieceDto.java @@ -0,0 +1,12 @@ +package dto; + +import domain.piece.Piece; + +public record PieceDto(String type, String side) { + public static PieceDto from(Piece piece) { + return new PieceDto( + piece.getType().name(), + piece.getSide().name() + ); + } +} diff --git a/src/main/java/dto/PositionDto.java b/src/main/java/dto/PositionDto.java new file mode 100644 index 0000000000..3caf121a22 --- /dev/null +++ b/src/main/java/dto/PositionDto.java @@ -0,0 +1,9 @@ +package dto; + +import domain.Position; + +public record PositionDto(int x, int y) { + public static PositionDto from(Position position) { + return new PositionDto(position.getX(), position.getY()); + } +} diff --git a/src/main/java/view/InputParser.java b/src/main/java/view/InputParser.java new file mode 100644 index 0000000000..33f8a6b56f --- /dev/null +++ b/src/main/java/view/InputParser.java @@ -0,0 +1,59 @@ +package view; + +import domain.Position; +import domain.board.FormationCommand; +import domain.player.Name; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +public class InputParser { + private static final Pattern COMMA_SEPARATED_COORDINATES = Pattern.compile("^\\s*\\d+\\s*,\\s*\\d+\\s*$"); + private static final String DELIMITER = ","; + private static final Map FORMATION_MAP = Map.of( + "1", FormationCommand.FIRST, + "2", FormationCommand.SECOND, + "3", FormationCommand.THIRD, + "4", FormationCommand.FOURTH + ); + + public static Name parseName(String input) { + validateBlank(input); + return new Name(input.strip()); + } + + public static FormationCommand parseFormation(String input) { + validateBlank(input); + String strippedInput = input.strip(); + + if (!FORMATION_MAP.containsKey(strippedInput)) { + throw new IllegalArgumentException("올바른 포메이션 입력이 아닙니다. (1~4 사이의 숫자)"); + } + return FORMATION_MAP.get(strippedInput); + } + + public static Position parsePosition(String input) { + validateBlank(input); + if (!COMMA_SEPARATED_COORDINATES.matcher(input).matches()) { + throw new IllegalArgumentException("잘못된 입력 형식입니다. ex) 0,3"); + } + + return getPosition(input); + } + + private static Position getPosition(String input) { + List coordinate = Arrays.stream(input.split(DELIMITER)) + .map(String::strip) + .map(Integer::parseInt) + .toList(); + + return Position.of(coordinate.get(0), coordinate.get(1)); + } + + private static void validateBlank(String input) { + if (input == null || input.isBlank()) { + 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..35a086691a --- /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() + "나라 플레이어 차례입니다. 이동 시킬 기물의 위치를 입력하세요. ex) 0, 3"); + return scanner.nextLine(); + } + + public String readTargetPosition() { + System.out.println("이동할 위치를 입력하세요. ex) 0, 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..a639836690 --- /dev/null +++ b/src/main/java/view/OutputView.java @@ -0,0 +1,61 @@ +package view; + +import dto.BoardDto; +import dto.DestinationDto; +import dto.PieceDto; +import dto.PositionDto; +import java.util.List; +import java.util.stream.IntStream; + +public class OutputView { + + 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(BoardDto boardDto) { + System.out.println(); + for (int y = 9; y >= 0; y--) { + System.out.println(buildRow(boardDto, 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(BoardDto boardDto, int y) { + return SPACE + NUMBERS.get(y) + SPACE + IntStream.rangeClosed(0, 8) + .mapToObj(x -> formatCell(boardDto.board().get(new PositionDto(x, y)))) + .reduce("", String::concat); + } + + private String formatCell(PieceDto pieceDto) { + if (pieceDto == null) { + return EMPTY + SPACE; + } + return PieceFormatter.format(pieceDto) + SPACE; + } + + public void printError(String message) { + System.out.println(message); + } + + public void printDestinations(DestinationDto destinations) { + String result = destinations.positions().stream() + .map(pos -> String.format("(%d, %d)", pos.x(), pos.y())) + .reduce((a, b) -> a + ", " + b) + .orElse(""); + System.out.println(result); + } + + public void printWinner(String winnerName) { + System.out.printf("%s(이/가) 승리했습니다.%n", winnerName); + } +} diff --git a/src/main/java/view/PieceFormatter.java b/src/main/java/view/PieceFormatter.java new file mode 100644 index 0000000000..fb74d05704 --- /dev/null +++ b/src/main/java/view/PieceFormatter.java @@ -0,0 +1,49 @@ +package view; + +import dto.PieceDto; +import java.util.Map; + +public class PieceFormatter { + private static final String RED = "\u001B[31m"; + private static final String BLUE = "\u001B[34m"; + private static final String RESET = "\u001B[0m"; + + private static final Map SYMBOLS = Map.of( + "GENERAL", "궁", + "CHARIOT", "차", + "CANNON", "포", + "HORSE", "마", + "ELEPHANT", "상", + "GUARD", "사", + "SOLDIER", "졸" + ); + + public static String format(PieceDto pieceDto) { + String name = getName(pieceDto); + String color = getColor(pieceDto.side()); + + return color + name + RESET; + } + + private static String getName(PieceDto pieceDto) { + String type = pieceDto.type(); + String side = pieceDto.side(); + + if ("SOLDIER".equals(type) && "HAN".equals(side)) { + return "병"; + } + + if (!SYMBOLS.containsKey(type)) { + throw new IllegalArgumentException("지원하지 않는 기물 타입입니다: " + type); + } + + return SYMBOLS.get(type); + } + + private static String getColor(String side) { + if ("HAN".equals(side)) { + return RED; + } + return BLUE; + } +} 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..b08fad6b9b --- /dev/null +++ b/src/test/java/domain/GameTest.java @@ -0,0 +1,83 @@ +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.board.FormationCommand; +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.from(FormationCommand.FIRST), Formation.from(FormationCommand.FIRST)); + 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 + MoveCandidate candidate = game.selectSource(source); + game.move(candidate, 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(game.selectSource(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..6d63f0eb64 --- /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, 8}) + void x의_값은_0_이상_8_이하여야_한다(int validX) { + int y = 0; + assertThatCode(() -> Position.of(validX, y)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(ints = {0, 9}) + void y의_값은_0_이상_9_이하여야_한다(int validY) { + int x = 0; + assertThatCode(() -> Position.of(x, validY)) + .doesNotThrowAnyException(); + } + + + @ParameterizedTest + @ValueSource(ints = {-1, 10}) + void x_좌표가_0_미만이거나_8_초과이면_예외가_발생한다(int invalidX) { + int y = 0; + assertThatThrownBy(() -> Position.of(invalidX, y)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("범위를 벗어난 좌표입니다"); + } + + @ParameterizedTest + @ValueSource(ints = {-1, 11}) + void y_좌표가_0_미만이거나_9_초과이면_예외가_발생한다(int invalidY) { + int x = 0; + assertThatThrownBy(() -> Position.of(x, invalidY)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("범위를 벗어난 좌표입니다"); + } +} 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..4bc5564e61 --- /dev/null +++ b/src/test/java/domain/board/FormationTest.java @@ -0,0 +1,34 @@ +package domain.board; + +import static org.assertj.core.api.Assertions.assertThat; + +import domain.Position; +import domain.Side; +import domain.piece.Elephant; +import domain.piece.Horse; +import domain.piece.Piece; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class FormationTest { + + @Test + void 선택값으로_포메이션을_찾는다() { + assertThat(Formation.from(FormationCommand.FIRST)).isEqualTo(Formation.LEFT_ELEPHANT); + assertThat(Formation.from(FormationCommand.SECOND)).isEqualTo(Formation.RIGHT_ELEPHANT); + assertThat(Formation.from(FormationCommand.THIRD)).isEqualTo(Formation.OUTER_ELEPHANT); + assertThat(Formation.from(FormationCommand.FOURTH)).isEqualTo(Formation.INNER_ELEPHANT); + } + + @Test + void LEFT_ELEPHANT은_상마상마로_배치한다() { + List pieces = Formation.LEFT_ELEPHANT.getPieceOrders(Side.CHO); + + assertThat(pieces.get(0)).isInstanceOf(Elephant.class); + assertThat(pieces.get(1)).isInstanceOf(Horse.class); + assertThat(pieces.get(2)).isInstanceOf(Elephant.class); + assertThat(pieces.get(3)).isInstanceOf(Horse.class); + } +} 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..bd56131170 --- /dev/null +++ b/src/test/java/domain/piece/SoldierTest.java @@ -0,0 +1,50 @@ +package domain.piece; + +import static org.assertj.core.api.Assertions.assertThat; + +import domain.Position; +import domain.Side; +import domain.board.Board; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class SoldierTest { + + @Test + @DisplayName("초나라 졸은 상(Y 증가), 좌, 우로 한 칸씩 이동할 수 있다") + void moveChoSoldier() { + // given + Position source = Position.of(4, 3); + Board board = new Board(Map.of(source, PieceFactory.createSoldier(Side.CHO))); + + // when + List destinations = board.findDestinations(source).getPositions(); + + // then + assertThat(destinations).containsExactlyInAnyOrder( + Position.of(4, 4), // 상 + Position.of(3, 3), // 좌 + Position.of(5, 3) // 우 + ); + } + + @Test + @DisplayName("한나라 졸은 하(Y 감소), 좌, 우로 한 칸씩 이동할 수 있다") + void moveHanSoldier() { + // given + Position source = Position.of(4, 6); + Board board = new Board(Map.of(source, PieceFactory.createSoldier(Side.HAN))); + + // when + List destinations = board.findDestinations(source).getPositions(); + + // then + assertThat(destinations).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..f0f4c093ed --- /dev/null +++ b/src/test/java/domain/player/NameTest.java @@ -0,0 +1,35 @@ +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) + .hasMessageContaining("이름은 빈 값이 될 수 없습니다."); + } + + @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) + .hasMessageContaining("이름은 2~5글자 사이여야 합니다."); + } +} diff --git a/src/test/java/domain/player/PlayerTest.java b/src/test/java/domain/player/PlayerTest.java new file mode 100644 index 0000000000..b98a377372 --- /dev/null +++ b/src/test/java/domain/player/PlayerTest.java @@ -0,0 +1,55 @@ +package domain.player; + +import static org.assertj.core.api.Assertions.*; +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: 예외 없이 통과해야 함 + assertThatCode(() -> choPlayer.validateAlly(choPiece)) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/domain/player/PlayersTest.java b/src/test/java/domain/player/PlayersTest.java new file mode 100644 index 0000000000..f6e77bb07e --- /dev/null +++ b/src/test/java/domain/player/PlayersTest.java @@ -0,0 +1,15 @@ +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) + .hasMessageContaining("동일한 플레이어 이름을 사용할 수 없습니다."); + } +} diff --git a/src/test/java/view/InputParserTest.java b/src/test/java/view/InputParserTest.java new file mode 100644 index 0000000000..e49c44cfa2 --- /dev/null +++ b/src/test/java/view/InputParserTest.java @@ -0,0 +1,43 @@ +package view; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import domain.board.FormationCommand; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class InputParserTest { + + @ParameterizedTest + @ValueSource(strings = {"0,3", "3, 9"}) + void 올바른_좌표_형식이_입력되는_경우_정상_동작한다(String input) { + assertThatCode(() -> InputParser.parsePosition(input)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "(0,3)", "(a,b)", "a,b", "0", "(,)"}) + void 위치_입력_포맷이_올바른_형태가_아니면_예외가_발생한다(String input) { + assertThatThrownBy(() -> InputParser.parsePosition(input)) + .isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @CsvSource(value = {"1:FIRST", "2:SECOND", "3:THIRD", "4:FOURTH"}, delimiter = ':') + void 포메이션_번호를_입력하면_해당하는_커맨드로_변환한다(String input, FormationCommand expected) { + assertThat(InputParser.parseFormation(input)).isEqualTo(expected); + } + + @ParameterizedTest + @ValueSource(strings = {"0", "5", "a"}) + void 올바르지_않은_포메이션_번호_입력_시_예외가_발생한다(String input) { + assertThatThrownBy(() -> InputParser.parseFormation(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("올바른 포메이션 입력이 아닙니다."); + } +}