diff --git a/unitary/examples/quantum_chinese_chess/board.py b/unitary/examples/quantum_chinese_chess/board.py index bcbe83b5..c2401315 100644 --- a/unitary/examples/quantum_chinese_chess/board.py +++ b/unitary/examples/quantum_chinese_chess/board.py @@ -11,7 +11,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from typing import List +import numpy as np +from typing import List, Tuple import unitary.alpha as alpha from unitary.examples.quantum_chinese_chess.enums import ( SquareState, @@ -34,6 +35,7 @@ def __init__( ): self.board = board self.current_player = current_player + # This saves the locations of KINGs in the order of [RED_KING_LOCATION, BLACK_KING_LOCATION]. self.king_locations = king_locations self.lang = Language.EN # The default language is English. @@ -76,6 +78,11 @@ def from_fen(cls, fen: str = _INITIAL_FEN) -> "Board": board = alpha.QuantumWorld(chess_board.values()) # Here 0 means the player RED while 1 the player BLACK. current_player = 0 if "w" in turns else 1 + # TODO(): maybe add check to make sure the input fen itself is correct. + if len(king_locations) != 2: + raise ValueError( + f"We expect two KINGs on the board, but got {len(king_locations)}." + ) return cls(board, current_player, king_locations) def __str__(self): @@ -119,3 +126,65 @@ def __str__(self): .replace("abcdefghi", " abcdefghi") .translate(translation) ) + + def path_pieces(self, source: str, target: str) -> Tuple[List[str], List[str]]: + """Returns the nonempty classical and quantum pieces from source to target (excluded).""" + x0 = ord(source[0]) + x1 = ord(target[0]) + dx = x1 - x0 + y0 = int(source[1]) + y1 = int(target[1]) + dy = y1 - y0 + # In case of only moving one step, return empty path pieces. + if abs(dx) + abs(dy) <= 1: + return [], [] + # In case of advisor moving, return empty path pieces. + # TODO(): maybe move this to the advisor move check. + if abs(dx) == 1 and abs(dy) == 1: + return [], [] + pieces = [] + classical_pieces = [] + quantum_pieces = [] + dx_sign = np.sign(dx) + dy_sign = np.sign(dy) + # In case of elephant move, there should only be one path piece. + if abs(dx) == abs(dy): + pieces.append(f"{chr(x0 + dx_sign)}{y0 + dy_sign}") + # This could be move of rook, king, pawn or cannon. + elif dx == 0: + for i in range(1, abs(dy)): + pieces.append(f"{chr(x0)}{y0 + dy_sign * i}") + # This could be move of rook, king, pawn or cannon. + elif dy == 0: + for i in range(1, abs(dx)): + pieces.append(f"{chr(x0 + dx_sign * i)}{y0}") + # This covers four possible directions of horse move. + elif abs(dx) == 2 and abs(dy) == 1: + pieces.append(f"{chr(x0 + dx_sign)}{y0}") + # This covers the other four possible directions of horse move. + elif abs(dy) == 2 and abs(dx) == 1: + pieces.append(f"{chr(x0)}{y0 + dy_sign}") + else: + raise ValueError("Unexpected input to path_pieces().") + for piece in pieces: + if self.board[piece].is_entangled: + quantum_pieces.append(piece) + elif self.board[piece].type_ != Type.EMPTY: + classical_pieces.append(piece) + return classical_pieces, quantum_pieces + + def flying_general_check(self) -> bool: + """Check and return if the two KINGs are directly facing each other (i.e. in the same column) without any pieces in between.""" + king_0 = self.king_locations[0] + king_1 = self.king_locations[1] + if king_0[0] != king_1[0]: + # If they are in different columns, the check fails. Game continues. + return False + classical_pieces, quantum_pieces = self.path_pieces(king_0, king_1) + if len(classical_pieces) > 0: + # If there are classical pieces between two KINGs, the check fails. Game continues. + return False + if len(quantum_pieces) == 0: + # If there are no pieces between two KINGs, the check successes. Game ends. + return True + # TODO(): add check when there are quantum pieces in between. diff --git a/unitary/examples/quantum_chinese_chess/board_test.py b/unitary/examples/quantum_chinese_chess/board_test.py index 0ba0e73f..6aec090b 100644 --- a/unitary/examples/quantum_chinese_chess/board_test.py +++ b/unitary/examples/quantum_chinese_chess/board_test.py @@ -12,8 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from unitary.examples.quantum_chinese_chess.enums import Language +from unitary.examples.quantum_chinese_chess.enums import ( + Language, + Color, + Type, + SquareState, +) from unitary.examples.quantum_chinese_chess.board import Board +from unitary.examples.quantum_chinese_chess.piece import Piece def test_init_with_default_fen(): @@ -99,3 +105,53 @@ def test_init_with_specified_fen(): ) assert board.king_locations == ["e9", "e0"] + + +def test_path_pieces(): + board = Board.from_fen() + # In case of only moving one step, return empty path pieces. + assert board.path_pieces("a0", "a1") == ([], []) + + # In case of advisor moving, return empty path pieces. + assert board.path_pieces("d0", "e1") == ([], []) + + # In case of elephant move, there should be at most one path piece. + assert board.path_pieces("c0", "e2") == ([], []) + # Add one classical piece in the path. + board.board["d1"].reset(Piece("d1", SquareState.OCCUPIED, Type.ROOK, Color.RED)) + assert board.path_pieces("c0", "e2") == (["d1"], []) + # Add one quantum piece in the path. + board.board["d1"].is_entangled = True + assert board.path_pieces("c0", "e2") == ([], ["d1"]) + + # Horizontal move + board.board["c7"].reset(Piece("c7", SquareState.OCCUPIED, Type.ROOK, Color.RED)) + board.board["c7"].is_entangled = True + assert board.path_pieces("a7", "i7") == (["b7", "h7"], ["c7"]) + + # Vertical move + assert board.path_pieces("c0", "c9") == (["c3", "c6"], ["c7"]) + + # In case of horse move, there should be at most one path piece. + assert board.path_pieces("b9", "a7") == ([], []) + assert board.path_pieces("b9", "c7") == ([], []) + # One classical piece in path. + assert board.path_pieces("b9", "d8") == (["c9"], []) + # One quantum piece in path. + assert board.path_pieces("c8", "d6") == ([], ["c7"]) + + +def test_flying_general_check(): + board = Board.from_fen() + # If they are in different columns, the check fails. + board.king_locations = ["d0", "e9"] + assert board.flying_general_check() == False + + # If there are classical pieces between two KINGs, the check fails. + board.king_locations = ["e0", "e9"] + assert board.flying_general_check() == False + + # If there are no pieces between two KINGs, the check successes. + board.board["e3"].reset() + board.board["e6"].reset() + assert board.flying_general_check() == True diff --git a/unitary/examples/quantum_chinese_chess/chess.py b/unitary/examples/quantum_chinese_chess/chess.py index bca5db3d..496ad8cc 100644 --- a/unitary/examples/quantum_chinese_chess/chess.py +++ b/unitary/examples/quantum_chinese_chess/chess.py @@ -13,7 +13,14 @@ # limitations under the License. from typing import Tuple, List from unitary.examples.quantum_chinese_chess.board import Board -from unitary.examples.quantum_chinese_chess.enums import Language, GameState, Type +from unitary.examples.quantum_chinese_chess.enums import ( + Language, + GameState, + Type, + Color, + MoveType, + MoveVariant, +) from unitary.examples.quantum_chinese_chess.move import Move # List of accepable commands. @@ -35,6 +42,23 @@ class QuantumChineseChess: """A class that implements Quantum Chinese Chess using the unitary API.""" + def print_welcome(self) -> None: + """Prints the welcome message. Gets board language and players' name.""" + print(_WELCOME_MESSAGE) + print(_HELP_TEXT) + # TODO(): add the whole set of Chinese interface support. + lang = input( + "Switch to Chinese board characters? (y/n) (default to be English) " + ) + if lang.lower() == "y": + self.lang = Language.ZH + else: + self.lang = Language.EN + name_0 = input("Player 0's name (default to be Player_0): ") + self.players_name.append("Player_0" if len(name_0) == 0 else name_0) + name_1 = input("Player 1's name (default to be Player_1): ") + self.players_name.append("Player_1" if len(name_1) == 0 else name_1) + def __init__(self): self.players_name = [] self.print_welcome() @@ -45,17 +69,6 @@ def __init__(self): self.current_player = self.board.current_player self.debug_level = 3 - def game_over(self) -> None: - """Checks if the game is over, and update self.game_state accordingly.""" - if self.game_state != GameState.CONTINUES: - return - return - # TODO(): add the following checks - # - The current player wins if general is captured in the current move. - # - The other player wins if the flying general rule is satisfied, i.e. there is no piece - # (after measurement) between two generals. - # - If player 0 made N repeatd back-and_forth moves in a row. - @staticmethod def parse_input_string(str_to_parse: str) -> Tuple[List[str], List[str]]: """Check if the input string could be turned into a valid move. @@ -105,19 +118,283 @@ def parse_input_string(str_to_parse: str) -> Tuple[List[str], List[str]]: ) return sources, targets + @classmethod + def _is_in_palace(self, color: Color, x: int, y: int) -> bool: + """Check if the given location is within palace. This check will be applied to all KING ans ADVISOR moves.""" + return ( + x <= ord("f") + and x >= ord("d") + and ((color == Color.RED and y >= 7) or (color == Color.BLACK and y <= 2)) + ) + + def check_classical_rule( + self, source: str, target: str, classical_path_pieces: List[str] + ) -> None: + """Check if the proposed move satisfies classical rules, and raises ValueError if not. + + Args: + source: the name of the source piece + target: the name of the target piece + classical_path_pieces: the names of the path pieces from source to target (excluded) + """ + source_piece = self.board.board[source] + target_piece = self.board.board[target] + # Check if the move is blocked by classical path piece. + if len(classical_path_pieces) > 0 and source_piece.type_ != Type.CANNON: + # The path is blocked by classical pieces. + raise ValueError("The path is blocked.") + + # Check if the target has classical piece of the same color. + if not target_piece.is_entangled and source_piece.color == target_piece.color: + raise ValueError( + "The target place has classical piece with the same color." + ) + + # Check if the move violates any classical rule. + x0 = ord(source[0]) + x1 = ord(target[0]) + dx = x1 - x0 + y0 = int(source[1]) + y1 = int(target[1]) + dy = y1 - y0 + + if source_piece.type_ == Type.ROOK: + if dx != 0 and dy != 0: + raise ValueError("ROOK cannot move like this.") + elif source_piece.type_ == Type.HORSE: + if not ((abs(dx) == 2 and abs(dy) == 1) or (abs(dx) == 1 and abs(dy) == 2)): + raise ValueError("HORSE cannot move like this.") + elif source_piece.type_ == Type.ELEPHANT: + if not (abs(dx) == 2 and abs(dy) == 2): + raise ValueError("ELEPHANT cannot move like this.") + if (source_piece.color == Color.RED and y1 < 5) or ( + source_piece.color == Color.BLACK and y1 > 4 + ): + raise ValueError( + "ELEPHANT cannot cross the river (i.e. the middle line)." + ) + elif source_piece.type_ == Type.ADVISOR: + if not (abs(dx) == 1 and abs(dy) == 1): + raise ValueError("ADVISOR cannot move like this.") + if not self._is_in_palace(source_piece.color, x1, y1): + raise ValueError("ADVISOR cannot leave the palace.") + elif source_piece.type_ == Type.KING: + if abs(dx) + abs(dy) != 1: + raise ValueError("KING cannot move like this.") + if not self._is_in_palace(source_piece.color, x1, y1): + raise ValueError("KING cannot leave the palace.") + elif source_piece.type_ == Type.CANNON: + if dx != 0 and dy != 0: + raise ValueError("CANNON cannot move like this.") + if len(classical_path_pieces) > 0: + if len(classical_path_pieces) > 1: + # Invalid cannon move, since there could only be at most one classical piece between + # the source (i.e. the cannon) and the target. + raise ValueError("CANNON cannot fire like this.") + elif source_piece.color == target_piece.color: + raise ValueError("CANNON cannot fire to a piece with same color.") + elif target_piece.color == Color.NA: + raise ValueError("CANNON cannot fire to an empty piece.") + elif source_piece.type_ == Type.PAWN: + if abs(dx) + abs(dy) != 1: + raise ValueError("PAWN cannot move like this.") + if source_piece.color == Color.RED: + if dy == 1: + raise ValueError("PAWN can not move backward.") + if y0 > 4 and dy != -1: + raise ValueError( + "PAWN can only go forward before crossing the river (i.e. the middle line)." + ) + else: + if dy == -1: + raise ValueError("PAWN can not move backward.") + if y0 <= 4 and dy != 1: + raise ValueError( + "PAWN can only go forward before crossing the river (i.e. the middle line)." + ) + + def classify_move( + self, + sources: List[str], + targets: List[str], + classical_path_pieces_0: List[str], + quantum_path_pieces_0: List[str], + classical_path_pieces_1: List[str], + quantum_path_pieces_1: List[str], + ) -> Tuple[MoveType, MoveVariant]: + """Determines and returns the MoveType and MoveVariant. This function assumes that check_classical_rule() + has been called before this. + + Args: + sources: the list of names of the source pieces + targets: the list of names of the target pieces + classical_path_pieces_0: the list of names of classical pieces from source_0 to target_0 (excluded) + quantum_path_pieces_0: the list of names of quantum pieces from source_0 to target_0 (excluded) + classical_path_pieces_1: the list of names of classical pieces from source_0 to target_1 (for split) + or from source_1 to target_0 (for merge) (excluded) + quantum_path_pieces_1: the list of names of quantum pieces from source_0 to target_1 (for split) or + from source_1 to target_0 (for merge) (excluded) + """ + move_type = MoveType.UNSPECIFIED_STANDARD + move_variant = MoveVariant.UNSPECIFIED + + source = self.board.board[sources[0]] + target = self.board.board[targets[0]] + + if len(sources) == 1 and len(targets) == 1: + if len(quantum_path_pieces_0) == 0: + if ( + len(classical_path_pieces_0) == 0 + and source.type_ == Type.CANNON + and target.color.value == 1 - source.color.value + ): + # CANNON is special in that there has to be a platform between itself and the target + # to capture. + raise ValueError( + "CANNON could not fire/capture without a cannon platform." + ) + if not source.is_entangled and not target.is_entangled: + # This handles all classical cases, where no quantum piece is envolved. + # We don't need to further classify MoveVariant types since all classical cases + # will be handled in a similar way. + return MoveType.CLASSICAL, MoveVariant.UNSPECIFIED + else: + # If any of the source or target is entangled, this move is a JUMP. + move_type = MoveType.JUMP + else: + # If there is any quantum path pieces, this move is a SLIDE. + move_type = MoveType.SLIDE + + if source.type_ == Type.CANNON and ( + len(classical_path_pieces_0) == 1 or len(quantum_path_pieces_0) > 0 + ): + # By this time the classical cannon fire has been identified as CLASSICAL move, + # so the current case has quantum piece(s) envolved. + return MoveType.CANNON_FIRE, MoveVariant.CAPTURE + + # Determine MoveVariant. + if target.color == Color.NA: + # If the target piece is classical empty => BASIC. + move_variant = MoveVariant.BASIC + elif target.color == source.color: + # If the target has the same color as the source => EXCLUDED. + # TODO(): such move could be a merge. Take care of such cases later. + move_variant = MoveVariant.EXCLUDED + else: + # If the target is on the opposite side => CAPTURE. + move_variant = MoveVariant.CAPTURE + + elif len(sources) == 2: + # Determine types for merge cases. + source_1 = self.board.board[sources[1]] + if not source.is_entangled or not source_1.is_entangled: + raise ValueError( + "Both sources need to be in quantum state in order to merge." + ) + # TODO(): Currently we don't support merge + excluded/capture, or cannon_merge_fire + capture. Maybe add support later. + if len(classical_path_pieces_0) > 0 or len(classical_path_pieces_1) > 0: + raise ValueError("Currently CANNON cannot merge while firing.") + if target.type_ != Type.EMPTY: + raise ValueError("Currently we could only merge into an empty piece.") + if len(quantum_path_pieces_0) == 0 and len(quantum_path_pieces_1) == 0: + # The move is MERGE_JUMP if there are no quantum pieces in either path. + move_type = MoveType.MERGE_JUMP + else: + # The move is MERGE_SLIDE if there is quantum piece in any path. + move_type = MoveType.MERGE_SLIDE + # Currently we don't support EXCLUDE or CAPTURE typed merge moves. + move_variant = MoveVariant.BASIC + + elif len(targets) == 2: + # Determine types for split cases. + target_1 = self.board.board[targets[1]] + # TODO(): Currently we don't support split + excluded/capture, or cannon_split_fire + capture. Maybe add support later. + if len(classical_path_pieces_0) > 0 or len(classical_path_pieces_1) > 0: + raise ValueError("Currently CANNON cannot split while firing.") + if target.type_ != Type.EMPTY or target_1.type_ != Type.EMPTY: + raise ValueError("Currently we could only split into empty pieces.") + if source.type_ == Type.KING: + # TODO(): Currently we don't support KING split. Maybe add support later. + raise ValueError("King split is not supported currently.") + if len(quantum_path_pieces_0) == 0 and len(quantum_path_pieces_1) == 0: + # The move is SPLIT_JUMP if there are no quantum pieces in either path. + move_type = MoveType.SPLIT_JUMP + else: + # The move is SPLIT_SLIDE if there is quantum piece in any path. + move_type = MoveType.SPLIT_SLIDE + # Currently we don't support EXCLUDE or CAPTURE typed split moves. + move_variant = MoveVariant.BASIC + return move_type, move_variant + def apply_move(self, str_to_parse: str) -> None: """Check if the input string is valid. If it is, determine the move type and variant and return the move.""" - try: - sources, targets = self.parse_input_string(str_to_parse) - except ValueError as e: - raise e + sources, targets = self.parse_input_string(str_to_parse) + # Additional checks based on the current board. for source in sources: if self.board.board[source].type_ == Type.EMPTY: raise ValueError("Could not move empty piece.") if self.board.board[source].color.value != self.board.current_player: raise ValueError("Could not move the other player's piece.") - # TODO(): add analysis to determine move type and variant. + source_0 = self.board.board[sources[0]] + target_0 = self.board.board[targets[0]] + if len(sources) == 2: + source_1 = self.board.board[sources[1]] + if source_0.type_ != source_1.type_: + raise ValueError("Two sources need to be the same type.") + if len(targets) == 2: + target_1 = self.board.board[targets[1]] + # TODO(): handle the case where a piece is split into the current piece and another piece, in which case two targets are different. + if target_0.type_ != target_1.type_: + raise ValueError("Two targets need to be the same type.") + if target_0.color != target_1.color: + raise ValueError("Two targets need to be the same color.") + + # Check if the first path satisfies the classical rule. + classical_pieces_0, quantum_pieces_0 = self.board.path_pieces( + sources[0], targets[0] + ) + self.check_classical_rule(sources[0], targets[0], classical_pieces_0) + + # Check if the second path (if exists) satisfies the classical rule. + classical_pieces_1 = None + quantum_pieces_1 = None + + if len(sources) == 2: + classical_pieces_1, quantum_pieces_1 = self.board.path_pieces( + sources[1], targets[0] + ) + self.check_classical_rule(sources[1], targets[0], classical_pieces_1) + elif len(targets) == 2: + classical_pieces_1, quantum_pieces_1 = self.board.path_pieces( + sources[0], targets[1] + ) + self.check_classical_rule(sources[0], targets[1], classical_pieces_1) + + # Classify the move type and move variant. + move_type, move_variant = self.classify_move( + sources, + targets, + classical_pieces_0, + quantum_pieces_0, + classical_pieces_1, + quantum_pieces_1, + ) + + if move_type == MoveType.CLASSICAL: + if source_0.type_ == Type.KING: + # Update the locations of KING. + self.board.king_locations[self.current_player] = targets[0] + # TODO(): only make such prints for a certain debug level. + print(f"Updated king locations: {self.board.king_locations}.") + if target_0.type_ == Type.KING: + # King is captured, then the game is over. + self.game_state = GameState(self.current_player) + target_0.reset(source_0) + source_0.reset() + # TODO(): only make such prints for a certain debug level. + print("Classical move.") + # TODO(): apply other move types. def next_move(self) -> bool: """Check if the player wants to exit or needs help message. Otherwise parse and apply the move. @@ -138,9 +415,21 @@ def next_move(self) -> bool: self.apply_move(input_str.lower()) return True except ValueError as e: + print("Invalid move.") print(e) return False + def game_over(self) -> None: + """Checks if the game is over, and update self.game_state accordingly.""" + if self.game_state != GameState.CONTINUES: + return + if self.board.flying_general_check(): + # If two KINGs are directly facing each other (i.e. in the same column) without any pieces in between, then the game ends. The other player wins. + self.game_state = GameState(1 - self.current_player) + return + # TODO(): add the following checks + # - If player 0 made N repeatd back-and_forth moves in a row. + def play(self) -> None: """The loop where each player takes turn to play.""" while True: @@ -153,8 +442,8 @@ def play(self) -> None: continue # Check if the game is over. self.game_over() - # If the game continues, switch the player. if self.game_state == GameState.CONTINUES: + # If the game continues, switch the player. self.current_player = 1 - self.current_player self.board.current_player = self.current_player continue @@ -166,23 +455,6 @@ def play(self) -> None: print("Draw! Game is over.") break - def print_welcome(self) -> None: - """Prints the welcome message. Gets board language and players' name.""" - print(_WELCOME_MESSAGE) - print(_HELP_TEXT) - # TODO(): add whole set of Chinese interface support. - lang = input( - "Switch to Chinese board characters? (y/n) (default to be English) " - ) - if lang.lower() == "y": - self.lang = Language.ZH - else: - self.lang = Language.EN - name_0 = input("Player 0's name (default to be Player_0): ") - self.players_name.append("Player_0" if len(name_0) == 0 else name_0) - name_1 = input("Player 1's name (default to be Player_1): ") - self.players_name.append("Player_1" if len(name_1) == 0 else name_1) - def main(): game = QuantumChineseChess() diff --git a/unitary/examples/quantum_chinese_chess/chess_test.py b/unitary/examples/quantum_chinese_chess/chess_test.py index 01d9b70f..67278ee9 100644 --- a/unitary/examples/quantum_chinese_chess/chess_test.py +++ b/unitary/examples/quantum_chinese_chess/chess_test.py @@ -15,7 +15,15 @@ import io import sys from unitary.examples.quantum_chinese_chess.chess import QuantumChineseChess -from unitary.examples.quantum_chinese_chess.enums import Language +from unitary.examples.quantum_chinese_chess.piece import Piece +from unitary.examples.quantum_chinese_chess.enums import ( + Language, + Color, + Type, + SquareState, + MoveType, + MoveVariant, +) def test_game_init(monkeypatch): @@ -68,6 +76,12 @@ def test_apply_move_fail(monkeypatch): game.apply_move("a1b1") with pytest.raises(ValueError, match="Could not move the other player's piece."): game.apply_move("a0b1") + with pytest.raises(ValueError, match="Two sources need to be the same type."): + game.apply_move("a9a6^a5") + with pytest.raises(ValueError, match="Two targets need to be the same type."): + game.apply_move("b7^a7h7") + with pytest.raises(ValueError, match="Two targets need to be the same color."): + game.apply_move("b7^b2h7") def test_game_invalid_move(monkeypatch): @@ -82,3 +96,263 @@ def test_game_invalid_move(monkeypatch): in output.getvalue() ) sys.stdout = sys.__stdout__ + + +def test_check_classical_rule(monkeypatch): + output = io.StringIO() + sys.stdout = output + inputs = iter(["y", "Bob", "Ben"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + game = QuantumChineseChess() + # The move is blocked by classical path piece. + with pytest.raises(ValueError, match="The path is blocked."): + game.check_classical_rule("a0", "a4", ["a3"]) + + # Target should not be a classical piece of the same color. + with pytest.raises( + ValueError, match="The target place has classical piece with the same color." + ): + game.check_classical_rule("a0", "a3", []) + + # ROOK + game.check_classical_rule("a0", "a2", []) + with pytest.raises(ValueError, match="ROOK cannot move like this."): + game.check_classical_rule("a0", "b1", []) + + # HORSE + game.check_classical_rule("b0", "c2", []) + with pytest.raises(ValueError, match="HORSE cannot move like this."): + game.check_classical_rule("b0", "c1", []) + + # ELEPHANT + game.check_classical_rule("c0", "e2", []) + with pytest.raises(ValueError, match="ELEPHANT cannot move like this."): + game.check_classical_rule("c0", "e1", []) + game.board.board["g4"].reset( + Piece("g4", SquareState.OCCUPIED, Type.ELEPHANT, Color.BLACK) + ) + with pytest.raises(ValueError, match="ELEPHANT cannot cross the river"): + game.check_classical_rule("g4", "i6", []) + game.board.board["c5"].reset( + Piece("c5", SquareState.OCCUPIED, Type.ELEPHANT, Color.RED) + ) + with pytest.raises(ValueError, match="ELEPHANT cannot cross the river"): + game.check_classical_rule("c5", "e3", []) + + # ADVISOR + game.check_classical_rule("d9", "e8", []) + with pytest.raises(ValueError, match="ADVISOR cannot move like this."): + game.check_classical_rule("d9", "d8", []) + with pytest.raises(ValueError, match="ADVISOR cannot leave the palace."): + game.check_classical_rule("d0", "c1", []) + with pytest.raises(ValueError, match="ADVISOR cannot leave the palace."): + game.check_classical_rule("f9", "g8", []) + + # KING + game.check_classical_rule("e9", "e8", []) + with pytest.raises(ValueError, match="KING cannot move like this."): + game.check_classical_rule("e9", "d8", []) + game.board.board["c9"].reset() + game.board.board["d9"].reset(game.board.board["e9"]) + game.board.board["e9"].reset() + with pytest.raises(ValueError, match="KING cannot leave the palace."): + game.check_classical_rule("d9", "c9", []) + + # CANNON + game.check_classical_rule("b7", "b4", []) + with pytest.raises(ValueError, match="CANNON cannot move like this."): + game.check_classical_rule("b7", "a8", []) + # Cannon could jump across exactly one piece. + game.check_classical_rule("b2", "b9", ["b7"]) + with pytest.raises(ValueError, match="CANNON cannot fire like this."): + game.check_classical_rule("b2", "b9", ["b5", "b7"]) + # Cannon cannot fire to a piece with same color. + game.board.board["b3"].reset(game.board.board["b2"]) + game.board.board["b2"].reset() + game.board.board["e3"].is_entangled = True + with pytest.raises( + ValueError, match="CANNON cannot fire to a piece with same color." + ): + game.check_classical_rule("b3", "e3", ["c3"]) + with pytest.raises(ValueError, match="CANNON cannot fire to an empty piece."): + game.check_classical_rule("b3", "d3", ["c3"]) + + # PAWN + game.check_classical_rule("a6", "a5", []) + with pytest.raises(ValueError, match="PAWN cannot move like this."): + game.check_classical_rule("a6", "a4", []) + with pytest.raises( + ValueError, match="PAWN can only go forward before crossing the river" + ): + game.check_classical_rule("a6", "b6", []) + with pytest.raises( + ValueError, match="PAWN can only go forward before crossing the river" + ): + game.check_classical_rule("g3", "h3", []) + with pytest.raises(ValueError, match="PAWN can not move backward."): + game.check_classical_rule("a6", "a7", []) + with pytest.raises(ValueError, match="PAWN can not move backward."): + game.check_classical_rule("g3", "g2", []) + # After crossing the rive the pawn could move horizontally. + game.board.board["c4"].reset(game.board.board["c6"]) + game.board.board["c6"].reset() + game.check_classical_rule("c4", "b4", []) + game.check_classical_rule("c4", "d4", []) + + +def test_classify_move_fail(monkeypatch): + output = io.StringIO() + sys.stdout = output + inputs = iter(["y", "Bob", "Ben"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + game = QuantumChineseChess() + with pytest.raises( + ValueError, match="CANNON could not fire/capture without a cannon platform." + ): + game.classify_move(["b7"], ["b2"], [], [], [], []) + + with pytest.raises( + ValueError, match="Both sources need to be in quantum state in order to merge." + ): + game.classify_move(["b2", "h2"], ["e2"], [], [], [], []) + + game.board.board["c0"].reset(game.board.board["b7"]) + game.board.board["c0"].is_entangled = True + game.board.board["b7"].reset() + game.board.board["g0"].reset(game.board.board["h7"]) + game.board.board["g0"].is_entangled = True + game.board.board["h7"].reset() + with pytest.raises(ValueError, match="Currently CANNON cannot merge while firing."): + game.classify_move(["c0", "g0"], ["e0"], ["d0"], [], ["f0"], []) + + game.board.board["b3"].reset(game.board.board["b2"]) + game.board.board["b3"].is_entangled = True + game.board.board["b2"].reset() + game.board.board["d3"].reset(game.board.board["h2"]) + game.board.board["d3"].is_entangled = True + game.board.board["h2"].reset() + with pytest.raises( + ValueError, match="Currently we could only merge into an empty piece." + ): + game.classify_move(["b3", "d3"], ["c3"], [], [], [], []) + + with pytest.raises(ValueError, match="Currently CANNON cannot split while firing."): + game.classify_move(["g0"], ["e0", "i0"], ["f0"], [], ["h0"], []) + + game.board.board["d0"].is_entangled = True + with pytest.raises( + ValueError, match="Currently we could only split into empty pieces." + ): + game.classify_move(["d3"], ["d0", "d4"], [], [], [], []) + + game.board.board["d0"].reset() + game.board.board["f0"].reset() + with pytest.raises(ValueError, match="King split is not supported currently."): + game.classify_move(["e0"], ["d0", "f0"], [], [], [], []) + + +def test_classify_move_success(monkeypatch): + output = io.StringIO() + sys.stdout = output + inputs = iter(["y", "Bob", "Ben"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + game = QuantumChineseChess() + # classical + assert game.classify_move(["h9"], ["g7"], [], [], [], []) == ( + MoveType.CLASSICAL, + MoveVariant.UNSPECIFIED, + ) + assert game.classify_move(["b2"], ["b9"], ["b7"], [], [], []) == ( + MoveType.CLASSICAL, + MoveVariant.UNSPECIFIED, + ) + + # jump basic + game.board.board["c9"].is_entangled = True + assert game.classify_move(["c9"], ["e7"], [], [], [], []) == ( + MoveType.JUMP, + MoveVariant.BASIC, + ) + game.board.board["b2"].is_entangled = True + assert game.classify_move(["b2"], ["e2"], [], [], [], []) == ( + MoveType.JUMP, + MoveVariant.BASIC, + ) + + # jump excluded + game.board.board["a3"].is_entangled = True + assert game.classify_move(["a0"], ["a3"], [], [], [], []) == ( + MoveType.JUMP, + MoveVariant.EXCLUDED, + ) + + # jump capture + game.board.board["g4"].reset(game.board.board["g6"]) + game.board.board["g4"].is_entangled = True + game.board.board["g6"].reset() + assert game.classify_move(["g4"], ["g3"], [], [], [], []) == ( + MoveType.JUMP, + MoveVariant.CAPTURE, + ) + + # slide basic + assert game.classify_move(["a0"], ["a4"], [], ["a3"], [], []) == ( + MoveType.SLIDE, + MoveVariant.BASIC, + ) + + # slide excluded + game.board.board["i7"].reset(game.board.board["h7"]) + game.board.board["i7"].is_entangled = True + game.board.board["h7"].reset() + game.board.board["i6"].is_entangled = True + assert game.classify_move(["i9"], ["i6"], [], ["i7"], [], []) == ( + MoveType.SLIDE, + MoveVariant.EXCLUDED, + ) + + # slide capture + assert game.classify_move(["a0"], ["a6"], [], ["a3"], [], []) == ( + MoveType.SLIDE, + MoveVariant.CAPTURE, + ) + + # split_jump basic + assert game.classify_move(["g4"], ["f4", "h4"], [], [], [], []) == ( + MoveType.SPLIT_JUMP, + MoveVariant.BASIC, + ) + + # split_slide basic + game.board.board["d3"].reset(game.board.board["h2"]) + game.board.board["h2"].reset() + game.board.board["c3"].is_entangled = True + game.board.board["e3"].is_entangled = True + assert game.classify_move(["d3"], ["b3", "f3"], [], ["c3"], [], ["e3"]) == ( + MoveType.SPLIT_SLIDE, + MoveVariant.BASIC, + ) + + # merge_jump basic + game.board.board["b7"].is_entangled = True + assert game.classify_move(["b7", "i7"], ["e7"], [], [], [], []) == ( + MoveType.MERGE_JUMP, + MoveVariant.BASIC, + ) + + # merge_slide basic + assert game.classify_move(["b7", "i7"], ["a7"], [], [], [], ["b7"]) == ( + MoveType.MERGE_SLIDE, + MoveVariant.BASIC, + ) + + # cannon_fire capture + assert game.classify_move(["i7"], ["i3"], [], ["i6"], [], []) == ( + MoveType.CANNON_FIRE, + MoveVariant.CAPTURE, + ) + game.board.board["i6"].is_entangled = False + assert game.classify_move(["i7"], ["i3"], ["i6"], [], [], []) == ( + MoveType.CANNON_FIRE, + MoveVariant.CAPTURE, + ) diff --git a/unitary/examples/quantum_chinese_chess/enums.py b/unitary/examples/quantum_chinese_chess/enums.py index 6fbf0f13..485f2b91 100644 --- a/unitary/examples/quantum_chinese_chess/enums.py +++ b/unitary/examples/quantum_chinese_chess/enums.py @@ -40,10 +40,12 @@ class GameState(enum.Enum): DRAW = 2 +# TODO(): consider if we could allow split/merge + excluded/capture, +# and cannon_split_fire/cannon_merge_fire + capture class MoveType(enum.Enum): """Each valid move will be classfied into one of the following MoveTypes.""" - NULL_TYPE = 0 + CLASSICAL = 0 UNSPECIFIED_STANDARD = 1 JUMP = 2 SLIDE = 3 @@ -51,21 +53,18 @@ class MoveType(enum.Enum): SPLIT_SLIDE = 5 MERGE_JUMP = 6 MERGE_SLIDE = 7 - HORSE_MOVE = 8 - HORSE_SPLIT_MOVE = 9 - HORSE_MERGE_MOVE = 10 - CANNON_FIRE = 11 + CANNON_FIRE = 8 class MoveVariant(enum.Enum): - """Each valid move will be classfied into one of the following MoveVariat, in addition to + """Each valid move will be classfied into one of the following MoveVariant, in addition to the MoveType above. """ - UNSPECIFIED = 0 - BASIC = 1 - EXCLUDED = 2 - CAPTURE = 3 + UNSPECIFIED = 0 # Used together with MoveType = CLASSICAL. + BASIC = 1 # The target piece is empty. + EXCLUDED = 2 # The target piece has the same color. + CAPTURE = 3 # The target piece has the opposite color. class Color(enum.Enum): diff --git a/unitary/examples/quantum_chinese_chess/move.py b/unitary/examples/quantum_chinese_chess/move.py index 050615e1..ec0fb6bf 100644 --- a/unitary/examples/quantum_chinese_chess/move.py +++ b/unitary/examples/quantum_chinese_chess/move.py @@ -17,70 +17,6 @@ from unitary.examples.quantum_chinese_chess.enums import MoveType, MoveVariant, Type -def parse_input_string(str_to_parse: str) -> Tuple[List[str], List[str]]: - """Check if the input string could be turned into a valid move. - Returns the sources and targets if it is valid. - The input needs to be: - - s1t1 for slide/jump move; or - - s1^t1t2 for split moves; or - - s1s2^t1 for merge moves. - Examples: - 'a1a2' - 'b1^a3c3' - 'a3b1^c3' - """ - sources = None - targets = None - - if "^" in str_to_parse: - sources_str, targets_str = str_to_parse.split("^", maxsplit=1) - # The only two allowed cases here are s1^t1t2 and s1s2^t1. - if ( - str_to_parse.count("^") > 1 - or len(str_to_parse) != 7 - or len(sources_str) not in [2, 4] - ): - raise ValueError(f"Invalid sources/targets string {str_to_parse}.") - sources = [sources_str[i : i + 2] for i in range(0, len(sources_str), 2)] - targets = [targets_str[i : i + 2] for i in range(0, len(targets_str), 2)] - if len(sources) == 2: - if sources[0] == sources[1]: - raise ValueError("Two sources should not be the same.") - elif targets[0] == targets[1]: - raise ValueError("Two targets should not be the same.") - else: - # The only allowed case here is s1t1. - if len(str_to_parse) != 4: - raise ValueError(f"Invalid sources/targets string {str_to_parse}.") - sources = [str_to_parse[0:2]] - targets = [str_to_parse[2:4]] - if sources[0] == targets[0]: - raise ValueError("Source and target should not be the same.") - - # Make sure all the locations are valid. - for location in sources + targets: - if location[0].lower() not in "abcdefghi" or not location[1].isdigit(): - raise ValueError( - f"Invalid location string. Make sure they are from a0 to i9." - ) - return sources, targets - - -def apply_move(str_to_parse: str, board: Board) -> None: - """Check if the input string is valid. If it is, determine the move type and variant and return the move.""" - try: - sources, targets = parse_input_string(str_to_parse) - except ValueError as e: - raise e - # Additional checks based on the current board. - for source in sources: - if board.board[source].type_ == Type.EMPTY: - raise ValueError("Could not move empty piece.") - if board.board[source].color.value != board.current_player: - raise ValueError("Could not move the other player's piece.") - # TODO(): add analysis to determine move type and variant. - - class Move(QuantumEffect): """The base class of all chess moves.""" diff --git a/unitary/examples/quantum_chinese_chess/piece.py b/unitary/examples/quantum_chinese_chess/piece.py index 6335c530..e8d4b79a 100644 --- a/unitary/examples/quantum_chinese_chess/piece.py +++ b/unitary/examples/quantum_chinese_chess/piece.py @@ -11,6 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from typing import Optional from unitary.alpha import QuantumObject from unitary.examples.quantum_chinese_chess.enums import ( SquareState, @@ -25,9 +26,25 @@ def __init__(self, name: str, state: SquareState, type_: Type, color: Color): QuantumObject.__init__(self, name, state) self.type_ = type_ self.color = color + # TODO(): maybe modify QuantumObject to allow it to: + # - not create qubit at initialization; + # - add qubit and set it to the current classical state when needed. + self.is_entangled = False def symbol(self, lang: Language = Language.EN) -> str: + """Returns the symbol of this piece according to its type, color and language.""" return Type.symbol(self.type_, self.color, lang) def __str__(self): return self.symbol() + + def reset(self, piece: "Piece" = None) -> None: + """Modifies the classical attributes of the piece. + If piece is provided, then its type_ and color is copied, otherwise set the current piece to be an empty piece. + """ + if piece is not None: + self.type_ = piece.type_ + self.color = piece.color + else: + self.type_ = Type.EMPTY + self.color = Color.NA diff --git a/unitary/examples/quantum_chinese_chess/piece_test.py b/unitary/examples/quantum_chinese_chess/piece_test.py index 6141ff0a..c15ac942 100644 --- a/unitary/examples/quantum_chinese_chess/piece_test.py +++ b/unitary/examples/quantum_chinese_chess/piece_test.py @@ -47,3 +47,16 @@ def test_enum(): assert board.peek() == [ [SquareState.OCCUPIED, SquareState.OCCUPIED, SquareState.EMPTY] ] + + +def test_reset(): + p0 = Piece("a0", SquareState.OCCUPIED, Type.CANNON, Color.RED) + p1 = Piece("b1", SquareState.OCCUPIED, Type.HORSE, Color.BLACK) + + p0.reset() + assert p0.type_ == Type.EMPTY + assert p0.color == Color.NA + + p0.reset(p1) + assert p0.type_ == p1.type_ + assert p0.color == p1.color