diff --git a/.gitignore b/.gitignore index 6c01878138..0b6d420a1b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ out/ ### VS Code ### .vscode/ + +.DS_Store +**/.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index f3cec56551..6ef500eaa8 100644 --- a/README.md +++ b/README.md @@ -174,13 +174,16 @@ - [x] [규칙] 도착 위치에 같은 팀(아군) 기물이 있으면 이동할 수 없다. - [ ] [규칙] 이동 이후 자신의 '궁'이 상대에게 잡힐 수 있는 위험한 상태(장군)가 된다면, 그 이동은 허용되지 않는다. -- [x] **[Domain]** 개별 기물의 이동 규칙을 검증한다. (※ **궁성 영역 배제** 룰 적용) - - [x] [규칙] **궁/사**: (궁성을 구현하지 않으므로) 현재 위치에서 상하좌우 1칸씩만 이동 가능하다. +- [x] **[Domain]** 개별 기물의 이동 규칙을 검증한다. + - [x] [규칙] **궁/사**: 궁성 내에서 모든 방향으로 1칸 이동 가능하다. - [x] [규칙] **차**: 상하좌우로 거리 제한 없이 이동할 수 있다. 단, 이동 경로 중간에 다른 기물이 있으면 뛰어넘을 수 없다. - - [ ] [규칙] **포**: 이동 경로 사이에 반드시 다른 기물이 하나 이상 있어야 하며, 넘는 기물이 '포'이면 안 된다. 또한 도착 위치의 기물이 '포'인 경우에는 잡을 수 없다. + - [x] [규칙]: 진영 상관없이 궁성 내로 진입하는 경우, 거리 제한 없이 대각선 이동이 가능하다. + - [x] [규칙] **포**: 이동 경로 사이에 반드시 다른 기물이 하나 이상 있어야 하며, 넘는 기물이 '포'이면 안 된다. 또한 도착 위치의 기물이 '포'인 경우에는 잡을 수 없다. + - [x] [규칙]: 진영 상관없이 궁성 내로 진입하는 경우, 대각선을 포함하여 포를 제외한 기물을 뛰어넘을 수 있다. - [x] [규칙] **마**: 직진 한 칸 후 대각선 한 칸 이동한다. 직진하는 첫 칸에 기물이 있으면 이동할 수 없다 (멱). - [x] [규칙] **상**: 직진 한 칸 후 대각선으로 두 칸 이동한다. 이동 경로(직선 1칸, 대각선 1칸)가 막혀 있으면 이동할 수 없다 (멱). - [x] [규칙] **졸/병**: 한 칸씩 전진 및 좌우 이동만 가능하다. (후퇴 불가) + -[x] [규칙]: 상대방의 궁성 안으로 들어갈 경우, 대각선을 포함하여 전진 이동만 가능하다. - [x] **[UI]** 선택한 기물이 이동할 수 있는 유효한 위치 목록을 출력한다. @@ -189,17 +192,57 @@ - [x] **[Domain]** 검증을 통과하면 기물을 이동시키고 장기판을 갱신한다. ------------------------- ✂️ 1차 구현 및 PR 포인트 ------------------------ +## 4. 승패 판정 및 게임 종료 (1차 PR 이후 구현) +- [x] **[Domain]** 상대의 '궁'이 잡혔는지 판단하여 게임 종료 여부를 결정한다. + - [x] [규칙] 기물 이동 후, 한쪽의 '궁'이 보드판에서 사라졌다면 즉시 게임이 끝난다. -## 4. 승패 판정 및 게임 종료 (1차 PR 이후 구현) +- [x] **[Domain]** 현재 남아 있는 기물의 총점을 구한다. + - [x] [규칙] 궁을 잡으면 게임이 끝나기 때문에, 궁의 점수는 0으로 한다. + - [x] [규칙] 차: 13점 + - [x] [규칙] 포: 7점 + - [x] [규칙] 마: 5점 + - [x] [규칙] 상: 3점 + - [x] [규칙] 사: 3점 + - [x] [규칙] 졸/병: 2점 + +- [x] **[UI]** 최종 승패 결과를 출력한다. + - [x] [출력] 상대방의 장을 잡은 진영(플레이어)을 승자로 출력한다. + + +## 5. 데이터베이스 연동 및 영속성 관리 + +- [x] **[Infrastructure]** JDBC를 이용해 MySQL을 연동한다. `class DBConnection` + - [x] [규칙] `DB_URL`, `DB_USER`, `DB_PASSWORD`를 환경 변수로 설정해 DB를 연결한다. + +- [x] **[Persistence]** 게임 상태 및 보드 정보를 저장한다. `class JdbcJanggiRepository` + - [x] [규칙] 새로운 게임 시작 시, 플레이어 정보 및 초기화된 턴을 저장한다. `public Long save(Players players)` + - [x] [규칙] 기물 이동 시마다 현재 장기판의 상태를 업데이트한다. `public void updateGameStatus` + - [x] [규칙] 턴 변경과 보드 상태 갱신이 원자적으로 이루어지도록 트랜잭션을 보장한다. + - [x] [규칙] 종료되지 않고, 진행 중인 가장 최근의 게임 ID를 조회한다. `public Optional findInProgressGameId` + - [x] [규칙] 저장된 게임 ID를 바탕으로 이전 게임의 플레이어, 턴, 보드 상태를 복원한다. + - [x] [규칙] 승패 결정 시, 해당 게임의 종료 상태(`is_finished`)를 반영한다. `public void finishGame` + +- [x] **[Database]** 게임 영속화를 위해 데이터 스키마를 설계한다. + - [x] `game`: 게임 기본 정보 및 현재 턴, 종료 여부 관리 + - [x] `board_state`: 각 게임 ID별 기물의 종류, 진영, 위치, 고유 번호 관리 + +![db_diagram.png](images/db_diagram.png) + +## 6. 실행 방법 + +**✅ 프로그램을 실행하기 위해 로컬 환경에 `MySQL`이 설치되어 있어야 하며, 아래와 같은 데이터베이스 설정이 필요합니다.** + +1. 데이터베이스 생성: janggi_db (또는 원하는 이름) +2. 테이블 생성: `src/main/resources/schema.sql` 파일을 실행하여 필요한 테이블을 생성 -- [ ] **[Domain]** 상대의 '궁'이 잡혔는지 판단하여 게임 종료 여부를 결정한다. - - [ ] [규칙] 기물 이동 후, 한쪽의 '궁'이 보드판에서 사라졌다면 즉시 게임이 끝난다. +### 프로그램 실행을 위해 필요한 환경 변수 -- [ ] **[UI]** 최종 승패 결과를 출력한다. - - [ ] [출력] 상대방의 장을 잡은 진영(플레이어)을 승자로 출력한다. +- `DB_URL`: JDBC 연결 주소 (예: jdbc:mysql://localhost:3306/janggi_db) +- `DB_USER`: MySQL 사용자 이름 +- `DB_PASSWORD`: MySQL 비밀번호 +➡️ IntelliJ를 사용하는 경우, `Run/Debug Configurations`의 `Environment variables` 항목에서 편리하게 설정할 수 있습니다. ## 3️⃣ 입출력 요구 사항 diff --git a/build.gradle b/build.gradle index ce846f70cc..2eaadd4d15 100644 --- a/build.gradle +++ b/build.gradle @@ -9,10 +9,14 @@ repositories { } dependencies { + implementation 'com.mysql:mysql-connector-j:8.4.0' + implementation 'org.slf4j:slf4j-api:2.0.12' + implementation 'ch.qos.logback:logback-classic:1.5.3' testImplementation platform('org.junit:junit-bom:5.11.4') testImplementation platform('org.assertj:assertj-bom:3.27.3') testImplementation('org.junit.jupiter:junit-jupiter') testImplementation('org.assertj:assertj-core') + testImplementation 'com.h2database:h2:2.2.224' } java { diff --git a/images/db_diagram.png b/images/db_diagram.png new file mode 100644 index 0000000000..f350de9509 Binary files /dev/null and b/images/db_diagram.png differ diff --git a/src/main/java/janggi/Application.java b/src/main/java/janggi/Application.java index 9d8687ed81..821ca6434d 100644 --- a/src/main/java/janggi/Application.java +++ b/src/main/java/janggi/Application.java @@ -1,15 +1,12 @@ package janggi; -import janggi.view.InputView; -import janggi.view.OutputView; +import janggi.config.AppConfig; public class Application { public static void main(String[] args) { - InputView inputView = new InputView(); - OutputView outputView = new OutputView(); - JanggiGame janggiGame = new JanggiGame(outputView, inputView); - janggiGame.run(); - inputView.close(); + AppConfig appConfig = new AppConfig(); + GameRunner gameRunner = appConfig.gameRunner(); + gameRunner.run(); } } diff --git a/src/main/java/janggi/GameRunner.java b/src/main/java/janggi/GameRunner.java new file mode 100644 index 0000000000..dda0995dce --- /dev/null +++ b/src/main/java/janggi/GameRunner.java @@ -0,0 +1,88 @@ +package janggi; + +import janggi.domain.board.Board; +import janggi.domain.game.Players; +import janggi.domain.game.PlayersSaveDTO; +import janggi.domain.game.Side; +import janggi.domain.repository.JanggiRepository; +import janggi.view.InputView; +import janggi.view.OutputView; +import java.util.Optional; +import java.util.function.Supplier; + +public class GameRunner { + private final OutputView outputView; + private final InputView inputView; + private final JanggiRepository repository; + + public GameRunner(OutputView outputView, InputView inputView, JanggiRepository repository) { + this.outputView = outputView; + this.inputView = inputView; + this.repository = repository; + } + + public void run() { + Optional lastGameId = repository.findInProgressGameId(); + + if (lastGameId.isPresent() && isContinued()) { + continuallyPlay(lastGameId.get()); + return; + } + + startNewGame(); + inputView.close(); + } + + private void startNewGame() { + Players players = initialPlayers(); + Long gameId = repository.save(PlayersSaveDTO.from(players)); + Board board = Board.initialize(); + + new JanggiGame(outputView, inputView, repository, board, gameId).play(players); + } + + private void continuallyPlay(Long id) { + Board board = repository.findBoardById(id); + Players players = repository.findPlayersById(id); + outputView.printResumeNotice(); + + new JanggiGame(outputView, inputView, repository, board, id).play(players); + } + + private Players initialPlayers() { + return retry(() -> { + String choPlayerName = readPlayerName(Side.CHO); + String hanPlayerName = readPlayerName(Side.HAN); + return Players.createInitial(choPlayerName, hanPlayerName); + }); + } + + private String readPlayerName(Side side) { + outputView.printPlayerNameNotice(side.getDisplayName()); + return inputView.readPlayerName(); + } + + private boolean isContinued() { + return retry(() -> { + outputView.printContinueGameNotice(); + return inputView.readContinueAnswer(); + }); + } + + private T retry(Supplier supplier) { + Optional result = Optional.empty(); + while (result.isEmpty()) { + result = tryOnce(supplier); + } + return result.get(); + } + + private Optional tryOnce(Supplier supplier) { + try { + return Optional.of(supplier.get()); + } catch (IllegalArgumentException e) { + outputView.printLine(e.getMessage()); + return Optional.empty(); + } + } +} diff --git a/src/main/java/janggi/JanggiGame.java b/src/main/java/janggi/JanggiGame.java index 469eeca69f..a419d27033 100644 --- a/src/main/java/janggi/JanggiGame.java +++ b/src/main/java/janggi/JanggiGame.java @@ -2,15 +2,20 @@ import janggi.domain.board.Board; import janggi.domain.board.PieceSelection; +import janggi.domain.game.Player; +import janggi.domain.game.PlayerResultDTO; import janggi.domain.game.Players; import janggi.domain.board.Position; import janggi.domain.game.Side; import janggi.domain.board.BoardDTO; import janggi.domain.game.PlayerDTO; +import janggi.domain.repository.JanggiRepository; import janggi.view.InputView; import janggi.view.OutputView; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.function.Supplier; public class JanggiGame { @@ -18,24 +23,36 @@ public class JanggiGame { private final OutputView outputView; private final InputView inputView; private final Board board; + private final JanggiRepository repository; + private final Long gameId; - public JanggiGame(OutputView outputView, InputView inputView) { + public JanggiGame(OutputView outputView, InputView inputView, JanggiRepository repository, Board board, + Long gameId) { this.outputView = outputView; this.inputView = inputView; - this.board = Board.initialize(); + this.repository = repository; + this.board = board; + this.gameId = gameId; } - public void run() { - Players players = initialPlayers(); + public void play(Players players) { printBoard(); - play(players); - } - private Players initialPlayers() { - String choPlayerName = readPlayerName(Side.CHO); - String hanPlayerName = readPlayerName(Side.HAN); + while (true) { + Player current = players.getCurrentPlayer(); + PlayerDTO currentPlayerDTO = PlayerDTO.from(current); + printPlayerTurnNotice(currentPlayerDTO); + + playerTurn(currentPlayerDTO); + + if (board.isGameOver()) { + handleGameOver(currentPlayerDTO, players); + break; + } - return Players.of(choPlayerName, hanPlayerName); + players.switchTurn(); + repository.updateGameStatus(this.gameId, board, players.getTurn()); + } } private void printBoard() { @@ -43,21 +60,24 @@ private void printBoard() { outputView.printBoardStatus(BoardDTO.from(board)); } - private void play(Players players) { - while (true) { - PlayerDTO currentPlayer = players.getCurrentPlayer(); - printPlayerTurnNotice(currentPlayer); - playerTurn(currentPlayer); - players.switchTurn(); - } - } - private void playerTurn(PlayerDTO currentPlayer) { Side currentSide = currentPlayer.side(); PieceSelection pieceSelection = selectMovablePiece(currentSide); movePiece(pieceSelection.selected(), pieceSelection.destinations()); } + private void handleGameOver(PlayerDTO winner, Players players) { + repository.finishGame(this.gameId); + outputView.printWinnerNotice(winner.side(), winner.name()); + + List playerResults = new ArrayList<>(); + for (Player player : players) { + double score = board.calculateScore(player.getSide()); + playerResults.add(new PlayerResultDTO(player.getName(), player.getSide(), score)); + } + outputView.printTotalScores(playerResults); + } + private PieceSelection selectMovablePiece(Side currentSide) { return retry(() -> { outputView.printSelectPiecePosition(); @@ -99,31 +119,24 @@ private Position readTargetPosition() { }); } - private String readPlayerName(Side side) { - return retry(() -> { - outputView.printPlayerNameNotice(side.getDisplayName()); - return inputView.readPlayerName(); - }); - } - private void printPlayerTurnNotice(PlayerDTO currentPlayer) { outputView.printPlayerTurnNotice(currentPlayer.name(), currentPlayer.side().getDisplayName()); } private T retry(Supplier supplier) { - T result = null; - while (result == null) { + Optional result = Optional.empty(); + while (result.isEmpty()) { result = tryOnce(supplier); } - return result; + return result.get(); } - private T tryOnce(Supplier supplier) { + private Optional tryOnce(Supplier supplier) { try { - return supplier.get(); + return Optional.of(supplier.get()); } catch (IllegalArgumentException e) { outputView.printLine(e.getMessage()); - return null; + return Optional.empty(); } } } diff --git a/src/main/java/janggi/config/AppConfig.java b/src/main/java/janggi/config/AppConfig.java new file mode 100644 index 0000000000..38fb9ba188 --- /dev/null +++ b/src/main/java/janggi/config/AppConfig.java @@ -0,0 +1,53 @@ +package janggi.config; + +import com.mysql.cj.jdbc.MysqlDataSource; +import janggi.GameRunner; +import janggi.domain.repository.JanggiRepository; +import janggi.infrastructure.JdbcJanggiRepository; +import janggi.view.InputView; +import janggi.view.OutputView; +import java.sql.SQLException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import javax.sql.DataSource; + +public class AppConfig { + private static final Logger logger = LoggerFactory.getLogger(AppConfig.class); + + private static final String DB_URL = "DB_URL"; + private static final String DB_USER = "DB_USER"; + private static final String DB_PASSWORD = "DB_PASSWORD"; + + public GameRunner gameRunner() { + return new GameRunner(outputView(), inputView(), janggiRepository()); + } + + private InputView inputView() { + return new InputView(); + } + + private OutputView outputView() { + return new OutputView(); + } + + private JanggiRepository janggiRepository() { + return new JdbcJanggiRepository(dataSource()); + } + + private DataSource dataSource() { + MysqlDataSource dataSource = new MysqlDataSource(); + + dataSource.setURL(System.getenv(DB_URL)); + dataSource.setUser(System.getenv(DB_USER)); + dataSource.setPassword(System.getenv(DB_PASSWORD)); + try { + logger.info("DB 연결을 시작합니다. URL : {}", System.getenv(DB_URL)); + dataSource.getConnection().close(); + logger.info("DB 연결에 성공했습니다."); + return dataSource; + } catch (SQLException e) { + logger.error("DB 연결 중, 에러가 발생했습니다. 원인: {}", e.getMessage(), e); + throw new RuntimeException("서비스 연결에 문제가 발생했습니다. 잠시 후 다시 시도해 주세요."); + } + } +} diff --git a/src/main/java/janggi/domain/board/Board.java b/src/main/java/janggi/domain/board/Board.java index 58f4bcdf6a..6328cf75b3 100644 --- a/src/main/java/janggi/domain/board/Board.java +++ b/src/main/java/janggi/domain/board/Board.java @@ -8,6 +8,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; public class Board { private static final String ERROR_NOT_FOUND_PIECE = "[ERROR] 해당 위치에 기물이 없습니다."; @@ -44,12 +45,19 @@ public class Board { private static final String ID_FIRST = "0"; private static final String ID_SECOND = "1"; + // 점수 계산 + private static final double HAN_BONUS_SCORE = 1.5; + private final Map piecePosition; private Board(Map piecePosition) { this.piecePosition = new HashMap<>(piecePosition); } + public static Board from(Map piecePosition) { + return new Board(piecePosition); + } + public static Board initialize() { Map initialBoard = new HashMap<>(); @@ -90,7 +98,7 @@ private static void initMajorPieces(Map initialBoard, Side side } private static void initPalaceAndCannons(Map initialBoard, Side side, int palaceRow, int cannonRow) { - put(initialBoard, palaceRow, PALACE_COL, side, PieceType.PALACE, ID_FIRST); + put(initialBoard, palaceRow, PALACE_COL, side, PieceType.GENERAL, ID_FIRST); put(initialBoard, cannonRow, ELEPHANT_LEFT, side, PieceType.CANNON, ID_FIRST); put(initialBoard, cannonRow, ELEPHANT_RIGHT, side, PieceType.CANNON, ID_SECOND); } @@ -106,7 +114,7 @@ private static void put(Map initialBoard, int row, int column, initialBoard.put(new Position(row, column), new Piece(side, pieceType, pieceNumber)); } - Map getPiecePosition() { + public Map getPiecePosition() { return Map.copyOf(this.piecePosition); } @@ -174,4 +182,40 @@ public void movePiece(Position selected, Position target) { Piece movingPiece = piecePosition.remove(selected); piecePosition.put(target, movingPiece); } + + public boolean isGameOver() { + return !hasGeneral(Side.CHO) || !hasGeneral(Side.HAN); + } + + private boolean hasGeneral(Side side) { + return piecePosition.values().stream() + .anyMatch(piece -> piece.isOwnedBy(side) && piece.isGeneral()); + } + + public double calculateScore(Side side) { + double totalScore = piecePosition.values().stream() + .filter(piece -> piece.isOwnedBy(side)) + .mapToDouble(Piece::getScore) + .sum(); + + if (side == Side.HAN) { + totalScore += HAN_BONUS_SCORE; + } + + return totalScore; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + Board board = (Board) o; + return Objects.equals(piecePosition, board.piecePosition); + } + + @Override + public int hashCode() { + return Objects.hashCode(piecePosition); + } } diff --git a/src/main/java/janggi/domain/board/Direction.java b/src/main/java/janggi/domain/board/Direction.java index aac9de552e..e0ff0fa136 100644 --- a/src/main/java/janggi/domain/board/Direction.java +++ b/src/main/java/janggi/domain/board/Direction.java @@ -1,5 +1,6 @@ package janggi.domain.board; +import janggi.domain.game.Side; import java.util.Collections; import java.util.List; import java.util.Map; @@ -42,4 +43,16 @@ public int getNextRow(int currentRow) { public int getNextColumn(int currentColumn) { return currentColumn + this.column; } + + public boolean isDiagonal() { + return (this == NE) || (this == NW) || (this == SE) || (this == SW); + } + + // 전진 방향 + public boolean isForwardFor(Side side) { + if (side == Side.CHO) { + return this.row < 0; // 초: 위 (-1) + } + return this.row > 0; // 한: 아래 (+1) + } } diff --git a/src/main/java/janggi/domain/board/Position.java b/src/main/java/janggi/domain/board/Position.java index b26c9cff3b..fb3c90d8b7 100644 --- a/src/main/java/janggi/domain/board/Position.java +++ b/src/main/java/janggi/domain/board/Position.java @@ -1,5 +1,8 @@ package janggi.domain.board; +import janggi.domain.game.Side; +import java.util.Collections; +import java.util.List; import java.util.Optional; public record Position(int row, int column) { @@ -10,6 +13,23 @@ public record Position(int row, int column) { public static final int BOARD_MAX_COLUMN = 8; public static final int BOARD_MIN_COLUMN = 0; + // 궁성 공통 열 범위 + private static final int PALACE_MIN_COLUMN = 3; + private static final int PALACE_MAX_COLUMN = 5; + + // 한 진영 궁성 행 범위 + private static final int TOP_PALACE_MIN_ROW = 0; + private static final int TOP_PALACE_MAX_ROW = 2; + + // 초 진영 궁성 행 범위 + private static final int BOTTOM_PALACE_MIN_ROW = 7; + private static final int BOTTOM_PALACE_MAX_ROW = 9; + + // 궁성 정중앙 좌표 + private static final int PALACE_CENTER_COLUMN = 4; + private static final int TOP_PALACE_CENTER_ROW = 1; + private static final int BOTTOM_PALACE_CENTER_ROW = 8; + public Position { if (!isWithinBoard(row, column)) { throw new IllegalArgumentException( @@ -33,4 +53,66 @@ private static boolean isWithinBoard(int row, int column) { return (row >= BOARD_MIN_ROW && row <= BOARD_MAX_ROW) && (column >= BOARD_MIN_COLUMN && column <= BOARD_MAX_COLUMN); } + + public boolean isPalace() { + return isWithinTopPalace() || isWithinBottomPalace(); + } + + private boolean isWithinTopPalace() { + return (row >= TOP_PALACE_MIN_ROW && row <= TOP_PALACE_MAX_ROW) + && (column >= PALACE_MIN_COLUMN && column <= PALACE_MAX_COLUMN); + } + + private boolean isWithinBottomPalace() { + return (row >= BOTTOM_PALACE_MIN_ROW && row <= BOTTOM_PALACE_MAX_ROW) + && (column >= PALACE_MIN_COLUMN && column <= PALACE_MAX_COLUMN); + } + + public boolean isPalaceCenter() { + return (row == TOP_PALACE_CENTER_ROW && column == PALACE_CENTER_COLUMN) || + (row == BOTTOM_PALACE_CENTER_ROW && column == PALACE_CENTER_COLUMN); + } + + public boolean isPalaceCorner() { + boolean isTopCorner = (row == TOP_PALACE_MIN_ROW || row == TOP_PALACE_MAX_ROW) + && (column == PALACE_MIN_COLUMN || column == PALACE_MAX_COLUMN); + + boolean isBottomCorner = (row == BOTTOM_PALACE_MIN_ROW || row == BOTTOM_PALACE_MAX_ROW) + && (column == PALACE_MIN_COLUMN || column == PALACE_MAX_COLUMN); + + return isTopCorner || isBottomCorner; + } + + public List getValidPalaceDiagonals() { + if (isPalaceCenter()) { + return List.of(Direction.NE, Direction.NW, Direction.SE, Direction.SW); + } + + // 좌상단 꼭짓점 + if ((row == TOP_PALACE_MIN_ROW || row == BOTTOM_PALACE_MIN_ROW) && column == PALACE_MIN_COLUMN) { + return List.of(Direction.SE); + } + // 우상단 꼭짓점 + if ((row == TOP_PALACE_MIN_ROW || row == BOTTOM_PALACE_MIN_ROW) && column == PALACE_MAX_COLUMN) { + return List.of(Direction.SW); + } + // 좌하단 꼭짓점 + if ((row == TOP_PALACE_MAX_ROW || row == BOTTOM_PALACE_MAX_ROW) && column == PALACE_MIN_COLUMN) { + return List.of(Direction.NE); + } + // 우하단 꼭짓점 + if ((row == TOP_PALACE_MAX_ROW || row == BOTTOM_PALACE_MAX_ROW) && column == PALACE_MAX_COLUMN) { + return List.of(Direction.NW); + } + + return Collections.emptyList(); + } + + // 상대 궁성인지 판단 + public boolean isOpponentPalace(Side side) { + if (side == Side.CHO) { + return isWithinTopPalace(); + } + return isWithinBottomPalace(); + } } diff --git a/src/main/java/janggi/domain/game/Player.java b/src/main/java/janggi/domain/game/Player.java index 78a4f6f8ea..ba66ae4fe7 100644 --- a/src/main/java/janggi/domain/game/Player.java +++ b/src/main/java/janggi/domain/game/Player.java @@ -1,5 +1,7 @@ package janggi.domain.game; +import java.util.Objects; + public class Player { private final String name; @@ -21,4 +23,18 @@ public String getName() { public Side getSide() { return side; } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + Player player = (Player) o; + return Objects.equals(name, player.name); + } + + @Override + public int hashCode() { + return Objects.hashCode(name); + } } diff --git a/src/main/java/janggi/domain/game/PlayerResultDTO.java b/src/main/java/janggi/domain/game/PlayerResultDTO.java new file mode 100644 index 0000000000..d0bb90ab2d --- /dev/null +++ b/src/main/java/janggi/domain/game/PlayerResultDTO.java @@ -0,0 +1,7 @@ +package janggi.domain.game; + +public record PlayerResultDTO(String name, Side side, double score) { + public static PlayerResultDTO of(Player player, double score) { + return new PlayerResultDTO(player.getName(), player.getSide(), score); + } +} diff --git a/src/main/java/janggi/domain/game/Players.java b/src/main/java/janggi/domain/game/Players.java index 1541a06774..d006886d3d 100644 --- a/src/main/java/janggi/domain/game/Players.java +++ b/src/main/java/janggi/domain/game/Players.java @@ -1,25 +1,28 @@ package janggi.domain.game; +import java.util.Iterator; +import java.util.Objects; import java.util.Set; -public class Players { +public class Players implements Iterable { private static final String ERROR_DUPLICATED_NAME = "[ERROR] 플레이어는 중복된 이름을 가질 수 없습니다."; - private static final String ERROR_PLAYER_NOT_FOUND = "[ERROR] 현재 턴에 해당하는 플레이어가 없습니다."; + private static final String ERROR_PLAYER_NOT_FOUND = "[ERROR] 해당하는 플레이어를 찾을 수 없습니다."; private final Set players; - private final Turn turn; + private Turn turn; - private Players(Set players) { - this.players = players; - this.turn = new Turn(); + private Players(String choPlayerName, String hanPlayerName, Side side) { + validateDuplicatedNames(choPlayerName, hanPlayerName); + this.players = Set.of(new Player(choPlayerName, Side.CHO), new Player(hanPlayerName, Side.HAN)); + this.turn = Turn.from(side); } - public static Players of(String choPlayerName, String hanPlayerName) { - validateDuplicatedNames(choPlayerName, hanPlayerName); - Player choPlayer = new Player(choPlayerName, Side.CHO); - Player hanPlayer = new Player(hanPlayerName, Side.HAN); + public static Players createInitial(String choPlayerName, String hanPlayerName) { + return new Players(choPlayerName, hanPlayerName, Side.CHO); + } - return new Players(Set.of(choPlayer, hanPlayer)); + public static Players fromSavedStatus(String choPlayerName, String hanPlayerName, Side currentTurn) { + return new Players(choPlayerName, hanPlayerName, currentTurn); } private static void validateDuplicatedNames(String choPlayerName, String hanPlayerName) { @@ -28,15 +31,52 @@ private static void validateDuplicatedNames(String choPlayerName, String hanPlay } } - public PlayerDTO getCurrentPlayer() { + public Player getCurrentPlayer() { return players.stream() .filter(player -> player.isMyTurn(turn)) .findFirst() - .map(PlayerDTO::from) .orElseThrow(() -> new IllegalStateException(ERROR_PLAYER_NOT_FOUND)); } public void switchTurn() { - turn.switchTurn(); + turn = turn.switchTurn(); + } + + public Iterator iterator() { + return players.iterator(); + } + + public Turn getTurn() { + return turn; + } + + public String getChoPlayerName() { + return getPlayerNameBySide(Side.CHO); + } + + public String getHanPlayerName() { + return getPlayerNameBySide(Side.HAN); + } + + private String getPlayerNameBySide(Side side) { + return players.stream() + .filter(player -> player.getSide() == side) + .map(Player::getName) + .findFirst() + .orElseThrow(() -> new IllegalStateException(ERROR_PLAYER_NOT_FOUND)); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + Players players1 = (Players) o; + return Objects.equals(players, players1.players); + } + + @Override + public int hashCode() { + return Objects.hashCode(players); } } diff --git a/src/main/java/janggi/domain/game/PlayersSaveDTO.java b/src/main/java/janggi/domain/game/PlayersSaveDTO.java new file mode 100644 index 0000000000..99d2305e53 --- /dev/null +++ b/src/main/java/janggi/domain/game/PlayersSaveDTO.java @@ -0,0 +1,11 @@ +package janggi.domain.game; + +public record PlayersSaveDTO(String choPlayerName, String hanPlayerName, String turn) { + public static PlayersSaveDTO from(Players players) { + return new PlayersSaveDTO( + players.getChoPlayerName(), + players.getHanPlayerName(), + players.getTurn().getSide().name() + ); + } +} diff --git a/src/main/java/janggi/domain/game/Side.java b/src/main/java/janggi/domain/game/Side.java index 7ef4b2cada..2a532bd418 100644 --- a/src/main/java/janggi/domain/game/Side.java +++ b/src/main/java/janggi/domain/game/Side.java @@ -22,10 +22,13 @@ public String getDisplayName() { } public Side opposite() { - if (this.equals(CHO)) { + if (this == CHO) { return HAN; } - return CHO; + if (this == HAN) { + return CHO; + } + return NONE; } public EnumSet getSoldierDirections() { diff --git a/src/main/java/janggi/domain/game/Turn.java b/src/main/java/janggi/domain/game/Turn.java index f2f1567186..146cfaa5a0 100644 --- a/src/main/java/janggi/domain/game/Turn.java +++ b/src/main/java/janggi/domain/game/Turn.java @@ -1,17 +1,41 @@ package janggi.domain.game; +import java.util.Objects; + public class Turn { - private Side current; + private final Side current; + + private Turn(Side side) { + this.current = side; + } - public Turn() { - this.current = Side.CHO; + public static Turn from(Side side) { + return new Turn(side); } - public void switchTurn() { - this.current = current.opposite(); + public Turn switchTurn() { + return new Turn(current.opposite()); } public boolean isCurrent(Side side) { return this.current == side; } + + public Side getSide() { + return current; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + Turn turn = (Turn) o; + return current == turn.current; + } + + @Override + public int hashCode() { + return Objects.hashCode(current); + } } diff --git a/src/main/java/janggi/domain/piece/Piece.java b/src/main/java/janggi/domain/piece/Piece.java index a8cc4df54b..d0de579c48 100644 --- a/src/main/java/janggi/domain/piece/Piece.java +++ b/src/main/java/janggi/domain/piece/Piece.java @@ -36,6 +36,10 @@ public boolean isCannon() { return this.pieceType == PieceType.CANNON; } + public boolean isGeneral() { + return this.pieceType == PieceType.GENERAL; + } + public boolean isEmpty() { return this.pieceType == PieceType.EMPTY; } @@ -48,18 +52,22 @@ public static Piece createEmpty() { return EMPTY_INSTANCE; } - Side getSide() { + public Side getSide() { return side; } - PieceType getPieceType() { + public PieceType getPieceType() { return pieceType; } - String getPieceNumber() { + public String getPieceNumber() { return pieceNumber; } + public double getScore() { + return pieceType.getScore(); + } + @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) { diff --git a/src/main/java/janggi/domain/piece/PieceType.java b/src/main/java/janggi/domain/piece/PieceType.java index 0e715e77b3..d2907f3861 100644 --- a/src/main/java/janggi/domain/piece/PieceType.java +++ b/src/main/java/janggi/domain/piece/PieceType.java @@ -9,6 +9,7 @@ import janggi.domain.strategy.EmptyMoveStrategy; import janggi.domain.strategy.HorseMoveStrategy; import janggi.domain.strategy.MoveStrategy; +import janggi.domain.strategy.PalaceMoveStrategy; import janggi.domain.strategy.SlideMoveStrategy; import janggi.domain.strategy.StepMoveStrategy; import java.util.EnumSet; @@ -18,32 +19,62 @@ public enum PieceType { -// TODO: 사이클 2 에서 궁성 구현 시 -// PALACE(EnumSet.allOf(Direction.class), new StepMoveStrategy()), -// GUARD(EnumSet.allOf(Direction.class), new StepMoveStrategy()), - PALACE(side -> EnumSet.of(Direction.N, Direction.S, Direction.E, Direction.W), new StepMoveStrategy()), - GUARD(side -> EnumSet.of(Direction.N, Direction.S, Direction.E, Direction.W), new StepMoveStrategy()), - CHARIOT(side -> EnumSet.of(Direction.N, Direction.S, Direction.E, Direction.W), new SlideMoveStrategy()), - CANNON(side -> EnumSet.of(Direction.N, Direction.S, Direction.E, Direction.W), new CannonMoveStrategy()), - HORSE(side -> EnumSet.of(Direction.N, Direction.S, Direction.E, Direction.W), new HorseMoveStrategy()), - ELEPHANT(side -> EnumSet.of(Direction.N, Direction.S, Direction.E, Direction.W), new ElephantMoveStrategy()), - SOLDIER(Side::getSoldierDirections, new StepMoveStrategy()), - EMPTY(side -> EnumSet.noneOf(Direction.class), new EmptyMoveStrategy()), + GENERAL(side -> eightDirections(), new PalaceMoveStrategy(), 0.0), + GUARD(side -> eightDirections(), new PalaceMoveStrategy(), 3.0), + CHARIOT(side -> fourDirections(), new SlideMoveStrategy(), 13.0), + CANNON(side -> fourDirections(), new CannonMoveStrategy(), 7.0), + HORSE(side -> fourDirections(), new HorseMoveStrategy(), 5.0), + ELEPHANT(side -> fourDirections(), new ElephantMoveStrategy(), 3.0), + SOLDIER(Side::getSoldierDirections, new StepMoveStrategy(), 2.0), + EMPTY(side -> EnumSet.noneOf(Direction.class), new EmptyMoveStrategy(), 0.0), ; private final Function> directionProvider; private final MoveStrategy moveStrategy; + private final double score; - PieceType(Function> directionProvider, MoveStrategy moveStrategy) { + PieceType(Function> directionProvider, MoveStrategy moveStrategy, double score) { this.moveStrategy = moveStrategy; this.directionProvider = directionProvider; + this.score = score; + } + + private static EnumSet eightDirections() { + return EnumSet.of(Direction.N, Direction.S, Direction.E, Direction.W, + Direction.NE, Direction.NW, Direction.SE, Direction.SW); + } + + private static EnumSet fourDirections() { + return EnumSet.of(Direction.N, Direction.S, Direction.E, Direction.W); } public Paths calculatePaths(Position current, Side side) { - return moveStrategy.findMovablePaths(current, directionProvider.apply(side)); + EnumSet movableDirections = getMovableDirections(current, side); + return moveStrategy.findMovablePaths(current, movableDirections); + } + + private EnumSet getMovableDirections(Position current, Side side) { + EnumSet directions = directionProvider.apply(side); + + if (this == SOLDIER && current.isOpponentPalace(side)) { + addSoldierPalaceDiagonals(current, side, directions); + } + + return directions; + } + + private void addSoldierPalaceDiagonals(Position current, Side side, EnumSet directions) { + current.getValidPalaceDiagonals().stream() + // 궁성 대각선들 중, 전진하는 방향만 추가 + .filter(diagonal -> diagonal.isForwardFor(side)) + .forEach(directions::add); } public List determineDestinations(Paths paths, Map boardState, Piece movingPiece) { return moveStrategy.determineDestinations(paths, boardState, movingPiece); } + + public double getScore() { + return score; + } } diff --git a/src/main/java/janggi/domain/repository/JanggiRepository.java b/src/main/java/janggi/domain/repository/JanggiRepository.java new file mode 100644 index 0000000000..4e698db093 --- /dev/null +++ b/src/main/java/janggi/domain/repository/JanggiRepository.java @@ -0,0 +1,28 @@ +package janggi.domain.repository; + +import janggi.domain.board.Board; +import janggi.domain.game.Players; +import janggi.domain.game.PlayersSaveDTO; +import janggi.domain.game.Turn; +import java.util.Optional; + +public interface JanggiRepository { + // 게임 생성 + Long save(PlayersSaveDTO dto); + + // 턴 끝날 때마다 게임 상태 저장 + void updateGameStatus(Long gameId, Board board, Turn turn); + + // 진행 중인 가장 최근 게임 조회 + Optional findInProgressGameId(); + + // 게임 복구 (도메인 정보 조회) + Board findBoardById(Long gameId); + + Players findPlayersById(Long gameId); + + Turn findTurnById(Long gameId); + + // 게임 종료 + void finishGame(Long gameId); +} diff --git a/src/main/java/janggi/domain/route/Path.java b/src/main/java/janggi/domain/route/Path.java index 40e687b478..5e3d426a98 100644 --- a/src/main/java/janggi/domain/route/Path.java +++ b/src/main/java/janggi/domain/route/Path.java @@ -60,4 +60,18 @@ public static Path fromContinuousMove(Position start, Direction direction) { return path; } + + public static Path fromPalaceContinuousMove(Position start, Direction direction) { + Path path = new Path(); + Optional nextCandidate = start.tryMove(direction); + + // 다음 칸이 궁성 안일 때까지만 반복 + while (nextCandidate.isPresent() && nextCandidate.get().isPalace()) { + Position current = nextCandidate.get(); + path.add(current); + nextCandidate = current.tryMove(direction); + } + + return path; + } } diff --git a/src/main/java/janggi/domain/route/Paths.java b/src/main/java/janggi/domain/route/Paths.java index 81de746ec5..e2c0fcd48b 100644 --- a/src/main/java/janggi/domain/route/Paths.java +++ b/src/main/java/janggi/domain/route/Paths.java @@ -13,7 +13,7 @@ public Paths() { } public void addPath(Path path) { - if (path.isEmpty()) { + if (path.isEmpty() || paths.contains(path)) { return; } paths.add(path); diff --git a/src/main/java/janggi/domain/strategy/CannonMoveStrategy.java b/src/main/java/janggi/domain/strategy/CannonMoveStrategy.java index 0cb86d9098..5b9098caec 100644 --- a/src/main/java/janggi/domain/strategy/CannonMoveStrategy.java +++ b/src/main/java/janggi/domain/strategy/CannonMoveStrategy.java @@ -10,15 +10,27 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Optional; public class CannonMoveStrategy implements MoveStrategy { @Override public Paths findMovablePaths(Position current, EnumSet baseDirections) { Paths paths = new Paths(); + + // 기본 직선 경로 for (Direction baseDirection : baseDirections) { addCannonPath(current, baseDirection, paths); } + + // 궁성 내 대각선 경로 + if (current.isPalaceCorner()) { + for (Direction diagonalDirection : current.getValidPalaceDiagonals()) { + Path.fromSequence(current, List.of(diagonalDirection, diagonalDirection)) + .ifPresent(paths::addPath); + } + } + return paths; } @@ -46,36 +58,40 @@ private void validateCannonPath(Path route, Map state, List iterator, Map state) { - Piece firstPiece = findFirstPiece(iterator, state); - return isValidBridge(firstPiece); + Optional firstPiece = findFirstPiece(iterator, state); + + return firstPiece + .map(this::isValidBridge) + .orElse(false); } - private Piece findFirstPiece(Iterator iterator, Map state) { + private Optional findFirstPiece(Iterator iterator, Map state) { while (iterator.hasNext()) { - Piece piece = state.get(iterator.next()); - if (piece != null) { + Optional piece = Optional.ofNullable(state.get(iterator.next())); + + if (piece.isPresent()) { return piece; } } - return null; + return Optional.empty(); } private boolean isValidBridge(Piece piece) { - return (piece != null) && !piece.isCannon(); + return !piece.isCannon(); } private void findDestinationsAfterJump(Iterator iterator, Map state, List destinations, Piece me) { while (iterator.hasNext()) { Position position = iterator.next(); - Piece target = state.get(position); + Optional target = Optional.ofNullable(state.get(position)); - if (target == null) { + if (target.isEmpty()) { destinations.add(position); continue; } - addTargetIfCapturable(position, target, destinations, me); + addTargetIfCapturable(position, target.get(), destinations, me); return; } } diff --git a/src/main/java/janggi/domain/strategy/ElephantMoveStrategy.java b/src/main/java/janggi/domain/strategy/ElephantMoveStrategy.java index 9e7b51251c..3050a25c11 100644 --- a/src/main/java/janggi/domain/strategy/ElephantMoveStrategy.java +++ b/src/main/java/janggi/domain/strategy/ElephantMoveStrategy.java @@ -20,6 +20,7 @@ public Paths findMovablePaths(Position current, EnumSet baseDirection for (Direction baseDirection : baseDirections) { addElephantPaths(current, baseDirection, paths); } + return paths; } @@ -36,22 +37,27 @@ public List determineDestinations(Paths routes, Map b for (Path route : routes) { validateElephantPath(route, boardState, destinations, movingPiece); } + return destinations; } private void validateElephantPath(Path route, Map state, List destinations, Piece me) { Iterator iterator = route.iterator(); - Position transit1 = iterator.next(); - Position transit2 = iterator.next(); + Position firstStep = iterator.next(); + Position secondStep = iterator.next(); - if (state.get(transit1) == null && state.get(transit2) == null) { + Optional pieceAtFirstStep = Optional.ofNullable(state.get(firstStep)); + Optional pieceAtSecondStep = Optional.ofNullable(state.get(secondStep)); + + if (pieceAtFirstStep.isEmpty() && pieceAtSecondStep.isEmpty()) { addIfValid(iterator.next(), state, destinations, me); // 최종 도착지 } } private void addIfValid(Position destination, Map state, List destinations, Piece me) { - Piece target = state.get(destination); - if (target == null || !target.isSameSide(me)) { + Optional target = Optional.ofNullable(state.get(destination)); + + if (target.isEmpty() || !target.get().isSameSide(me)) { destinations.add(destination); } } diff --git a/src/main/java/janggi/domain/strategy/HorseMoveStrategy.java b/src/main/java/janggi/domain/strategy/HorseMoveStrategy.java index 09ad13cea4..055d85bbc3 100644 --- a/src/main/java/janggi/domain/strategy/HorseMoveStrategy.java +++ b/src/main/java/janggi/domain/strategy/HorseMoveStrategy.java @@ -10,6 +10,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Optional; public class HorseMoveStrategy implements MoveStrategy { @@ -40,16 +41,18 @@ public List determineDestinations(Paths routes, Map b private void validateHorsePath(Path route, Map state, List destinations, Piece me) { Iterator iterator = route.iterator(); - Position transit = iterator.next(); // 멱 (1번째) + Position firstStep = iterator.next(); // 멱 (1번째) + Optional pieceAtFirstStep = Optional.ofNullable(state.get(firstStep)); - if (state.get(transit) == null) { + if (pieceAtFirstStep.isEmpty()) { addIfValid(iterator.next(), state, destinations, me); // 도착지 (2번째) } } private void addIfValid(Position destination, Map state, List destinations, Piece me) { - Piece target = state.get(destination); - if (target == null || !target.isSameSide(me)) { + Optional target = Optional.ofNullable(state.get(destination)); + + if (target.isEmpty() || !target.get().isSameSide(me)) { destinations.add(destination); } } diff --git a/src/main/java/janggi/domain/strategy/PalaceMoveStrategy.java b/src/main/java/janggi/domain/strategy/PalaceMoveStrategy.java new file mode 100644 index 0000000000..11dfef1b78 --- /dev/null +++ b/src/main/java/janggi/domain/strategy/PalaceMoveStrategy.java @@ -0,0 +1,62 @@ +package janggi.domain.strategy; + +import janggi.domain.board.Direction; +import janggi.domain.board.Position; +import janggi.domain.piece.Piece; +import janggi.domain.route.Path; +import janggi.domain.route.Paths; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class PalaceMoveStrategy implements MoveStrategy { + + @Override + public Paths findMovablePaths(Position current, EnumSet baseDirections) { + Paths paths = new Paths(); + for (Direction baseDirection : baseDirections) { + addPalacePath(current, baseDirection, paths); + } + return paths; + } + + private void addPalacePath(Position current, Direction baseDirection, Paths paths) { + Path.fromSequence(current, List.of(baseDirection)) + .filter(path -> isValidPalacePath(current, path, baseDirection)) + .ifPresent(paths::addPath); + } + + private boolean isValidPalacePath(Position current, Path path, Direction baseDirection) { + Position next = path.iterator().next(); + + if (!next.isPalace()) { + return false; + } + + if (baseDirection.isDiagonal()) { + return current.isPalaceCenter() || next.isPalaceCenter(); + } + + return true; + } + + @Override + public List determineDestinations(Paths routes, Map boardState, Piece movingPiece) { + List destinations = new ArrayList<>(); + for (Path route : routes) { + validatePalacePath(route, boardState, destinations, movingPiece); + } + + return destinations; + } + + private void validatePalacePath(Path route, Map state, List destinations, Piece me) { + Position destination = route.iterator().next(); + Optional target = Optional.ofNullable(state.get(destination)); + if (target.isEmpty() || !target.get().isSameSide(me)) { + destinations.add(destination); + } + } +} diff --git a/src/main/java/janggi/domain/strategy/SlideMoveStrategy.java b/src/main/java/janggi/domain/strategy/SlideMoveStrategy.java index caab60f049..f94862ebd6 100644 --- a/src/main/java/janggi/domain/strategy/SlideMoveStrategy.java +++ b/src/main/java/janggi/domain/strategy/SlideMoveStrategy.java @@ -9,15 +9,27 @@ import java.util.EnumSet; import java.util.List; import java.util.Map; +import java.util.Optional; public class SlideMoveStrategy implements MoveStrategy { @Override public Paths findMovablePaths(Position current, EnumSet baseDirections) { Paths paths = new Paths(); + + // 기본 직선 경로 for (Direction baseDirection : baseDirections) { addSlidePath(current, baseDirection, paths); } + + // 궁성 내 대각선 경로 + if (current.isPalaceCorner() || current.isPalaceCenter()) { + for (Direction diagonalDirection : current.getValidPalaceDiagonals()) { + Path diagonalPath = Path.fromPalaceContinuousMove(current, diagonalDirection); + paths.addPath(diagonalPath); + } + } + return paths; } @@ -34,6 +46,7 @@ public List determineDestinations(Paths routes, Map b for (Path route : routes) { validateSlidePath(route, boardState, destinations, movingPiece); } + return destinations; } @@ -47,16 +60,16 @@ private void validateSlidePath(Path route, Map state, List state, List destinations, Piece me) { - Piece target = state.get(destination); + Optional target = Optional.ofNullable(state.get(destination)); // 빈 칸이면 경로에 추가하고, 계속 전진 - if (target == null) { + if (target.isEmpty()) { destinations.add(destination); return false; } // 적군이면 경로에 추가하고, 멈춤 - if (!target.isSameSide(me)) { + if (!target.get().isSameSide(me)) { destinations.add(destination); } diff --git a/src/main/java/janggi/domain/strategy/StepMoveStrategy.java b/src/main/java/janggi/domain/strategy/StepMoveStrategy.java index 380e37898a..4b5c3dce32 100644 --- a/src/main/java/janggi/domain/strategy/StepMoveStrategy.java +++ b/src/main/java/janggi/domain/strategy/StepMoveStrategy.java @@ -9,15 +9,17 @@ import java.util.EnumSet; import java.util.List; import java.util.Map; +import java.util.Optional; public class StepMoveStrategy implements MoveStrategy { @Override public Paths findMovablePaths(Position current, EnumSet baseDirections) { Paths paths = new Paths(); - for (Direction baseDirection : baseDirections) { - addStepPath(current, baseDirection, paths); + for (Direction direction : baseDirections) { + addStepPath(current, direction, paths); } + return paths; } @@ -35,14 +37,14 @@ public List determineDestinations(Paths routes, Map b for (Path route : routes) { validateStepPath(route, boardState, destinations, movingPiece); } + return destinations; } private void validateStepPath(Path route, Map state, List destinations, Piece me) { Position destination = route.iterator().next(); - Piece target = state.get(destination); - - if (target == null || !target.isSameSide(me)) { + Optional target = Optional.ofNullable(state.get(destination)); + if (target.isEmpty() || !target.get().isSameSide(me)) { destinations.add(destination); } } diff --git a/src/main/java/janggi/infrastructure/JdbcJanggiRepository.java b/src/main/java/janggi/infrastructure/JdbcJanggiRepository.java new file mode 100644 index 0000000000..85f2cdeea4 --- /dev/null +++ b/src/main/java/janggi/infrastructure/JdbcJanggiRepository.java @@ -0,0 +1,234 @@ +package janggi.infrastructure; + +import janggi.domain.board.Board; +import janggi.domain.board.Position; +import janggi.domain.game.Players; +import janggi.domain.game.PlayersSaveDTO; +import janggi.domain.game.Side; +import janggi.domain.game.Turn; +import janggi.domain.piece.Piece; +import janggi.domain.piece.PieceType; +import janggi.domain.repository.JanggiRepository; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.HashMap; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import javax.sql.DataSource; + +public class JdbcJanggiRepository implements JanggiRepository { + // SQL 쿼리 + private static final String INSERT_GAME_SQL = "INSERT INTO game (cho_player, han_player, current_turn) VALUES (?, ?, ?)"; + private static final String UPDATE_TURN_SQL = "UPDATE game SET current_turn = ? WHERE id = ?"; + private static final String DELETE_BOARD_SQL = "DELETE FROM board_state WHERE game_id = ?"; + private static final String INSERT_BOARD_SQL = "INSERT INTO board_state (game_id, row_index, col_index, piece_type, side, piece_number) VALUES (?, ?, ?, ?, ?, ?)"; + private static final String SELECT_IN_PROGRESS_ID_SQL = "SELECT id FROM game WHERE is_finished = FALSE ORDER BY created_at DESC LIMIT 1"; + private static final String SELECT_BOARD_BY_ID_SQL = "SELECT row_index, col_index, piece_type, side, piece_number FROM board_state WHERE game_id = ?"; + private static final String SELECT_PLAYERS_BY_ID_SQL = "SELECT cho_player, han_player, current_turn FROM game WHERE id = ?"; + private static final String SELECT_TURN_BY_ID_SQL = "SELECT current_turn FROM game WHERE id = ?"; + private static final String UPDATE_FINISH_GAME_SQL = "UPDATE game SET is_finished = TRUE WHERE id = ?"; + + // 컬럼명 + private static final String COLUMN_ID = "id"; + private static final String COLUMN_CHO_PLAYER = "cho_player"; + private static final String COLUMN_HAN_PLAYER = "han_player"; + private static final String COLUMN_CURRENT_TURN = "current_turn"; + private static final String COLUMN_ROW_INDEX = "row_index"; + private static final String COLUMN_COL_INDEX = "col_index"; + private static final String COLUMN_PIECE_TYPE = "piece_type"; + private static final String COLUMN_SIDE = "side"; + private static final String COLUMN_PIECE_NUMBER = "piece_number"; + + // 에러 메시지 + private static final String ERROR_SAVE = "[ERROR] DB 저장 실패: "; + private static final String ERROR_ID_GENERATION = "[ERROR] ID 생성 실패"; + private static final String ERROR_UPDATE_STATUS = "[ERROR] 게임 상태 업데이트 실패: "; + private static final String ERROR_FIND_IN_PROGRESS = "[ERROR] 진행 중인 게임 조회 실패: "; + private static final String ERROR_FIND_BOARD = "[ERROR] 보드 복구 실패: "; + private static final String ERROR_FIND_PLAYERS = "[ERROR] 플레이어 조회 실패: "; + private static final String ERROR_NOT_FOUND_GAME = "[ERROR] 해당 ID의 게임을 찾을 수 없습니다."; + private static final String ERROR_FIND_TURN = "[ERROR] 턴 조회 실패: "; + private static final String ERROR_FIND_TURN_NOT_FOUND = "[ERROR] 턴 정보를 찾을 수 없습니다."; + private static final String ERROR_FINISH_GAME = "[ERROR] 게임 종료 처리 실패: "; + + private final DataSource dataSource; + + public JdbcJanggiRepository(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public Long save(PlayersSaveDTO dto) { + try (Connection conn = dataSource.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(INSERT_GAME_SQL, + Statement.RETURN_GENERATED_KEYS)) { // 자동으로 생성된 키 반환 + + // 데이터 바인딩 + pstmt.setString(1, dto.choPlayerName()); + pstmt.setString(2, dto.hanPlayerName()); + pstmt.setString(3, dto.turn()); + + // SQL 명령 -> DB 서버 + pstmt.executeUpdate(); + + try (ResultSet rs = pstmt.getGeneratedKeys()) { + if (rs.next()) { + return rs.getLong(1); + } + } + } catch (SQLException e) { + throw new RuntimeException(ERROR_SAVE + e.getMessage()); + } + throw new RuntimeException(ERROR_ID_GENERATION); + } + + @Override + public void updateGameStatus(Long gameId, Board board, Turn turn) { + try (Connection conn = dataSource.getConnection()) { + conn.setAutoCommit(false); // 트랜잭션 시작 + + try { + // 현재 턴 정보 업데이트 + try (PreparedStatement pstmt = conn.prepareStatement(UPDATE_TURN_SQL)) { + pstmt.setString(1, turn.getSide().name()); + pstmt.setLong(2, gameId); + pstmt.executeUpdate(); + } + + // 기존 보드 상태 삭제 + try (PreparedStatement pstmt = conn.prepareStatement(DELETE_BOARD_SQL)) { + pstmt.setLong(1, gameId); + pstmt.executeUpdate(); + } + + // 새로운 보드 데이터 삽입 + try (PreparedStatement pstmt = conn.prepareStatement(INSERT_BOARD_SQL)) { + for (Map.Entry entry : board.getPiecePosition().entrySet()) { + Position position = entry.getKey(); + Piece piece = entry.getValue(); + + if (piece.isEmpty()) { + continue; + } + + pstmt.setLong(1, gameId); + pstmt.setInt(2, position.row()); + pstmt.setInt(3, position.column()); + pstmt.setString(4, piece.getPieceType().name()); + pstmt.setString(5, piece.getSide().name()); + pstmt.setString(6, piece.getPieceNumber()); + pstmt.addBatch(); + } + pstmt.executeBatch(); + } + conn.commit(); + } catch (SQLException e) { + conn.rollback(); + throw e; + } + } catch (SQLException e) { + throw new RuntimeException(ERROR_UPDATE_STATUS + e.getMessage()); + } + } + + @Override + public Optional findInProgressGameId() { + try (Connection conn = dataSource.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(SELECT_IN_PROGRESS_ID_SQL); + ResultSet rs = pstmt.executeQuery()) { // 조회 메서드 + + if (rs.next()) { + return Optional.of(rs.getLong(COLUMN_ID)); + } + } catch (SQLException e) { + throw new RuntimeException(ERROR_FIND_IN_PROGRESS + e.getMessage()); + } + return Optional.empty(); + } + + @Override + public Board findBoardById(Long gameId) { + Map piecePosition = new HashMap<>(); + + try (Connection conn = dataSource.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(SELECT_BOARD_BY_ID_SQL)) { + + pstmt.setLong(1, gameId); + try (ResultSet rs = pstmt.executeQuery()) { + if (!rs.isBeforeFirst()) { + throw new NoSuchElementException(ERROR_NOT_FOUND_GAME); + } + + while (rs.next()) { + + Position position = new Position(rs.getInt(COLUMN_ROW_INDEX), rs.getInt(COLUMN_COL_INDEX)); + + // DB 문자열 -> Enum 변환 + PieceType type = PieceType.valueOf(rs.getString(COLUMN_PIECE_TYPE)); + Side side = Side.valueOf(rs.getString(COLUMN_SIDE)); + String pieceNumber = rs.getString(COLUMN_PIECE_NUMBER); + + // Piece 생성 + piecePosition.put(position, new Piece(side, type, pieceNumber)); + } + } + return Board.from(piecePosition); + } catch (SQLException e) { + throw new RuntimeException(ERROR_FIND_BOARD + e.getMessage()); + } + } + + @Override + public Players findPlayersById(Long gameId) { + try (Connection conn = dataSource.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(SELECT_PLAYERS_BY_ID_SQL)) { + + pstmt.setLong(1, gameId); + try (ResultSet rs = pstmt.executeQuery()) { + if (rs.next()) { + String choName = rs.getString(COLUMN_CHO_PLAYER); + String hanName = rs.getString(COLUMN_HAN_PLAYER); + Turn currentTurn = findTurnById(gameId); + return Players.fromSavedStatus(choName, hanName, currentTurn.getSide()); + } + } + } catch (SQLException e) { + throw new RuntimeException(ERROR_FIND_PLAYERS + e.getMessage()); + } + throw new RuntimeException(ERROR_NOT_FOUND_GAME); + } + + @Override + public Turn findTurnById(Long gameId) { + try (Connection conn = dataSource.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(SELECT_TURN_BY_ID_SQL)) { + + pstmt.setLong(1, gameId); + try (ResultSet rs = pstmt.executeQuery()) { + if (rs.next()) { + Side side = Side.valueOf(rs.getString(COLUMN_CURRENT_TURN)); + return Turn.from(side); + } + } + } catch (SQLException e) { + throw new RuntimeException(ERROR_FIND_TURN + e.getMessage()); + } + throw new RuntimeException(ERROR_FIND_TURN_NOT_FOUND); + } + + @Override + public void finishGame(Long gameId) { + try (Connection conn = dataSource.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(UPDATE_FINISH_GAME_SQL)) { + + pstmt.setLong(1, gameId); + pstmt.executeUpdate(); + } catch (SQLException e) { + throw new RuntimeException(ERROR_FINISH_GAME + e.getMessage()); + } + } +} diff --git a/src/main/java/janggi/view/InputView.java b/src/main/java/janggi/view/InputView.java index 372d7a612a..1c2b961e03 100644 --- a/src/main/java/janggi/view/InputView.java +++ b/src/main/java/janggi/view/InputView.java @@ -41,4 +41,19 @@ private int parseToInt(String input) { public void close() { scanner.close(); } + + public Boolean readContinueAnswer() { + String input = scanner.nextLine().trim().toLowerCase(); + validateNotBlank(input); + + if (input.equals("y")) { + return true; + } + + if (input.equals("n")) { + return false; + } + + throw new IllegalArgumentException("[ERROR] 'y' 또는 'n'만 입력 가능합니다."); + } } diff --git a/src/main/java/janggi/view/OutputView.java b/src/main/java/janggi/view/OutputView.java index 882a86487e..81ca00eefc 100644 --- a/src/main/java/janggi/view/OutputView.java +++ b/src/main/java/janggi/view/OutputView.java @@ -1,8 +1,11 @@ package janggi.view; import janggi.domain.board.Position; +import janggi.domain.game.PlayerResultDTO; +import janggi.domain.game.Side; import janggi.domain.piece.PieceDTO; import janggi.domain.board.BoardDTO; +import java.util.List; import java.util.Map; import java.util.Set; @@ -15,6 +18,15 @@ public class OutputView { private static final String MOVE_POSITION_ROW_NOTICE = "좌표의 행을 입력해주세요."; private static final String MOVE_POSITION_COLUMN_NOTICE = "좌표의 열을 입력해주세요."; + private static final String CONTINUE_GAME_NOTICE = "\n[안내] 진행 중인 게임이 발견되었습니다."; + private static final String CONTINUE_GAME_QUESTION = "이전 게임을 이어서 진행하시겠습니까? (y / n)"; + private static final String RESUME_NOTICE = "\n이전 게임 데이터를 성공적으로 불러왔습니다. 게임을 재개합니다."; + + private static final String GAME_OVER_NOTICE = "\n========================== 게임 종료 =========================="; + private static final String SCORE_RESULT_NOTICE = "\n-------------------------- [ 최종 기물 점수 결과 ] --------------------------"; + private static final String WINNER_NOTICE = "(%s) 플레이어 %s 님의 승리입니다."; + private static final String TOTAL_SCORE_NOTICE = "(%s) 플레이어 %s 님의 총점은 %.1f점입니다."; + private static final String EMPTY_CELL = "  "; private static final String COLUMN_INDEXES = "  ║  0    1    2    3    4    5    6    7    8"; private static final String DIVIDER = "  ║=========================================================================================="; @@ -129,4 +141,28 @@ public void printSelectPiecePosition() { public void printSelectTargetPosition() { printLine(INPUT_TARGET_TO_MOVE_NOTICE); } + + public void printWinnerNotice(Side side, String playerName) { + printLine(GAME_OVER_NOTICE); + printLine(String.format(WINNER_NOTICE, side, playerName)); + printLine(SCORE_RESULT_NOTICE); + } + + public void printTotalScores(List results) { + for (PlayerResultDTO result : results) { + printLine(String.format(TOTAL_SCORE_NOTICE, + result.side(), + result.name(), + result.score())); + } + } + + public void printContinueGameNotice() { + printLine(CONTINUE_GAME_NOTICE); + printLine(CONTINUE_GAME_QUESTION); + } + + public void printResumeNotice() { + printLine(RESUME_NOTICE); + } } diff --git a/src/main/java/janggi/view/PieceLabelFormatter.java b/src/main/java/janggi/view/PieceLabelFormatter.java index 838a58c5c1..758a04bfec 100644 --- a/src/main/java/janggi/view/PieceLabelFormatter.java +++ b/src/main/java/janggi/view/PieceLabelFormatter.java @@ -19,7 +19,7 @@ public static String toFullWidth(PieceDTO pieceDTO) { } private static String getPieceName(PieceDTO pieceDTO) { - if (pieceDTO.pieceType() == PieceType.PALACE) { + if (pieceDTO.pieceType() == PieceType.GENERAL) { return toPalaceName(pieceDTO.side()); } diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000000..2c1ed09684 --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,25 @@ +-- 데이터베이스 생성 및 선택 +CREATE DATABASE IF NOT EXISTS janggi_db; +USE janggi_db; + +-- 게임 정보 테이블 +CREATE TABLE IF NOT EXISTS game ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + cho_player VARCHAR(50) NOT NULL, + han_player VARCHAR(50) NOT NULL, + current_turn VARCHAR(10) NOT NULL, + is_finished BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 보드 상태 정보 테이블 +CREATE TABLE IF NOT EXISTS board_state ( + game_id BIGINT, + row_index INT, + col_index INT, + piece_type VARCHAR(20), + side VARCHAR(10), + piece_number VARCHAR(10), + PRIMARY KEY (game_id, row_index, col_index), + FOREIGN KEY (game_id) REFERENCES game(id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/src/test/java/janggi/domain/board/BoardTest.java b/src/test/java/janggi/domain/board/BoardTest.java index f95f75b1c1..084aa45de1 100644 --- a/src/test/java/janggi/domain/board/BoardTest.java +++ b/src/test/java/janggi/domain/board/BoardTest.java @@ -28,8 +28,9 @@ void setUp() { assertThat(piecePosition).hasSize(32); } - @Test @DisplayName("보드 초기화 시, 특정 위치에 올바른 기물이 배치된다.") + + @Test void 보드_초기화_위치_테스트() { Map piecePosition = board.getPiecePosition(); @@ -44,8 +45,8 @@ void setUp() { ); } - @Test @DisplayName("상대방의 기물을 선택하면 예외가 발생한다.") + @Test void 상대_기물_선택_예외_테스트() { // given Position hanPiecePosition = new Position(0, 0); // 한나라 기물 위치 @@ -56,8 +57,8 @@ void setUp() { .hasMessageContaining("[ERROR] 상대방의 기물은 선택할 수 없습니다."); } - @Test @DisplayName("기물을 이동시키는 경우, 이전 위치는 비고, 새로운 위치에 기물이 존재한다.") + @Test void 기물_이동_테스트() { // given Position selected = new Position(6, 0); // 초나라 졸 위치 @@ -74,8 +75,8 @@ void setUp() { ); } - @Test @DisplayName("기물이 없는 빈 칸을 선택하려 하는 경우, IllegalArgumentException이 발생한다.") + @Test void 빈_칸_선택_예외_테스트() { // given Board board = Board.initialize(); @@ -89,4 +90,78 @@ void setUp() { assertThat(board.getPiecePosition().get(targetPosition)).isNull(); } + + @DisplayName("양쪽 궁이 모두 존재하는 경우, 게임은 계속 진행된다.") + @Test + void 양쪽_궁_모두_존재_테스트() { + // given + Board board = Board.initialize(); + + // when & then + assertThat(board.isGameOver()).isFalse(); + } + + @DisplayName("한쪽 궁이 잡히면, 게임은 종료된다.") + @Test + void 궁_제거_게임_종료_테스트() { + // given + Board board = Board.initialize(); + + // when + Position selectedPosition = new Position(6, 4); + Position targetPosition = new Position(1, 4); + board.movePiece(selectedPosition, targetPosition); + + // then + assertThat(board.isGameOver()).isTrue(); + } + + @DisplayName("초기 보드 상태에서, 각 진영의 점수를 계산한다.") + @Test + void 초기_점수_계산_테스트() { + // given & when + double choScore = board.calculateScore(Side.CHO); + double hanScore = board.calculateScore(Side.HAN); + + // then + assertAll( + () -> assertThat(choScore).isEqualTo(72.0), + () -> assertThat(hanScore).isEqualTo(73.5) // 72.0 + 1.5 + ); + } + + @DisplayName("기물을 잡았을 때, 해당 진영의 점수가 감소한다.") + @Test + void 기물_제거_점수_감소_테스트() { + // given + Position choChariot = new Position(9, 0); // 초 진영 차 (13점) + Position target = new Position(0, 0); // 한 진영 차 + + // when + board.movePiece(choChariot, target); + + // then + // 한 진영 점수: 73.5 - 13.0 = 60.5 + assertThat(board.calculateScore(Side.HAN)).isEqualTo(60.5); + } + + @DisplayName("점수가 더 높더라도, 궁이 잡히면 게임이 종료된다.") + @Test + void 점수_상관없이_궁_잡히면_게임_종료_테스트() { + // given + // 초기 상태 73.5 vs 72.0 + assertThat(board.calculateScore(Side.HAN)).isGreaterThan(board.calculateScore(Side.CHO)); + + // when + // 한 진영 궁 제거 + Position choSoldier = new Position(6, 4); + Position hanGeneral = new Position(1, 4); + board.movePiece(choSoldier, hanGeneral); + + // then + assertAll( + () -> assertThat(board.isGameOver()).isTrue(), + () -> assertThat(board.calculateScore(Side.HAN)).isEqualTo(73.5) + ); + } } diff --git a/src/test/java/janggi/domain/board/DirectionTest.java b/src/test/java/janggi/domain/board/DirectionTest.java new file mode 100644 index 0000000000..fc9178f35e --- /dev/null +++ b/src/test/java/janggi/domain/board/DirectionTest.java @@ -0,0 +1,28 @@ +package janggi.domain.board; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class DirectionTest { + @DisplayName("방향에 따라 인접한 대각선 방향을 반환한다.") + @ParameterizedTest + @CsvSource({ + "N, NE, NW", + "S, SE, SW", + "E, NE, SE", + "W, NW, SW" + }) + void 직선_방향_인접_대각선_반환_테스트(Direction direction, Direction diagonal1, Direction diagonal2) { + assertThat(direction.getAdjacentDiagonals()).containsExactlyInAnyOrder(diagonal1, diagonal2); + } + + @DisplayName("대각선 방향의 경우, 빈 리스트를 반환한다.") + @Test + void 대각선_방향_인접_대각선_빈_리스트_반환_테스트() { + assertThat(Direction.NE.getAdjacentDiagonals()).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/janggi/domain/board/PositionTest.java b/src/test/java/janggi/domain/board/PositionTest.java new file mode 100644 index 0000000000..d85b95e989 --- /dev/null +++ b/src/test/java/janggi/domain/board/PositionTest.java @@ -0,0 +1,62 @@ +package janggi.domain.board; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +public class PositionTest { + @DisplayName("범위 내의 행과 열에 맞는 객체를 생성한다.") + @Test + void 객체_정상_생성_테스트() { + // given + int row = 4; + int column = 4; + + // when + Position position = new Position(row, column); + + // then + assertThat(position.row()).isEqualTo(row); + assertThat(position.column()).isEqualTo(column); + } + + @DisplayName("행과 열이 범위를 벗어나는 경우, IllegalArgumentException이 발생한다.") + @ParameterizedTest + @CsvSource(value = {"10, 4", "4, 9", "-1, 1", "1, -1"}) + void 범위_예외_객체_생성_테스트(int row, int column) { + assertThatThrownBy(() -> new Position(row, column)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("보드 범위 내로 이동하는 경우, 이동된 좌표가 담긴 Optional 객체를 반환한다.") + @Test + void 이동_정상_테스트() { + // given + Position position = new Position(4, 4); + + // when + Optional movedPosition = position.tryMove(Direction.E); + + // then + assertThat(movedPosition).isPresent(); + assertThat(movedPosition).contains(new Position(4, 5)); + } + + @DisplayName("이동 결과가 보드 범위를 벗어나는 경우, 빈 Optional을 반환한다.") + @Test + void 이동_범위_예외_테스트() { + // given + Position position = new Position(0, 0); + + // when + Optional movedPosition = position.tryMove(Direction.N); + + // then + assertThat(movedPosition).isEmpty(); + } +} diff --git a/src/test/java/janggi/domain/piece/PieceTypeTest.java b/src/test/java/janggi/domain/piece/PieceTypeTest.java new file mode 100644 index 0000000000..e9572c015a --- /dev/null +++ b/src/test/java/janggi/domain/piece/PieceTypeTest.java @@ -0,0 +1,375 @@ +package janggi.domain.piece; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import janggi.domain.board.Position; +import janggi.domain.game.Side; +import janggi.domain.route.Paths; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class PieceTypeTest { + private static final Side DEFAULT_SIDE = Side.CHO; + + @DisplayName("마(HORSE)는 보드 중앙에서 장애물이 없는 경우, 8개의 이동 경로를 생성한다.") + @Test + void 마_중앙_경로_생성_테스트() { + // given + Position center = new Position(4, 4); + + // when + Paths paths = PieceType.HORSE.calculatePaths(center, DEFAULT_SIDE); + + // then + assertThat(paths).hasSize(8); + } + + @DisplayName("마(HORSE)는 진행 방향의 멱이 막히는 경우, 해당 목적지로 이동할 수 없다.") + @Test + void 마_멱_차단_이동_검증_테스트() { + // given + Position current = new Position(4, 4); + PieceType horseType = PieceType.HORSE; + Paths paths = horseType.calculatePaths(current, DEFAULT_SIDE); + + // 남쪽 멱(5, 4)에 장애물 배치 + Map boardState = new HashMap<>(); + boardState.put(new Position(5, 4), createPiece(Side.CHO, PieceType.SOLDIER)); + + Piece movingPiece = createPiece(Side.HAN, PieceType.HORSE); + + // when + List destinations = horseType.determineDestinations(paths, boardState, movingPiece); + + // then + assertAll( + () -> assertThat(destinations).doesNotContain(new Position(6, 3), new Position(6, 5)), + () -> assertThat(destinations).hasSize(6) + ); + } + + @DisplayName("차(CHARIOT)는 장애물이 없는 경우, 보드 끝까지 이동할 수 있다.") + @Test + void 차_정상_이동_테스트() { + // given + Position current = new Position(0, 0); + PieceType chariotType = PieceType.CHARIOT; + Paths paths = chariotType.calculatePaths(current, DEFAULT_SIDE); + + Map boardState = new HashMap<>(); + Piece movingPiece = createPiece(Side.CHO, PieceType.CHARIOT); + + // when + List destinations = chariotType.determineDestinations(paths, boardState, movingPiece); + + // then + // (0, 0)에서 가로 8칸, 세로 9칸 총 17칸 이동 가능 + assertThat(destinations).hasSize(17); + } + + @DisplayName("차(CHARIOT)가 보드 구석(0, 0)에 있는 경우, 판 안쪽으로만 경로를 생성한다.") + @Test + void 차_구석_경계_경로_테스트() { + // given + Position corner = new Position(0, 0); + + // when + Paths paths = PieceType.CHARIOT.calculatePaths(corner, DEFAULT_SIDE); + + // then + assertThat(paths).hasSize(2); + } + + @DisplayName("졸(SOLDIER)은 장애물이 없는 경우, 정해진 방향으로 한 칸 이동할 수 있다.") + @Test + void 졸_정상_이동_테스트() { + // given + Position current = new Position(4, 4); + PieceType soldierType = PieceType.SOLDIER; + Paths paths = soldierType.calculatePaths(current, DEFAULT_SIDE); + + Map boardState = new HashMap<>(); + Piece movingPiece = createPiece(Side.CHO, PieceType.SOLDIER); + + // when + List destinations = soldierType.determineDestinations(paths, boardState, movingPiece); + + // then + assertThat(destinations).contains(new Position(3, 4), new Position(4, 5), new Position(4, 3)); + } + + @DisplayName("졸(SOLDIER)은 이동하려는 칸에 아군 기물이 있는 경우, 이동할 수 없다.") + @Test + void 졸_아군_차단_검증_테스트() { + // given + Position current = new Position(4, 4); + PieceType soldierType = PieceType.SOLDIER; + Paths paths = soldierType.calculatePaths(current, DEFAULT_SIDE); + + // 북쪽(3, 4)에 아군 기물 배치 + Map boardState = new HashMap<>(); + boardState.put(new Position(3, 4), createPiece(Side.CHO, PieceType.SOLDIER)); + + Piece movingPiece = createPiece(Side.CHO, PieceType.SOLDIER); + + // when + List destinations = soldierType.determineDestinations(paths, boardState, movingPiece); + + // then + assertThat(destinations).doesNotContain(new Position(3, 4)); + } + + @DisplayName("졸(SOLDIER)은 상대 진영 궁성 입구에서, 대각선 전진 경로를 생성한다.") + @Test + void 졸_상대_궁성_입구_대각선_테스트() { + // given + // 초 진영의 졸이 한 진영의 궁성 입구에 위치 + Position current = new Position(2, 5); + PieceType soldierType = PieceType.SOLDIER; + + // when + Paths paths = soldierType.calculatePaths(current, Side.CHO); + Map boardState = new HashMap<>(); + Piece movingPiece = createPiece(Side.CHO, PieceType.SOLDIER); + List destinations = soldierType.determineDestinations(paths, boardState, movingPiece); + + // then + // 직진(1, 5), 좌(2, 4), 우(2, 6) + 대각선(1, 4) + assertThat(destinations).contains(new Position(1, 4)); + assertThat(destinations).hasSize(4); + } + + @DisplayName("졸(SOLDIER)은 상대 진영 궁성 중앙에서, 대각선 전진 경로를 생성한다.") + @Test + void 졸_상대_궁성_중앙_대각선_테스트() { + // given + // 초 진영의 졸이 한 진영의 궁성 중앙에 위치 + Position current = new Position(1, 4); + PieceType soldierType = PieceType.SOLDIER; + + // when + Paths paths = soldierType.calculatePaths(current, Side.CHO); + Map boardState = new HashMap<>(); + Piece movingPiece = createPiece(Side.CHO, PieceType.SOLDIER); + List destinations = soldierType.determineDestinations(paths, boardState, movingPiece); + + // then + // 직진(0, 4), 좌(1, 3), 우(1, 5) + 대각선 2방향(0, 3, 0, 5) + assertThat(destinations).contains(new Position(0, 3), new Position(0, 5)); + assertThat(destinations).hasSize(5); + } + + @DisplayName("졸(SOLDIER)은 어떠한 상황에서도 후퇴할 수 없다.") + @Test + void 졸_후퇴_불가_검증_테스트() { + // given + Position current = new Position(4, 4); + PieceType soldierType = PieceType.SOLDIER; + + // when + Paths paths = soldierType.calculatePaths(current, Side.CHO); + Map boardState = new HashMap<>(); + Piece movingPiece = createPiece(Side.CHO, PieceType.SOLDIER); + List destinations = soldierType.determineDestinations(paths, boardState, movingPiece); + + // then + assertThat(destinations).doesNotContain(new Position(5, 4)); + } + + @DisplayName("포(CANNON)는 다리가 하나 있는 경우, 그 너머로 이동할 수 있다.") + @Test + void 포_정상_이동_테스트() { + // given + Position current = new Position(4, 0); + PieceType cannonType = PieceType.CANNON; + Paths paths = cannonType.calculatePaths(current, DEFAULT_SIDE); + + Map boardState = new HashMap<>(); + boardState.put(new Position(4, 2), createPiece(Side.CHO, PieceType.SOLDIER)); + Piece movingPiece = createPiece(Side.CHO, PieceType.CANNON); + + // when + List destinations = cannonType.determineDestinations(paths, boardState, movingPiece); + + // then + // (4, 2)를 다리로 삼아, (4, 3)부터 보드 끝(4, 8)까지 이동 가능 + assertThat(destinations).contains(new Position(4, 3), new Position(4, 8)); + } + + @Test + @DisplayName("포(CANNON)는 뛰어넘을 다리가 없는 경우, 이동할 수 없다.") + void 포_다리_없음_이동_불가_검증_테스트() { + // given + Position current = new Position(0, 0); + PieceType cannonType = PieceType.CANNON; + Paths paths = cannonType.calculatePaths(current, DEFAULT_SIDE); + + Map boardState = new HashMap<>(); + Piece movingPiece = createPiece(Side.CHO, PieceType.CANNON); + + // when + List destinations = cannonType.determineDestinations(paths, boardState, movingPiece); + + // then + assertThat(destinations).isEmpty(); + } + + @Test + @DisplayName("포(CANNON)는 상대방의 포를 잡을 수 없다.") + void 포_상대_포_포획_불가_검증_테스트() { + // given + Position current = new Position(4, 0); + PieceType cannonType = PieceType.CANNON; + Paths paths = cannonType.calculatePaths(current, DEFAULT_SIDE); + + Map boardState = new HashMap<>(); + boardState.put(new Position(4, 2), createPiece(Side.HAN, PieceType.SOLDIER)); // 다리 + boardState.put(new Position(4, 4), createPiece(Side.HAN, PieceType.CANNON)); // 적군 포 + + Piece movingPiece = createPiece(Side.CHO, PieceType.CANNON); + + // when + List destinations = cannonType.determineDestinations(paths, boardState, movingPiece); + + // then + assertThat(destinations).doesNotContain(new Position(4, 4)); + } + + @DisplayName("기물은 목적지에 적군이 있는 경우, 그 기물을 잡고 멈춘다.") + @Test + void 적군_포획_테스트() { + // given + Position current = new Position(0, 0); + PieceType chariotType = PieceType.CHARIOT; + Paths paths = chariotType.calculatePaths(current, DEFAULT_SIDE); + + Map boardState = new HashMap<>(); + Position enemyPos = new Position(0, 3); + boardState.put(enemyPos, createPiece(Side.HAN, PieceType.SOLDIER)); + + Piece movingPiece = createPiece(Side.CHO, PieceType.CHARIOT); + + // when + List destinations = chariotType.determineDestinations(paths, boardState, movingPiece); + + // then + assertAll( + () -> assertThat(destinations).contains(enemyPos), // 적군 자리는 갈 수 있음 + () -> assertThat(destinations).doesNotContain(new Position(0, 4)) // 적군 너머로는 못 감 + ); + } + + @DisplayName("상(ELEPHANT)은 보드 중앙에서 장애물이 없는 경우, 8개의 이동 경로를 생성한다.") + @Test + void 상_정상_이동_테스트() { + // given + Position current = new Position(4, 4); + PieceType elephantType = PieceType.ELEPHANT; + Paths paths = elephantType.calculatePaths(current, Side.CHO); + + Map boardState = new HashMap<>(); + Piece movingPiece = createPiece(Side.CHO, PieceType.ELEPHANT); + + // when + List destinations = elephantType.determineDestinations(paths, boardState, movingPiece); + + // then + assertThat(destinations).hasSize(8); + } + + @DisplayName("궁(PALACE)은 궁성 중앙에서 장애물이 없는 경우, 8개의 이동 경로를 생성한다.") + @Test + void 궁_정상_이동_테스트() { + // given + Position current = new Position(1, 4); + PieceType palaceType = PieceType.GENERAL; + Paths paths = palaceType.calculatePaths(current, Side.HAN); + + Map boardState = new HashMap<>(); + Piece movingPiece = createPiece(Side.HAN, PieceType.GENERAL); + + // when + List destinations = palaceType.determineDestinations(paths, boardState, movingPiece); + + // then + // 상하좌우 + 대각선 4곳 + assertThat(destinations).hasSize(8) + .containsExactlyInAnyOrder( + new Position(0, 4), new Position(2, 4), new Position(1, 3), new Position(1, 5), + new Position(0, 3), new Position(0, 5), new Position(2, 3), new Position(2, 5) + ); + } + + @DisplayName("궁(PALACE)은 궁성 내에서 아군 기물이 있는 곳으로는 이동할 수 없다.") + @Test + void 궁_아군_차단_검증_테스트() { + // given + Position current = new Position(1, 4); + PieceType palaceType = PieceType.GENERAL; + Paths paths = palaceType.calculatePaths(current, Side.HAN); + + // 북쪽(0, 4)에 아군(사) 배치 + Map boardState = new HashMap<>(); + boardState.put(new Position(0, 4), createPiece(Side.HAN, PieceType.GUARD)); + + Piece movingPiece = createPiece(Side.HAN, PieceType.GENERAL); + + // when + List destinations = palaceType.determineDestinations(paths, boardState, movingPiece); + + // then + assertThat(destinations).doesNotContain(new Position(0, 4)); + assertThat(destinations).hasSize(7); // 아군 제외 나머지 7방향 가능 + } + + @DisplayName("사(GUARD)는 궁성 꼭짓점에서 장애물이 없는 경우, 3방향으로 정상 이동한다.") + @Test + void 사_정상_이동_테스트() { + // given + Position current = new Position(9, 3); + PieceType guardType = PieceType.GUARD; + Paths paths = guardType.calculatePaths(current, Side.CHO); + + Map boardState = new HashMap<>(); + Piece movingPiece = createPiece(Side.CHO, PieceType.GUARD); + + // when + List destinations = guardType.determineDestinations(paths, boardState, movingPiece); + + // then + assertThat(destinations).hasSize(3) + .containsExactlyInAnyOrder( + new Position(8, 3), new Position(9, 4), new Position(8, 4) + ); + } + + @DisplayName("사(GUARD)는 궁성 내에서 적군 기물이 있는 곳으로 이동하여 포획할 수 있다.") + @Test + void 사_적군_포획_검증_테스트() { + // given + Position current = new Position(8, 4); + PieceType guardType = PieceType.GUARD; + Paths paths = guardType.calculatePaths(current, Side.CHO); + + // 동쪽(8, 5)에 적군(차) 배치 + Map boardState = new HashMap<>(); + Position enemyPos = new Position(8, 5); + boardState.put(enemyPos, createPiece(Side.HAN, PieceType.CHARIOT)); + + Piece movingPiece = createPiece(Side.CHO, PieceType.GUARD); + + // when + List destinations = guardType.determineDestinations(paths, boardState, movingPiece); + + // then + assertThat(destinations).contains(enemyPos); + } + + private Piece createPiece(Side side, PieceType type) { + return new Piece(side, type, "0"); + } +} diff --git a/src/test/java/janggi/domain/route/PathTest.java b/src/test/java/janggi/domain/route/PathTest.java new file mode 100644 index 0000000000..8d0ad51b6c --- /dev/null +++ b/src/test/java/janggi/domain/route/PathTest.java @@ -0,0 +1,98 @@ +package janggi.domain.route; + +import static org.assertj.core.api.Assertions.assertThat; + +import janggi.domain.board.Direction; +import janggi.domain.board.Position; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class PathTest { + @DisplayName("마의 경로를 생성한다.") + @Test + void 마_경로_생성_테스트() { + // given + Position start = new Position(0, 0); + List horseSteps = List.of(Direction.S, Direction.SE); + + // when + Optional path = Path.fromSequence(start, horseSteps); + + // then + assertThat(path).isPresent(); + assertThat(path.get()).hasSize(2); + assertThat(path.get()).containsExactly( + new Position(1, 0), + new Position(2, 1) + ); + } + + @DisplayName("상의 경로를 생성한다.") + @Test + void 상_경로_생성_테스트() { + // given + Position start = new Position(3, 3); + List elephantSteps = List.of(Direction.S, Direction.SE, Direction.SE); + + // when + Optional path = Path.fromSequence(start, elephantSteps); + + // then + assertThat(path).isPresent(); + assertThat(path.get()).hasSize(3); + assertThat(path.get()).containsExactly( + new Position(4, 3), + new Position(5, 4), + new Position(6, 5) + ); + } + + @DisplayName("이동 중간에 보드 밖으로 나가는 좌표가 포함되는 경우, 빈 경로를 반환한다.") + @Test + void 경로_생성_실패_테스트() { + // given + Position start = new Position(0, 0); + List outSteps = List.of(Direction.N, Direction.NW); + + // when + Optional path = Path.fromSequence(start, outSteps); + + // then + assertThat(path.isEmpty()).isTrue(); + } + + @DisplayName("연속 이동 경로를 생성한다.") + @Test + void 연속_이동_경로_생성_테스트() { + // given + Position start = new Position(7, 0); + Direction direction = Direction.N; + + // when + Path path = Path.fromContinuousMove(start, direction); + + // then + assertThat(path).hasSize(7); + assertThat(path).containsExactly( + new Position(6, 0), new Position(5, 0), new Position(4, 0), + new Position(3, 0), new Position(2, 0), new Position(1, 0), + new Position(0, 0) + ); + } + + @DisplayName("보드 끝에서 이동할 수 없는 방향으로 이동을 시도하는 경우, 빈 경로를 반환한다.") + @Test + void 보드_끝_이동_시도_빈_경로_반환_테스트() { + // given + Position start = new Position(0, 0); + Direction direction = Direction.N; + + // when + Path path = Path.fromContinuousMove(start, direction); + + // then + assertThat(path.isEmpty()).isTrue(); + } +} diff --git a/src/test/java/janggi/domain/strategy/CannonMoveStrategyTest.java b/src/test/java/janggi/domain/strategy/CannonMoveStrategyTest.java new file mode 100644 index 0000000000..de0cbda5cb --- /dev/null +++ b/src/test/java/janggi/domain/strategy/CannonMoveStrategyTest.java @@ -0,0 +1,165 @@ +package janggi.domain.strategy; + +import static org.assertj.core.api.Assertions.assertThat; + +import janggi.domain.board.Direction; +import janggi.domain.board.Position; +import janggi.domain.game.Side; +import janggi.domain.piece.Piece; +import janggi.domain.piece.PieceType; +import janggi.domain.route.Path; +import janggi.domain.route.Paths; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class CannonMoveStrategyTest { + private final MoveStrategy strategy = new CannonMoveStrategy(); + + @DisplayName("장애물 여부와 상관없이 직선 경로를 생성한다. - findMovablePaths()") + @Test + void 직선_경로_생성_테스트() { + // given + Position current = new Position(0, 0); + EnumSet directions = EnumSet.of(Direction.S); + + // when + Paths paths = strategy.findMovablePaths(current, directions); + + // then + Path southPath = paths.iterator().next(); + List positions = MoveStrategyTestHelper.toList(southPath); + assertThat(positions).hasSize(9); + } + + @DisplayName("경로에 다리가 하나도 없는 경우, 이동할 수 없다. - determineDestinations()") + @Test + void 다리_없는_경우_이동_불가_테스트() { + // given + Paths routes = MoveStrategyTestHelper.createRoute( + List.of(new Position(1, 0), new Position(2, 0), new Position(3, 0))); + Map boardState = new HashMap<>(); // 빈 보드 (다리 없음) + Piece me = new Piece(Side.CHO, PieceType.CANNON, "1"); + + // when + List destinations = strategy.determineDestinations(routes, boardState, me); + + // then + assertThat(destinations).isEmpty(); + } + + @DisplayName("포는 포를 다리로 삼아 넘을 수 없다. - determineDestinations()") + @Test + void 포_다리_사용_불가_테스트() { + // given + Paths routes = MoveStrategyTestHelper.createRoute( + List.of(new Position(1, 0), new Position(2, 0), new Position(3, 0))); + Map boardState = new HashMap<>(); + boardState.put(new Position(1, 0), new Piece(Side.HAN, PieceType.CANNON, "1")); + + Piece me = new Piece(Side.CHO, PieceType.CANNON, "1"); + + // when + List destinations = strategy.determineDestinations(routes, boardState, me); + + // then + assertThat(destinations).isEmpty(); + } + + @DisplayName("다리를 넘은 후, 빈 칸들은 이동 가능하다. - determineDestinations()") + @Test + void 다리_너머_빈칸_이동_테스트() { + // given + Paths routes = MoveStrategyTestHelper.createRoute( + List.of(new Position(1, 0), new Position(2, 0), new Position(3, 0))); + Map boardState = new HashMap<>(); + boardState.put(new Position(1, 0), new Piece(Side.HAN, PieceType.SOLDIER, "1")); + + Piece me = new Piece(Side.CHO, PieceType.CANNON, "1"); + + // when + List destinations = strategy.determineDestinations(routes, boardState, me); + + // then + // 다리(1, 0)는 이동 불가, 그 너머인 (2, 0)과 (3, 0)만 가능 + assertThat(destinations).containsExactly(new Position(2, 0), new Position(3, 0)); + } + + @DisplayName("다리를 넘은 후, 적군 포는 잡을 수 없다. - determineDestinations()") + @Test + void 적군_포_포획_불가_테스트() { + // given + Paths routes = MoveStrategyTestHelper.createRoute(List.of(new Position(1, 0), new Position(2, 0))); + Map boardState = new HashMap<>(); + boardState.put(new Position(1, 0), new Piece(Side.HAN, PieceType.SOLDIER, "1")); + boardState.put(new Position(2, 0), new Piece(Side.HAN, PieceType.CANNON, "1")); + + Piece me = new Piece(Side.CHO, PieceType.CANNON, "1"); + + // when + List destinations = strategy.determineDestinations(routes, boardState, me); + + // then + assertThat(destinations).isEmpty(); + } + + @DisplayName("다리를 넘은 후, 일반 적군 기물은 포획 가능하다. - determineDestinations()") + @Test + void 일반_적군_포획_테스트() { + // given + Paths routes = MoveStrategyTestHelper.createRoute(List.of(new Position(1, 0), new Position(2, 0))); + Map boardState = new HashMap<>(); + boardState.put(new Position(1, 0), new Piece(Side.HAN, PieceType.SOLDIER, "1")); // 다리 + boardState.put(new Position(2, 0), new Piece(Side.HAN, PieceType.CHARIOT, "1")); // 적군 차 + + Piece me = new Piece(Side.CHO, PieceType.CANNON, "1"); + + // when + List destinations = strategy.determineDestinations(routes, boardState, me); + + // then + assertThat(destinations).containsExactly(new Position(2, 0)); + } + + @DisplayName("궁성 내에 기물이 있는 경우, 직진 방향으로 뛰어넘을 수 있다.") + @Test + void 궁성_내_기물_직진_뛰어넘기_테스트() { + // given + Position current = new Position(2, 4); + Map boardState = new HashMap<>(); + boardState.put(new Position(1, 4), new Piece(Side.HAN, PieceType.SOLDIER, "1")); // 다리 + + Piece movingPiece = new Piece(Side.CHO, PieceType.CANNON, "1"); + EnumSet directions = EnumSet.of(Direction.N, Direction.S, Direction.E, Direction.W); + + // when + Paths paths = strategy.findMovablePaths(current, directions); + List destinations = strategy.determineDestinations(paths, boardState, movingPiece); + + // then + assertThat(destinations).containsExactly(new Position(0, 4)); + } + + @DisplayName("궁성 내에 기물이 있는 경우, 대각선 방향으로 뛰어넘을 수 있다.") + @Test + void 궁성_내_기물_대각선_뛰어넘기_테스트() { + // given + Position current = new Position(2, 5); + Map boardState = new HashMap<>(); + boardState.put(new Position(1, 4), new Piece(Side.HAN, PieceType.SOLDIER, "1")); // 다리 + + Piece movingPiece = new Piece(Side.CHO, PieceType.CANNON, "1"); + EnumSet directions = EnumSet.of(Direction.N, Direction.S, Direction.E, Direction.W); + + // when + Paths paths = strategy.findMovablePaths(current, directions); + List destinations = strategy.determineDestinations(paths, boardState, movingPiece); + + // then + assertThat(destinations).containsExactly(new Position(0, 3)); + } +} \ No newline at end of file diff --git a/src/test/java/janggi/domain/strategy/ElephantMoveStrategyTest.java b/src/test/java/janggi/domain/strategy/ElephantMoveStrategyTest.java new file mode 100644 index 0000000000..9ddf8c638d --- /dev/null +++ b/src/test/java/janggi/domain/strategy/ElephantMoveStrategyTest.java @@ -0,0 +1,108 @@ +package janggi.domain.strategy; + +import static org.assertj.core.api.Assertions.assertThat; + +import janggi.domain.board.Direction; +import janggi.domain.board.Position; +import janggi.domain.game.Side; +import janggi.domain.piece.Piece; +import janggi.domain.piece.PieceType; +import janggi.domain.route.Path; +import janggi.domain.route.Paths; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class ElephantMoveStrategyTest { + private final ElephantMoveStrategy strategy = new ElephantMoveStrategy(); + + @DisplayName("상의 이동 경로는 3개의 좌표(멱1, 멱2, 도착지)로 구성되어야 한다. - findMovablePaths()") + @Test + void 상_경로_테스트() { + // given + Position current = new Position(4, 4); + EnumSet directions = EnumSet.of(Direction.N); + + // when + Paths paths = strategy.findMovablePaths(current, directions); + + // then + List pathList = new ArrayList<>(); + paths.forEach(pathList::add); + assertThat(pathList).hasSize(2); + + List pathPositions = MoveStrategyTestHelper.toList(pathList.getFirst()); + assertThat(pathPositions).hasSize(3); + } + + @DisplayName("첫 번째 멱이나 두 번째 멱 중 하나라도 막히는 경우, 이동할 수 없다. - determineDestinations()") + @Test + void 멱_막힘_테스트() { + // given + Position transit1 = new Position(3, 4); + Position transit2 = new Position(2, 5); + Position dest = new Position(1, 6); + Paths routes = MoveStrategyTestHelper.createRoute(List.of(transit1, transit2, dest)); + + // 첫 번째 멱이 막힌 경우 + Map boardState1 = new HashMap<>(); + boardState1.put(transit1, new Piece(Side.HAN, PieceType.SOLDIER, "1")); + + Piece me = new Piece(Side.CHO, PieceType.ELEPHANT, "1"); + + assertThat(strategy.determineDestinations(routes, boardState1, me)).isEmpty(); + + // 두 번째 멱이 막힌 경우 + Map boardState2 = new HashMap<>(); + boardState2.put(transit2, new Piece(Side.HAN, PieceType.SOLDIER, "2")); + + assertThat(strategy.determineDestinations(routes, boardState2, me)).isEmpty(); + } + + @DisplayName("모든 멱이 비어있고, 도착지에 적군이 있는 경우, 포획 가능하다. - determineDestinations()") + @Test + void 정상_이동_및_포획_테스트() { + // given + Position transit1 = new Position(3, 4); + Position transit2 = new Position(2, 5); + Position dest = new Position(1, 6); + Paths routes = MoveStrategyTestHelper.createRoute(List.of(transit1, transit2, dest)); + + Map boardState = new HashMap<>(); + boardState.put(dest, new Piece(Side.HAN, PieceType.SOLDIER, "1")); + + Piece me = new Piece(Side.CHO, PieceType.ELEPHANT, "1"); + + // when + List destinations = strategy.determineDestinations(routes, boardState, me); + + // then + assertThat(destinations).containsExactly(dest); + } + + @DisplayName("멱이 비어도, 도착지에 아군이 있는 경우, 이동할 수 없다. - determineDestinations()") + @Test + void 아군_막힘_테스트() { + // given + Position transit1 = new Position(3, 4); + Position transit2 = new Position(2, 5); + Position dest = new Position(1, 6); + Paths routes = MoveStrategyTestHelper.createRoute(List.of(transit1, transit2, dest)); + + Map boardState = new HashMap<>(); + boardState.put(dest, new Piece(Side.CHO, PieceType.SOLDIER, "1")); + + Piece me = new Piece(Side.CHO, PieceType.ELEPHANT, "1"); + + // when + List destinations = strategy.determineDestinations(routes, boardState, me); + + // then + assertThat(destinations).isEmpty(); + } + +} \ No newline at end of file diff --git a/src/test/java/janggi/domain/strategy/HorseMoveStrategyTest.java b/src/test/java/janggi/domain/strategy/HorseMoveStrategyTest.java new file mode 100644 index 0000000000..a6f658a025 --- /dev/null +++ b/src/test/java/janggi/domain/strategy/HorseMoveStrategyTest.java @@ -0,0 +1,104 @@ +package janggi.domain.strategy; + +import static org.assertj.core.api.Assertions.assertThat; + +import janggi.domain.board.Direction; +import janggi.domain.board.Position; +import janggi.domain.game.Side; +import janggi.domain.piece.Piece; +import janggi.domain.piece.PieceType; +import janggi.domain.route.Path; +import janggi.domain.route.Paths; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class HorseMoveStrategyTest { + private final HorseMoveStrategy strategy = new HorseMoveStrategy(); + + @DisplayName("한 방향에 대해, 대각선으로 갈라지는 두 개의 경로를 생성한다. - findMovablePaths()") + @Test + void 마_경로_생성_테스트() { + // given + Position current = new Position(3, 3); + EnumSet directions = EnumSet.of(Direction.N); + + // when + Paths paths = strategy.findMovablePaths(current, directions); + + // then + // 북쪽 기준으로 북동, 북서 두 갈래 경로 + List pathList = new ArrayList<>(); + paths.forEach(pathList::add); + assertThat(pathList).hasSize(2); + + List firstPathPositions = MoveStrategyTestHelper.toList(pathList.getFirst()); + assertThat(firstPathPositions).hasSize(2); + assertThat(firstPathPositions.get(0)).isEqualTo(new Position(2, 3)); // 멱 위치 확인 + assertThat(firstPathPositions.get(1)).isEqualTo(new Position(1, 4)); // 도착지 확인 + } + + @DisplayName("멱에 기물이 있으면 이동할 수 없다. - determineDestinations()") + @Test + void 멱이_막힌_경우_이동_불가_테스트() { + // given + Position transit = new Position(2, 3); + Position dest = new Position(1, 4); + Paths routes = MoveStrategyTestHelper.createRoute(List.of(transit, dest)); + + Map boardState = new HashMap<>(); + boardState.put(transit, new Piece(Side.HAN, PieceType.SOLDIER, "1")); + + Piece me = new Piece(Side.CHO, PieceType.HORSE, "1"); + + // when + List destinations = strategy.determineDestinations(routes, boardState, me); + + // then + assertThat(destinations).isEmpty(); + } + + @DisplayName("멱이 비어있고, 도착지에 적군이 있으면 포획 가능하다. - determineDestinations()") + @Test + void 멱_비고_적군_존재_시_이동_가능_테스트() { + // given + Position transit = new Position(2, 3); + Position dest = new Position(1, 4); + Paths routes = MoveStrategyTestHelper.createRoute(List.of(transit, dest)); + + Map boardState = new HashMap<>(); + boardState.put(dest, new Piece(Side.HAN, PieceType.SOLDIER, "1")); + + Piece me = new Piece(Side.CHO, PieceType.HORSE, "1"); + + // when + List destinations = strategy.determineDestinations(routes, boardState, me); + + // then + assertThat(destinations).containsExactly(dest); + } + + @DisplayName("멱이 비어있어도, 도착지에 아군이 있으면 이동할 수 없다. - determineDestinations()") + @Test + void 멱_비어도_아군_존재_시_이동_불가_테스트() { + // given + Position transit = new Position(2, 3); + Position dest = new Position(1, 4); + Paths routes = MoveStrategyTestHelper.createRoute(List.of(transit, dest)); + + Map boardState = new HashMap<>(); + boardState.put(dest, new Piece(Side.CHO, PieceType.SOLDIER, "1")); + + Piece me = new Piece(Side.CHO, PieceType.HORSE, "1"); + + // when + List destinations = strategy.determineDestinations(routes, boardState, me); + + // then + assertThat(destinations).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/janggi/domain/strategy/MoveStrategyTestHelper.java b/src/test/java/janggi/domain/strategy/MoveStrategyTestHelper.java new file mode 100644 index 0000000000..2f68a04b6e --- /dev/null +++ b/src/test/java/janggi/domain/strategy/MoveStrategyTestHelper.java @@ -0,0 +1,23 @@ +package janggi.domain.strategy; + +import janggi.domain.board.Position; +import janggi.domain.route.Path; +import janggi.domain.route.Paths; +import java.util.ArrayList; +import java.util.List; + +public class MoveStrategyTestHelper { + public static List toList(Path path) { + List list = new ArrayList<>(); + path.forEach(list::add); + return list; + } + + public static Paths createRoute(List positions) { + Path path = new Path(); + positions.forEach(path::add); + Paths paths = new Paths(); + paths.addPath(path); + return paths; + } +} diff --git a/src/test/java/janggi/domain/strategy/PalaceMoveStrategyTest.java b/src/test/java/janggi/domain/strategy/PalaceMoveStrategyTest.java new file mode 100644 index 0000000000..64d28a3aab --- /dev/null +++ b/src/test/java/janggi/domain/strategy/PalaceMoveStrategyTest.java @@ -0,0 +1,68 @@ +package janggi.domain.strategy; + +import static org.assertj.core.api.Assertions.assertThat; + +import janggi.domain.board.Direction; +import janggi.domain.board.Position; +import janggi.domain.route.Path; +import janggi.domain.route.Paths; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class PalaceMoveStrategyTest { + private final MoveStrategy strategy = new PalaceMoveStrategy(); + + @DisplayName("궁성의 정중앙인 경우, 상하좌우와 대각선을 포함해 8개의 경로를 생성한다.") + @Test + void 궁성_정중앙_경로_생성_테스트() { + // given + Position current = new Position(1, 4); + EnumSet directions = EnumSet.allOf(Direction.class); + directions.remove(Direction.NONE); + + // when + Paths paths = strategy.findMovablePaths(current, directions); + + // then + List pathList = new ArrayList<>(); + paths.forEach(pathList::add); + assertThat(pathList).hasSize(8); + } + + @DisplayName("궁성의 꼭짓점인 경우, 대각선을 포함한 3개의 경로를 생성한다.") + @Test + void 궁성_꼭짓점_경로_생성_테스트() { + // given + Position current = new Position(0, 3); + EnumSet directions = EnumSet.allOf(Direction.class); + directions.remove(Direction.NONE); + + // when + Paths paths = strategy.findMovablePaths(current, directions); + + // then + List pathList = new ArrayList<>(); + paths.forEach(pathList::add); + assertThat(pathList).hasSize(3); + } + + @DisplayName("궁성의 변의 중앙인 경우, 대각선을 포함하지 않은 3개의 경로를 생성한다.") + @Test + void 궁성_변_중앙_경로_생성_테스트() { + // given + Position current = new Position(0, 4); + EnumSet directions = EnumSet.allOf(Direction.class); + directions.remove(Direction.NONE); + + // when + Paths paths = strategy.findMovablePaths(current, directions); + + // then + List pathList = new ArrayList<>(); + paths.forEach(pathList::add); + assertThat(pathList).hasSize(3); + } +} diff --git a/src/test/java/janggi/domain/strategy/SlideMoveStrategyTest.java b/src/test/java/janggi/domain/strategy/SlideMoveStrategyTest.java new file mode 100644 index 0000000000..1f7cc09385 --- /dev/null +++ b/src/test/java/janggi/domain/strategy/SlideMoveStrategyTest.java @@ -0,0 +1,172 @@ +package janggi.domain.strategy; + +import static org.assertj.core.api.Assertions.assertThat; + +import janggi.domain.board.Direction; +import janggi.domain.board.Position; +import janggi.domain.game.Side; +import janggi.domain.piece.Piece; +import janggi.domain.piece.PieceType; +import janggi.domain.route.Path; +import janggi.domain.route.Paths; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class SlideMoveStrategyTest { + private final MoveStrategy strategy = new SlideMoveStrategy(); + + @DisplayName("장애물이 없는 경우, 보드 끝까지의 경로를 생성한다. - findMovablePath()") + @Test + void 장애물_없는_경우_경로_생성_테스트() { + // given + Position current = new Position(0, 0); + EnumSet directions = EnumSet.of(Direction.S); + + // when + Paths paths = strategy.findMovablePaths(current, directions); + + // then + Path southPath = paths.iterator().next(); // 첫 번째 경로 꺼내기 + List positions = MoveStrategyTestHelper.toList(southPath); + + assertThat(positions) + .hasSize(9) // (0, 0) 제외, (1, 0) ~ (9, 0) 9칸 + .contains(new Position(1, 0), new Position(9, 0)); + } + + @DisplayName("경로 중간에 적군이 있는 경우, 그 적군 위치까지만 이동 가능하다. - determineDestinations()") + @Test + void 경로에_적군_있는_경우_이동_결정_테스트() { + // given + Paths routes = MoveStrategyTestHelper.createRoute( + List.of(new Position(1, 0), new Position(2, 0), new Position(3, 0))); + Map boardState = new HashMap<>(); + boardState.put(new Position(2, 0), new Piece(Side.HAN, PieceType.SOLDIER, "1")); + Piece movingPiece = new Piece(Side.CHO, PieceType.CHARIOT, "1"); + + // when + List destinations = strategy.determineDestinations(routes, boardState, movingPiece); + + // then + // 빈칸(1, 0) 가능, 적군(2, 0) 잡기 가능, 그 뒤(3, 0) 불가 + assertThat(destinations).containsExactly(new Position(1, 0), new Position(2, 0)); + } + + @DisplayName("경로 중간에 아군이 있는 경우, 그 직전까지만 이동 가능하다. - determineDestinations()") + @Test + void 경로에_아군_있는_경우_이동_결정_테스트() { + // given + Paths routes = MoveStrategyTestHelper.createRoute( + List.of(new Position(1, 0), new Position(2, 0), new Position(3, 0))); + Map boardState = new HashMap<>(); + boardState.put(new Position(2, 0), new Piece(Side.CHO, PieceType.SOLDIER, "1")); + Piece movingPiece = new Piece(Side.CHO, PieceType.CHARIOT, "1"); + + // when + List destinations = strategy.determineDestinations(routes, boardState, movingPiece); + + // then + // 빈칸(1, 0)만 가능, 아군(2, 0)부터 막힘 + assertThat(destinations).containsExactly(new Position(1, 0)); + } + + @DisplayName("궁성의 꼭짓점인 경우, [궁성 테두리, 궁성 중앙, 반대편 꼭짓점]을 포함하여 경로를 생성한다.") + @Test + void 궁성_꼭짓점_경로_생성_테스트() { + // given + Position current = new Position(0, 3); + EnumSet directions = EnumSet.of(Direction.N, Direction.S, Direction.E, Direction.W); + + // when + Paths paths = strategy.findMovablePaths(current, directions); + + // then + List allPositions = new ArrayList<>(); + paths.forEach(path -> path.forEach(allPositions::add)); + assertThat(allPositions).containsExactlyInAnyOrder( + // 동쪽 직진 + new Position(0, 4), new Position(0, 5), new Position(0, 6), new Position(0, 7), new Position(0, 8), + + // 서쪽 직진 + new Position(0, 2), new Position(0, 1), new Position(0, 0), + + // 남쪽 직진 + new Position(1, 3), new Position(2, 3), new Position(3, 3), new Position(4, 3), new Position(5, 3), + new Position(6, 3), new Position(7, 3), new Position(8, 3), new Position(9, 3), + + // 남동쪽 대각선 (궁성 정중앙, 우하단 꼭짓점) + new Position(1, 4), new Position(2, 5) + ); + } + + @DisplayName("궁성의 중앙인 경우, [궁성 내 상하좌우, 대각선]을 포함하여 경로를 생성한다.") + @Test + void 궁성_정중앙_경로_생성_테스트() { + // given + Position current = new Position(1, 4); + EnumSet directions = EnumSet.of(Direction.N, Direction.S, Direction.E, Direction.W); + + // when + Paths paths = strategy.findMovablePaths(current, directions); + + // then + List allReachablePositions = new ArrayList<>(); + paths.forEach(path -> path.forEach(allReachablePositions::add)); + + assertThat(allReachablePositions).containsExactlyInAnyOrder( + // 북쪽 직진 + new Position(0, 4), + + // 남쪽 직진 + new Position(2, 4), new Position(3, 4), new Position(4, 4), new Position(5, 4), + new Position(6, 4), new Position(7, 4), new Position(8, 4), new Position(9, 4), + + // 동쪽 직진 + new Position(1, 5), new Position(1, 6), new Position(1, 7), new Position(1, 8), + // 서쪽 직진 + + new Position(1, 3), new Position(1, 2), new Position(1, 1), new Position(1, 0), + + // 대각선 4방향 (궁성 꼭짓점) + new Position(0, 3), + new Position(0, 5), + new Position(2, 3), + new Position(2, 5) + ); + } + + @DisplayName("궁성의 변의 중앙인 경우, [변의 꼭짓점, 궁성 정중앙, 반대편 변의 중앙]을 포함하여 경로를 생성한다.") + @Test + void 궁성_변_중앙_경로_생성_테스트() { + // given + Position current = new Position(1, 3); + EnumSet directions = EnumSet.of(Direction.N, Direction.S, Direction.E, Direction.W); + + // when + Paths paths = strategy.findMovablePaths(current, directions); + + // then + List allReachablePositions = new ArrayList<>(); + paths.forEach(path -> path.forEach(allReachablePositions::add)); + + assertThat(allReachablePositions).containsExactlyInAnyOrder( + // 북쪽 직진 + new Position(0, 3), + + // 남쪽 직진 + new Position(2, 3), new Position(3, 3), new Position(4, 3), new Position(5, 3), + new Position(6, 3), new Position(7, 3), new Position(8, 3), new Position(9, 3), + + // 동쪽 직진 + new Position(1, 4), new Position(1, 5), new Position(1, 6), new Position(1, 7), new Position(1, 8), + + // 서쪽 직진 + new Position(1, 2), new Position(1, 1), new Position(1, 0) + ); + } +} \ No newline at end of file diff --git a/src/test/java/janggi/domain/strategy/StepMoveStrategyTest.java b/src/test/java/janggi/domain/strategy/StepMoveStrategyTest.java new file mode 100644 index 0000000000..9f6fceaa9b --- /dev/null +++ b/src/test/java/janggi/domain/strategy/StepMoveStrategyTest.java @@ -0,0 +1,107 @@ +package janggi.domain.strategy; + +import static org.assertj.core.api.Assertions.assertThat; + +import janggi.domain.board.Direction; +import janggi.domain.board.Position; +import janggi.domain.game.Side; +import janggi.domain.piece.Piece; +import janggi.domain.piece.PieceType; +import janggi.domain.route.Path; +import janggi.domain.route.Paths; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class StepMoveStrategyTest { + private final MoveStrategy strategy = new StepMoveStrategy(); + + @DisplayName("한 칸 이동 시 보드 범위를 벗어나는 경우, 경로를 생성하지 않는다. - findMovablePaths()") + @Test + void 보드_범위_밖_경로_미생성_테스트() { + // given + // (0, 0) 위치에서 북쪽으로 한 칸 이동 시도 + Position current = new Position(0, 0); + EnumSet directions = EnumSet.of(Direction.N); + + // when + Paths paths = strategy.findMovablePaths(current, directions); + + // then + assertThat(paths.iterator().hasNext()).isFalse(); + } + + @DisplayName("정상 범위인 경우, 지정된 방향으로 딱 한 칸의 좌표만 생성한다. - findMovablePaths()") + @Test + void 한_칸_경로_생성_테스트() { + // given + Position current = new Position(3, 3); + EnumSet directions = EnumSet.of(Direction.S); + + // when + Paths paths = strategy.findMovablePaths(current, directions); + + // then + Path southPath = paths.iterator().next(); + List positions = MoveStrategyTestHelper.toList(southPath); + + assertThat(positions) + .hasSize(1) + .containsExactly(new Position(4, 3)); + } + + @DisplayName("목적지가 비어있는 경우, 이동 가능하다. - determineDestinations()") + @Test + void 빈_칸_이동_가능_테스트() { + // given + Position dest = new Position(4, 3); + Paths routes = MoveStrategyTestHelper.createRoute(List.of(dest)); + Map boardState = new HashMap<>(); + Piece movingPiece = new Piece(Side.CHO, PieceType.SOLDIER, "1"); + + // when + List destinations = strategy.determineDestinations(routes, boardState, movingPiece); + + // then + assertThat(destinations).containsExactly(dest); + } + + @DisplayName("목적지에 적군이 있는 경우, 포획 가능하다. - determineDestinations()") + @Test + void 적군_존재_시_이동_가능_테스트() { + // given + Position dest = new Position(4, 3); + Paths routes = MoveStrategyTestHelper.createRoute(List.of(dest)); + + Map boardState = new HashMap<>(); + boardState.put(dest, new Piece(Side.HAN, PieceType.SOLDIER, "1")); // 적군 + Piece movingPiece = new Piece(Side.CHO, PieceType.SOLDIER, "1"); + + // when + List destinations = strategy.determineDestinations(routes, boardState, movingPiece); + + // then + assertThat(destinations).containsExactly(dest); + } + + @DisplayName("목적지에 아군이 있는 경우, 이동할 수 없다. - determineDestinations()") + @Test + void 아군_존재_시_이동_불가_테스트() { + // given + Position dest = new Position(4, 3); + Paths routes = MoveStrategyTestHelper.createRoute(List.of(dest)); + + Map boardState = new HashMap<>(); + boardState.put(dest, new Piece(Side.CHO, PieceType.SOLDIER, "2")); // 아군 + Piece movingPiece = new Piece(Side.CHO, PieceType.SOLDIER, "1"); + + // when + List destinations = strategy.determineDestinations(routes, boardState, movingPiece); + + // then + assertThat(destinations).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/janggi/infrastructure/FakeJanggiRepository.java b/src/test/java/janggi/infrastructure/FakeJanggiRepository.java new file mode 100644 index 0000000000..07d1bffdc7 --- /dev/null +++ b/src/test/java/janggi/infrastructure/FakeJanggiRepository.java @@ -0,0 +1,87 @@ +package janggi.infrastructure; + +import janggi.domain.board.Board; +import janggi.domain.game.Players; +import janggi.domain.game.PlayersSaveDTO; +import janggi.domain.game.Side; +import janggi.domain.game.Turn; +import janggi.domain.repository.JanggiRepository; +import java.util.Comparator;import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +public class FakeJanggiRepository implements JanggiRepository { + private final Map boards = new ConcurrentHashMap<>(); + private final Map playersMap = new ConcurrentHashMap<>(); + private final Map turns = new ConcurrentHashMap<>(); + private final Map finishedStatus = new ConcurrentHashMap<>(); + + // ID 생성용 + private final AtomicLong idGenerator = new AtomicLong(0); + + @Override + public Long save(PlayersSaveDTO dto) { + long id = idGenerator.incrementAndGet(); + + Players players = Players.fromSavedStatus( + dto.choPlayerName(), + dto.hanPlayerName(), + Side.valueOf(dto.turn()) + ); + + playersMap.put(id, players); + turns.put(id, players.getTurn()); + finishedStatus.put(id, false); // 진행 중 + + return id; + } + + @Override + public void updateGameStatus(Long gameId, Board board, Turn turn) { + boards.put(gameId, board); + turns.put(gameId, turn); + } + + @Override + public Optional findInProgressGameId() { + return finishedStatus.entrySet().stream() + .filter(entry -> !entry.getValue()) + .map(Map.Entry::getKey) + .sorted(Comparator.reverseOrder()) + .findFirst(); + } + + @Override + public Board findBoardById(Long gameId) { + return Optional.ofNullable(boards.get(gameId)) + .orElseThrow(() -> new NoSuchElementException("해당 ID의 보드가 없습니다: " + gameId)); + } + + @Override + public Players findPlayersById(Long gameId) { + Players savedPlayers = Optional.ofNullable(playersMap.get(gameId)) + .orElseThrow(() -> new NoSuchElementException("해당 ID의 플레이어 정보가 없습니다: " + gameId)); + + Turn currentTurn = turns.getOrDefault(gameId, savedPlayers.getTurn()); + + return Players.fromSavedStatus( + savedPlayers.getChoPlayerName(), + savedPlayers.getHanPlayerName(), + currentTurn.getSide() + ); + } + + @Override + public Turn findTurnById(Long gameId) { + return Optional.ofNullable(turns.get(gameId)) + .orElseThrow(() -> new NoSuchElementException("해당 ID의 턴 정보가 없습니다: " + gameId)); + } + + @Override + public void finishGame(Long gameId) { + finishedStatus.put(gameId, true); + } +} diff --git a/src/test/java/janggi/infrastructure/FakeJanggiRepositoryTest.java b/src/test/java/janggi/infrastructure/FakeJanggiRepositoryTest.java new file mode 100644 index 0000000000..8783811511 --- /dev/null +++ b/src/test/java/janggi/infrastructure/FakeJanggiRepositoryTest.java @@ -0,0 +1,155 @@ +package janggi.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import janggi.domain.board.Board; +import janggi.domain.game.Players; +import janggi.domain.game.PlayersSaveDTO; +import janggi.domain.game.Side; +import janggi.domain.game.Turn; +import janggi.domain.repository.JanggiRepository; +import java.util.NoSuchElementException; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class FakeJanggiRepositoryTest { + private final JanggiRepository repository = new FakeJanggiRepository(); + + @DisplayName("새 게임을 시작하는 경우, 게임 ID가 생성된다.") + @Test + void 새_게임_시작_게임_아이디_생성_테스트() { + // given + Players players = Players.createInitial("pobi", "jason"); + PlayersSaveDTO dto = PlayersSaveDTO.from(players); + + // when + Long gameId = repository.save(dto); + + // then + assertThat(gameId).isNotNull(); + } + + @DisplayName("턴이 끝날 때마다, 게임 상태를 저장한다.") + @Test + void 턴_종료_시마다_게임_상태_저장_테스트() { + // given + Players players = Players.createInitial("pobi", "jason"); + Long gameId = repository.save(PlayersSaveDTO.from(players)); + Board board = Board.initialize(); + Turn turn = Turn.from(Side.CHO); + + // when + repository.updateGameStatus(gameId, board, turn); + + // then + assertThat(repository.findBoardById(gameId)).isEqualTo(board); + assertThat(repository.findTurnById(gameId)).isEqualTo(turn); + assertThat(repository.findPlayersById(gameId)).isEqualTo(players); + } + + @DisplayName("진행 중인 가장 최근 게임을 조회한다.") + @Test + void 진행_중인_가장_최근_게임_조회_테스트() { + // given + repository.save(PlayersSaveDTO.from(Players.createInitial("pobi", "jason"))); + Long secondGameId = repository.save(PlayersSaveDTO.from(Players.createInitial("gugu", "lisa"))); + + // when + Optional lastGameId = repository.findInProgressGameId(); + + // then + assertThat(lastGameId).isPresent(); + assertThat(lastGameId.get()).isEqualTo(secondGameId); + } + + @DisplayName("ID에 맞는 보드를 조회한다.") + @Test + void 게임_아이디로_보드_조회_테스트() { + // given + Long gameId = repository.save(PlayersSaveDTO.from(Players.createInitial("pobi", "jason"))); + Board board = Board.initialize(); + repository.updateGameStatus(gameId, board, Turn.from(Side.CHO)); + + // when + Board foundBoard = repository.findBoardById(gameId); + + // then + assertThat(foundBoard).isEqualTo(board); + } + + @DisplayName("데이터가 없는 ID로 보드를 조회하는 경우, NoSuchElementException이 발생한다.") + @Test + void 게임_아이디로_보드_조회_예외_테스트() { + assertThatThrownBy(() -> repository.findBoardById(999L)) + .isInstanceOf(NoSuchElementException.class); + } + + @DisplayName("ID에 맞는 플레이어를 조회한다.") + @Test + void 게임_아이디로_플레이어_조회_테스트() { + // given + Players players = Players.createInitial("pobi", "jason"); + PlayersSaveDTO dto = PlayersSaveDTO.from(players); + Long gameId = repository.save(dto); + + // when + Players foundPlayers = repository.findPlayersById(gameId); + + // then + assertThat(foundPlayers).isEqualTo(players); + } + + @DisplayName("데이터가 없는 ID로 플레이어를 조회하는 경우, NoSuchElementException이 발생한다.") + @Test + void 게임_아이디로_플레이어_조회_예외_테스트() { + assertThatThrownBy(() -> repository.findPlayersById(999L)) + .isInstanceOf(NoSuchElementException.class); + } + + @DisplayName("ID에 맞는 턴을 조회한다.") + @Test + void 게임_아이디로_턴_조회_테스트() { + // given + Long gameId = repository.save(PlayersSaveDTO.from(Players.createInitial("pobi", "jason"))); + Turn turn = Turn.from(Side.CHO); + repository.updateGameStatus(gameId, Board.initialize(), turn); + + // when + Turn foundTurn = repository.findTurnById(gameId); + + // then + assertThat(foundTurn).isEqualTo(turn); + } + + @DisplayName("게임을 종료한다.") + @Test + void 게임_종료_테스트() { + // given + Long gameId = repository.save(PlayersSaveDTO.from(Players.createInitial("pobi", "jason"))); + + // when + repository.finishGame(gameId); + + // then + assertThat(repository.findInProgressGameId()).isEmpty(); + } + + @DisplayName("한(HAN) 진영 턴에서 중단된 게임을 재시작하는 경우, 한(HAN) 진영 턴으로 복구된다.") + @Test + void 게임_재시작_시_턴_교체_테스트() { + // given + Players initialPlayers = Players.createInitial("pobi", "jason"); + Long gameId = repository.save(PlayersSaveDTO.from(initialPlayers)); // CHO 턴으로 저장 + + // when + Board board = Board.initialize(); + Turn hanTurn = Turn.from(Side.HAN); + repository.updateGameStatus(gameId, board, hanTurn); + Players loadedPlayers = repository.findPlayersById(gameId); + + // then + assertThat(loadedPlayers.getTurn().getSide()).isEqualTo(Side.HAN); + } +} diff --git a/src/test/java/janggi/infrastructure/JdbcJanggiRepositoryTest.java b/src/test/java/janggi/infrastructure/JdbcJanggiRepositoryTest.java new file mode 100644 index 0000000000..7e50d5cd27 --- /dev/null +++ b/src/test/java/janggi/infrastructure/JdbcJanggiRepositoryTest.java @@ -0,0 +1,219 @@ +package janggi.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import janggi.domain.board.Board; +import janggi.domain.game.Players; +import janggi.domain.game.PlayersSaveDTO; +import janggi.domain.game.Side; +import janggi.domain.game.Turn; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.NoSuchElementException; +import java.util.Optional; +import javax.sql.DataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class JdbcJanggiRepositoryTest { + private static DataSource dataSource; + private JdbcJanggiRepository repository; + + @BeforeAll + static void initAll() { + dataSource = new TestDataSource( + "jdbc:h2:mem:janggi;MODE=MySQL;DB_CLOSE_DELAY=-1", + "sa", + "" + ); + + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + + // game 테이블 생성 + stmt.execute("CREATE TABLE IF NOT EXISTS game (" + + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + + "cho_player VARCHAR(50) NOT NULL, " + + "han_player VARCHAR(50) NOT NULL, " + + "current_turn VARCHAR(10) NOT NULL, " + + "is_finished BOOLEAN DEFAULT FALSE, " + + "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"); + + // board_state 테이블 생성 + stmt.execute("CREATE TABLE IF NOT EXISTS board_state (" + + "game_id BIGINT, " + + "row_index INT, " + + "col_index INT, " + + "piece_type VARCHAR(20), " + + "side VARCHAR(10), " + + "piece_number VARCHAR(10), " + + "PRIMARY KEY (game_id, row_index, col_index), " + + "FOREIGN KEY (game_id) REFERENCES game(id) ON DELETE CASCADE)"); + + } catch (SQLException e) { + throw new RuntimeException("[ERROR] H2 스키마 초기화 실패: " + e.getMessage()); + } + } + + @BeforeEach + void setUp() { + repository = new JdbcJanggiRepository(dataSource); + } + + @AfterEach + void tearDown() { + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + // 제약 조건을 끄고 전체 삭제 + stmt.execute("SET REFERENTIAL_INTEGRITY FALSE"); + stmt.execute("TRUNCATE TABLE board_state"); + stmt.execute("TRUNCATE TABLE game"); + stmt.execute("SET REFERENTIAL_INTEGRITY TRUE"); + } catch (SQLException e) { + e.printStackTrace(); + } + } + + @DisplayName("새 게임을 시작하는 경우, 게임 ID가 생성된다.") + @Test + void 새_게임_시작_게임_아이디_생성_테스트() { + // given + PlayersSaveDTO dto = new PlayersSaveDTO("pobi", "jason", "CHO"); + + // when + Long gameId = repository.save(dto); + + // then + assertThat(gameId).isNotNull(); + assertThat(gameId).isGreaterThan(0); + } + + @DisplayName("턴이 끝날 때마다, 게임 상태를 저장한다.") + @Test + void 턴_종료_시마다_게임_상태_저장_테스트() { + // given + Players players = Players.createInitial("pobi", "jason"); + Long gameId = repository.save(PlayersSaveDTO.from(players)); + Board board = Board.initialize(); + Turn turn = Turn.from(Side.HAN); + + // when + repository.updateGameStatus(gameId, board, turn); + + // then + assertThat(repository.findBoardById(gameId)).isEqualTo(board); + assertThat(repository.findTurnById(gameId)).isEqualTo(turn); + assertThat(repository.findPlayersById(gameId)).isEqualTo(players); + } + + @DisplayName("진행 중인 가장 최근 게임을 조회한다.") + @Test + void 진행_중인_가장_최근_게임_조회_테스트() { + // given + repository.save(PlayersSaveDTO.from(Players.createInitial("pobi", "jason"))); + Long secondGameId = repository.save(PlayersSaveDTO.from(Players.createInitial("gugu", "lisa"))); + + // when + Optional lastGameId = repository.findInProgressGameId(); + + // then + assertThat(lastGameId).isPresent(); + assertThat(lastGameId.get()).isEqualTo(secondGameId); + } + + @DisplayName("ID에 맞는 보드를 조회한다.") + @Test + void 게임_아이디로_보드_조회_테스트() { + // given + Long gameId = repository.save(PlayersSaveDTO.from(Players.createInitial("pobi", "jason"))); + Board board = Board.initialize(); + repository.updateGameStatus(gameId, board, Turn.from(Side.CHO)); + + // when + Board foundBoard = repository.findBoardById(gameId); + + // then + assertThat(foundBoard).isEqualTo(board); + } + + @DisplayName("데이터가 없는 ID로 보드를 조회하는 경우, NoSuchElementException이 발생한다.") + @Test + void 게임_아이디로_보드_조회_예외_테스트() { + assertThatThrownBy(() -> repository.findBoardById(999L)) + .isInstanceOf(NoSuchElementException.class) + .hasMessage("[ERROR] 해당 ID의 게임을 찾을 수 없습니다."); + } + + @DisplayName("ID에 맞는 플레이어를 조회한다.") + @Test + void 게임_아이디로_플레이어_조회_테스트() { + // given + Players players = Players.createInitial("pobi", "jason"); + PlayersSaveDTO dto = PlayersSaveDTO.from(players); + Long gameId = repository.save(dto); + + // when + Players foundPlayers = repository.findPlayersById(gameId); + + // then + assertThat(foundPlayers).isEqualTo(players); + } + + @DisplayName("데이터가 없는 ID로 플레이어를 조회하는 경우, RuntimeException이 발생한다.") + @Test + void 게임_아이디로_플레이어_조회_예외_테스트() { + assertThatThrownBy(() -> repository.findPlayersById(999L)) + .isInstanceOf(RuntimeException.class) + .hasMessage("[ERROR] 해당 ID의 게임을 찾을 수 없습니다."); + } + + @DisplayName("ID에 맞는 턴을 조회한다.") + @Test + void 게임_아이디로_턴_조회_테스트() { + // given + Long gameId = repository.save(PlayersSaveDTO.from(Players.createInitial("pobi", "jason"))); + Turn turn = Turn.from(Side.CHO); + repository.updateGameStatus(gameId, Board.initialize(), turn); + + // when + Turn foundTurn = repository.findTurnById(gameId); + + // then + assertThat(foundTurn).isEqualTo(turn); + } + + @DisplayName("게임을 종료한다.") + @Test + void 게임_종료_테스트() { + // given + Long gameId = repository.save(PlayersSaveDTO.from(Players.createInitial("pobi", "jason"))); + + // when + repository.finishGame(gameId); + + // then + assertThat(repository.findInProgressGameId()).isEmpty(); + } + + @DisplayName("한(HAN) 진영 턴에서 중단된 게임을 재시작하는 경우, 한(HAN) 진영 턴으로 복구된다.") + @Test + void 게임_재시작_시_턴_교체_테스트() { + // given + Players initialPlayers = Players.createInitial("pobi", "jason"); + Long gameId = repository.save(PlayersSaveDTO.from(initialPlayers)); // CHO 턴으로 저장 + + // when + Board board = Board.initialize(); + Turn hanTurn = Turn.from(Side.HAN); + repository.updateGameStatus(gameId, board, hanTurn); + Players loadedPlayers = repository.findPlayersById(gameId); + + // then + assertThat(loadedPlayers.getTurn().getSide()).isEqualTo(Side.HAN); + } +} diff --git a/src/test/java/janggi/infrastructure/TestDataSource.java b/src/test/java/janggi/infrastructure/TestDataSource.java new file mode 100644 index 0000000000..2f2313c980 --- /dev/null +++ b/src/test/java/janggi/infrastructure/TestDataSource.java @@ -0,0 +1,66 @@ +package janggi.infrastructure; + +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.logging.Logger; +import javax.sql.DataSource; + +public class TestDataSource implements DataSource { + private final String url; + private final String user; + private final String password; + + public TestDataSource(String url, String user, String password) { + this.url = url; + this.user = user; + this.password = password; + } + + @Override + public Connection getConnection() throws SQLException { + return DriverManager.getConnection(url, user, password); + } + + @Override + public Connection getConnection(String username, String password) throws SQLException { + return null; + } + + @Override + public PrintWriter getLogWriter() throws SQLException { + return null; + } + + @Override + public void setLogWriter(PrintWriter out) throws SQLException { + + } + + @Override + public void setLoginTimeout(int seconds) throws SQLException { + + } + + @Override + public int getLoginTimeout() throws SQLException { + return 0; + } + + @Override + public Logger getParentLogger() throws SQLFeatureNotSupportedException { + return null; + } + + @Override + public T unwrap(Class iface) throws SQLException { + return null; + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + return false; + } +}