Chess, but each square also follows the rules of Conway's Game of Life
#!/usr/bin/env python3
# Conway's Game of Chess
# Copyright (C) 2023 Eric Lesiuta

import argparse
import atexit
import curses
import hashlib
import os
import pickle
import textwrap
import time
import socket
import sys


def exit_handler(engine, engine_state, conn, *args) -> None:
    """clean up in the event of an exception and atexit functions aren't called"""
    type_, value, traceback = args
    print(type_, value, traceback, file=sys.stderr)
    print(" ".join(engine.recent_moves_str))
    with open(engine.args.save, "wb") as f:
        pickle.dump(engine_state, f)
    if conn:
        conn.close()

def start_cli() -> int:
    # cli
    parser = argparse.ArgumentParser(description="Conway's game of chess")
    parser.epilog = textwrap.dedent("""
    Conway's game of chess is a chess variant where the pieces can reproduce and die.
    Legend:             White birth queue ┐
    White: P R P N P B P Q P B P N P R  <─┘
    ┌──────────────────────────────────────┐
    │ # <─ White birth COUNTER on empty  w │
    │      squares, born from queue on   ^ │
    │      next turn after reaching 2    │ │
    │                                    │ │
    │   INDICATOR that white has exactly ┘ │
    │   3 nearby pieces, birth counter     │
    │   will increment at the start of the │
    │   next turn, black birth counter and │
    │   indicator are below and separate   │
    │                                      │
    │ #               ♔ <─ Piece symbol  o │
    │ ^                                  ^ │
    │ └ Death COUNTER on occupied        │ │
    │   squares, dies after reaching 3   │ │
    │                                    │ │
    │   INDICATOR that the piece has > 3 ┘ │
    │   nearby pieces (overpopulation),    │
    │   or < 2 nearby pieces               │
    │   (underpopulation) and will die     │
    │                                      │
    │ # <─ Black COUNTER & INDICATOR ──> l │
    └──────────────────────────────────────┘
    The INDICATORs are updated immediately when the conditions are met.
    The COUNTERs are incremented only at the start of the respective player's turn.
    Births and deaths also only occur at the start of the respective player's turn.
    If the conditions for a birth or death counter are no longer met, (as shown by the indicators), the counter resets.
    Opponent pieces are not counted as nearby pieces for the birth/death population criteria.
    On birth, pieces are taken from the birth queue (circular) and placed on the board in order of rank then file.
    Placement starts from rank 1 for white and rank 8 for black, with both filling the board from left to right.
    The game ends when the king is captured or perishes due to over/underpopulation.
    """)
    parser.formatter_class = argparse.RawDescriptionHelpFormatter
    parser.add_argument("--flip", action="store_true", help="flip the board")
    parser.add_argument("--save", action="store", metavar="FILE", default="conway_chess.pickle", help="save file location")
    parser.add_argument("--load", action="store", metavar="FILE", help="load a save file")
    parser.add_argument("--host", nargs=3, metavar=("HOST", "PORT", "COLOR"), help="host a game")
    parser.add_argument("--join", nargs=2, metavar=("HOST", "PORT"), help="join a game")
    parser.add_argument("--ascii", action="store_true", help="use ascii characters for pieces")
    parser.add_argument("--light", action="store_true", help="flip unicode piece colors for light terminals")
    args = parser.parse_args()
    # print instructions before playing
    print(parser.epilog)
    _ = input("Press enter to play")
    # networking
    conn, my_colour = None, None
    if args.host and args.join:
        print("You can only host or join a game, not both", file=sys.stderr)
        return 1
    if args.host:
        if args.host[2] not in ("white", "black"):
            print("You can only host as white or black", file=sys.stderr)
            return 1
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        # s = ssl._create_unverified_context().wrap_socket(s, server_side=True)
        s.bind((args.host[0], int(args.host[1])))
        s.listen()
        conn, addr = s.accept()
        conn.sendall(args.host[2].encode())
        my_colour = args.host[2]
        atexit.register(conn.close)
    elif args.join:
        conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        # conn = ssl._create_unverified_context().wrap_socket(conn, server_side=False)
        conn.connect((args.join[0], int(args.join[1])))
        host_colour = conn.recv(5).decode()
        if host_colour == "white":
            my_colour = "black"
        else:
            my_colour = "white"
        atexit.register(conn.close)
    # engine initialization
    engine = Engine(args)
    engine_state = [pickle.dumps(engine)]
    engine_state_redo = []
    if args.load:
        engine_state = pickle.load(open(args.load, "rb"))
        for i in range(len(engine_state)):
            engine_state[i] = pickle.loads(engine_state[i])
            engine_state[i].args = args
            engine_state[i] = pickle.dumps(engine_state[i])
        engine = pickle.loads(engine_state[-1])
    atexit.register(lambda: print(" ".join(engine.recent_moves_str)))
    atexit.register(lambda: pickle.dump(engine_state, open(args.save, "wb")))
    sys.excepthook = lambda *args: exit_handler(engine, engine_state, conn, *args)
    try:
        from stockfish import Stockfish
        stockfish = Stockfish()
    except:
        stockfish = None
    # check terminal size
    columns, lines = os.get_terminal_size()
    assert engine.height <= lines, f"Terminal height ({lines}) is too short by {engine.height - lines} lines"
    assert engine.width <= columns, f"Terminal width ({columns}) is too narrow by {engine.width - columns} columns"
    # main curses loop
    for err_count in reversed(range(30)):
        try:
            return curses.wrapper(main_loop, engine, engine_state, engine_state_redo, stockfish, conn, my_colour)
        except curses.error as e:
            print("CURSES ERROR: %s" % e, file=sys.stderr)
            print("try resizing your terminal, game will quit in %s seconds" % (err_count + 1), file=sys.stderr)
            time.sleep(1)
        except Exception as e:
            print("ERROR: %s" % e, file=sys.stderr)
            return 1
    return 1

def main_loop(stdscr, engine: "Engine", engine_state: list[bytes], engine_state_redo: list[bytes], stockfish, conn, my_colour) -> int:
    """main loop for the curses implementation of the game"""
    curses.cbreak()
    curses.noecho()
    while True:
        # refresh screen
        current_display = engine.display(my_colour)
        stdscr.clear()
        curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE)
        curses.init_pair(2, curses.COLOR_BLUE, curses.COLOR_WHITE)
        curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_BLUE)
        curses.init_pair(4, curses.COLOR_YELLOW, curses.COLOR_WHITE)
        curses.init_pair(5, curses.COLOR_YELLOW, curses.COLOR_BLUE)
        curses.init_pair(6, curses.COLOR_GREEN, curses.COLOR_BLACK)
        curses.init_pair(7, curses.COLOR_YELLOW, curses.COLOR_BLACK)
        curses.init_pair(8, curses.COLOR_RED, curses.COLOR_BLACK)
        curses.init_pair(9, curses.COLOR_CYAN, curses.COLOR_BLACK)
        stdscr.attrset(curses.color_pair(0))
        y = 0
        for line in current_display:
            for x, char in enumerate(line):
                # pieces
                if char in "RNBQKP" and y > 4 and y < engine.height - 2:
                    if engine.use_unicode:
                        char = engine.unicode_replacements[char]
                    else:
                        if char == "K":
                            stdscr.attrset(curses.color_pair(4))
                        else:
                            stdscr.attrset(curses.color_pair(2))
                elif char in "rnbqkp" and y > 4 and y < engine.height - 2:
                    if engine.use_unicode:
                        char = engine.unicode_replacements[char]
                    else:
                        if char == "k":
                            stdscr.attrset(curses.color_pair(5))
                        else:
                            stdscr.attrset(curses.color_pair(3))
                # indicators
                elif char in "wl" and y > 4 and y < engine.height - 2:
                    stdscr.attrset(curses.color_pair(9))
                elif char in "ou" and y > 4 and y < engine.height - 2:
                    stdscr.attrset(curses.color_pair(7))
                # death counters
                elif char == "0" and x > 1 and x < engine.width - 1 and y > 4 and y < engine.height - 2 and (y - 5) % 4 == 2:
                    stdscr.attrset(curses.color_pair(7))
                elif char == "1" and x > 1 and x < engine.width - 1 and y > 4 and y < engine.height - 2 and (y - 5) % 4 == 2:
                    stdscr.attrset(curses.color_pair(7))
                elif char == "2" and x > 1 and x < engine.width - 1 and y > 4 and y < engine.height - 2 and (y - 5) % 4 == 2:
                    stdscr.attrset(curses.color_pair(7))
                elif char == "3" and x > 1 and x < engine.width - 1 and y > 4 and y < engine.height - 2 and (y - 5) % 4 == 2:
                    stdscr.attrset(curses.color_pair(8))
                # birth counters
                elif char == "0" and x > 1 and x < engine.width - 1 and y > 4 and y < engine.height - 2 and (y - 5) % 4 != 2:
                    stdscr.attrset(curses.color_pair(9))
                elif char == "1" and x > 1 and x < engine.width - 1 and y > 4 and y < engine.height - 2 and (y - 5) % 4 != 2:
                    stdscr.attrset(curses.color_pair(9))
                elif char == "2" and x > 1 and x < engine.width - 1 and y > 4 and y < engine.height - 2 and (y - 5) % 4 != 2:
                    stdscr.attrset(curses.color_pair(6))
                # board
                else:
                    stdscr.attrset(curses.color_pair(0))
                stdscr.addstr(y, x, char)
            y += 1
        stdscr.move(*engine.get_cursor())
        stdscr.refresh()
        # check for key press, sync with network player if connected, and update engine state
        key = None
        if conn:
            if my_colour == engine.current_turn:
                ch: int = stdscr.getch()
                ch = engine.flip_cursor_y(ch, curses.KEY_UP, curses.KEY_DOWN)
                conn.sendall(ch.to_bytes(2, "big") + hashlib.sha256(pickle.dumps(engine.recent_moves_str)).digest()[-2:])
            else:
                msg = conn.recv(4)
                ch = int.from_bytes(msg[:2], "big")
                hash_lsb = msg[2:]
                assert hash_lsb == hashlib.sha256(pickle.dumps(engine.recent_moves_str)).digest()[-2:], f"client and server are out of sync"
        else:
            ch: int = stdscr.getch()
            ch = engine.flip_cursor_y(ch, curses.KEY_UP, curses.KEY_DOWN)
        if ch == ord("\n") or ch == ord(" "):
            key = "enter"
        elif ch == ord("s"):
            key = "stockfish"
        elif ch == curses.KEY_UP:
            key = "up"
        elif ch == curses.KEY_DOWN:
            key = "down"
        elif ch == curses.KEY_LEFT:
            key = "left"
        elif ch == curses.KEY_RIGHT:
            key = "right"
        elif ch == curses.KEY_BACKSPACE or ch == ord("u"):
            if len(engine_state) >= 2:
                engine_state_redo.append(engine_state.pop())
                engine = pickle.loads(engine_state[-1])
            continue
        elif ch == ord("r"):
            if engine_state_redo:
                engine_state.append(engine_state_redo.pop())
                engine = pickle.loads(engine_state[-1])
            continue
        elif ch == 27 or ch == ord("q"):
            key = "esc"
            return 0
        else:
            key = "other"
        if engine.update_state(key, stockfish):
            engine_state.append(pickle.dumps(engine))
            engine_state_redo = []


class Engine:
    def __init__(self, args) -> None:
        self.args = args
        self.board = Board(self.args)
        # tick all the pieces for the first turn
        for piece in self.board.get_pieces():
            piece.tick(self.board.get_surrounding_pieces(piece), "white", True)
        self.cursor_row = 0
        self.cursor_col = 0
        self.height = len(self.board.display()) + 5
        self.width = len(self.board.display()[0])
        self.white_birth_queue = ["P", "R", "P", "N", "P", "B", "P", "Q", "P", "B", "P", "N", "P", "R"]
        self.black_birth_queue = ["P", "R", "P", "N", "P", "B", "P", "Q", "P", "B", "P", "N", "P", "R"]
        self.selected_piece = None
        self.current_turn = "white"
        self.col_labels = ["a", "b", "c", "d", "e", "f", "g", "h"]
        self.recent_moves = []
        self.recent_moves_str = []
        self.game_over_message = None
        self.use_unicode = not self.args.ascii
        self.unicode_pieces = "♟♜♞♝♛♚♙♖♘♗♕♔"
        self.ascii_pieces = "PRNBQKprnbqk"
        if self.args.light:
            self.ascii_pieces = "prnbqkPRNBQK"
        self.unicode_replacements = dict(zip(self.ascii_pieces, self.unicode_pieces))
        assert self.height == len(self.display(None))
        assert self.width == len(self.display(None)[0])

    def get_cursor(self) -> tuple[int, int]:
        """get the position of the cursor in terms of display row and column"""
        if self.args.flip:
            real_row = (7 - self.cursor_row) * 4 + 7
        else:
            real_row = self.cursor_row * 4 + 7
        real_col = self.cursor_col * 6 + 4
        return real_row, real_col

    def flip_cursor_y(self, ch: int, key_up: int, key_down: int) -> int:
        """flip the key press for up and down"""
        if self.args.flip:
            if ch == key_up:
                return key_down
            elif ch == key_down:
                return key_up
        return ch

    def display(self, my_colour) -> list:
        board = self.board.display()
        if self.game_over_message is not None:
            header = f"Game over: {self.game_over_message}".center(self.width, " ")
        else:
            header = f"Current turn: {self.current_turn}{' (your turn)' if my_colour == self.current_turn else ''}".center(self.width, " ")
        board.insert(0, list(header))
        if self.selected_piece is None:
            header_2 = "Selected: None".center(self.width, " ")
        else:
            header_2 = f"Selected: {self.selected_piece}{self.col_labels[self.selected_piece.col]}{self.selected_piece.row + 1}".center(self.width, " ")
        board.insert(1, list(header_2))
        header_3 = f"Recent moves: {' | '.join(self.recent_moves_str[-3:])}".center(self.width, " ")
        board.insert(2, list(header_3))
        if self.use_unicode:
            white_queue = f"White: {' '.join([self.unicode_replacements[piece] for piece in self.white_birth_queue])}".center(self.width, " ")
            black_queue = f"Black: {' '.join([self.unicode_replacements[piece.lower()] for piece in self.black_birth_queue])}".center(self.width, " ")
        else:
            white_queue = f"White: {' '.join(self.white_birth_queue)}".center(self.width, " ")
            black_queue = f"Black: {' '.join(self.black_birth_queue)}".center(self.width, " ")
        if self.args.flip:
            board.insert(3, list(black_queue))
            board.append(list(white_queue))
        else:
            board.insert(3, list(white_queue))
            board.append(list(black_queue))
        return board

    def move_is_valid(self, source, dest, stockfish) -> bool:
        source_row = source.row + 1
        source_col = self.col_labels[source.col]
        dest_row = dest.row + 1
        dest_col = self.col_labels[dest.col]
        move_str = f"{source}{source_col}{source_row}->{dest}{dest_col}{dest_row}"
        if source.move_is_valid(dest):
            if stockfish is not None:
                try:
                    stockfish.set_fen_position(self.board.get_fen_position(self.current_turn))
                    if stockfish.is_move_correct(f"{source_col}{source_row}{dest_col}{dest_row}"):
                        self.recent_moves.append((source, dest))
                        self.recent_moves_str.append(move_str)
                        return True
                    else:
                        return False
                except:
                    self.recent_moves.append((source, dest))
                    self.recent_moves_str.append(move_str)
                    return True
            else:
                self.recent_moves.append((source, dest))
                self.recent_moves_str.append(move_str)
                return True
        else:
            return False

    def update_state(self, key, stockfish) -> bool:
        """returns whether there was a state change"""
        # cursor
        if key:
            if key == "up":
                self.cursor_row = (self.cursor_row - 1) % 8
            elif key == "down":
                self.cursor_row = (self.cursor_row + 1) % 8
            elif key == "left":
                self.cursor_col = (self.cursor_col - 1) % 8
            elif key == "right":
                self.cursor_col = (self.cursor_col + 1) % 8
            elif key == "stockfish":
                try:
                    if stockfish is not None:
                        stockfish.set_fen_position(self.board.get_fen_position(self.current_turn))
                        move = stockfish.get_best_move()
                        if move is not None:
                            self.selected_piece = self.board.get_piece(int(move[1]) - 1, ord(move[0]) - ord("a"))
                            self.cursor_row = int(move[3]) - 1
                            self.cursor_col = ord(move[2]) - ord("a")
                            key = "enter"
                except:
                    pass
            if key == "enter":
                if self.selected_piece is None:
                    # select a piece
                    if self.board.get_piece(self.cursor_row, self.cursor_col).side == self.current_turn:
                        self.selected_piece = self.board.get_piece(self.cursor_row, self.cursor_col)
                elif self.move_is_valid(self.selected_piece, self.board.get_piece(self.cursor_row, self.cursor_col), stockfish):
                    # move the selected piece to the cursor
                    try:
                        self.board.move_piece(self.selected_piece, self.board.get_piece(self.cursor_row, self.cursor_col))
                    except Exception as e:
                        self.game_over_message = str(e)
                        return False
                    self.selected_piece = None
                    self.current_turn = "black" if self.current_turn == "white" else "white"
                    # tick all the pieces at the start of the next turn
                    for piece in self.board.get_pieces():
                        piece.tick(self.board.get_surrounding_pieces(piece), self.current_turn, True)
                    # check if any pieces need to be born
                    if self.current_turn == "white":
                        for i in range(8):
                            for j in range(8):
                                piece = self.board.get_piece(i, j)
                                if piece.side == "empty":
                                    if piece.birth_counter_white == 3:
                                        next_piece = self.white_birth_queue.pop(0)
                                        self.board.set_new_piece(i, j, next_piece, "white")
                                        self.white_birth_queue.append(next_piece)
                    elif self.current_turn == "black":
                        for i in reversed(range(8)):
                            for j in range(8):
                                piece = self.board.get_piece(i, j)
                                if piece.side == "empty":
                                    if piece.birth_counter_black == 3:
                                        next_piece = self.black_birth_queue.pop(0)
                                        self.board.set_new_piece(i, j, next_piece, "black")
                                        self.black_birth_queue.append(next_piece)
                    # check if any pieces need to die
                    for piece in self.board.get_pieces():
                        if piece.death_counter == 4:
                            try:
                                self.board.kill_piece(piece, self.current_turn)
                            except Exception as e:
                                self.game_over_message = str(e)
                                return False
                    # recalculate nearby pieces for indicators
                    for piece in self.board.get_pieces():
                        piece.tick(self.board.get_surrounding_pieces(piece), self.current_turn, False)
                    return True
            if key == "other":
                self.selected_piece = None
        return False


class Board:
    def __init__(self, args) -> None:
        self.args = args
        self.board: list[list[Piece]] = [[Empty() for x in range(8)] for y in range(8)]
        self.board[0] = [
            Rook("white"),
            Knight("white"),
            Bishop("white"),
            Queen("white"),
            King("white"),
            Bishop("white"),
            Knight("white"),
            Rook("white")
        ]
        self.board[1] = [Pawn("white") for x in range(8)]
        self.board[6] = [Pawn("black") for x in range(8)]
        self.board[7] = [
            Rook("black"),
            Knight("black"),
            Bishop("black"),
            Queen("black"),
            King("black"),
            Bishop("black"),
            Knight("black"),
            Rook("black")
        ]
        self.piece_width = 5
        self.piece_height = 3
        for i in range(8):
            for j in range(8):
                self.board[i][j].set_position(i, j)

    def get_fen_position(self, current_turn: str) -> str:
        # return a string in Forsyth-Edwards Notation (FEN)
        fen = ""
        for row in reversed(self.board):
            empty_spaces = 0
            for piece in row:
                if piece.side == "empty":
                    empty_spaces += 1
                else:
                    if empty_spaces > 0:
                        fen += str(empty_spaces)
                        empty_spaces = 0
                    fen += str(piece)
            if empty_spaces > 0:
                fen += str(empty_spaces)
            fen += "/"
        fen = fen[:-1]
        fen += " " + current_turn[0] + " - - 0 1"
        return fen

    def get_piece(self, row: int, col: int) -> "Piece":
        return self.board[row][col]

    def get_pieces(self) -> list["Piece"]:
        pieces = []
        for row in self.board:
            for piece in row:
                pieces.append(piece)
        return pieces

    def get_surrounding_pieces(self, piece: "Piece") -> list["Piece"]:
        surrounding_pieces = []
        for i in range(-1, 2):
            for j in range(-1, 2):
                if i == 0 and j == 0:
                    continue
                elif 0 <= piece.row + i < 8 and 0 <= piece.col + j < 8:
                    surrounding_pieces.append(self.board[piece.row + i][piece.col + j])
        return surrounding_pieces

    def set_new_piece(self, row: int, col: int, piece: str, side: str) -> None:
        if piece == "P":
            self.board[row][col] = Pawn(side)
        elif piece == "R":
            self.board[row][col] = Rook(side)
        elif piece == "N":
            self.board[row][col] = Knight(side)
        elif piece == "B":
            self.board[row][col] = Bishop(side)
        elif piece == "Q":
            self.board[row][col] = Queen(side)
        elif piece == "K":
            self.board[row][col] = King(side)
        else:
            raise ValueError("invalid piece")
        self.board[row][col].set_position(row, col)

    def kill_piece(self, piece: "Piece", turn: str) -> None:
        """piece died due to over/under population"""
        if piece.side == turn:
            row = piece.row
            col = piece.col
            self.board[row][col].perish(conway=True)
            self.board[row][col] = Empty()
            self.board[row][col].set_position(row, col)

    def display(self) -> list:
        """get a version of the board suitable for printing to the ui"""
        # use ascii art to create a grid between the pieces
        WIDTH = self.piece_width
        HEIGHT = self.piece_height
        middle = ["─"] * WIDTH + ["┬"]
        board = [["┌"] + middle * 7 + ["─"] * WIDTH + ["┐"]]
        for row in reversed(self.board) if self.args.flip else self.board:
            board += [["│"], ["│"], ["│"]]
            for piece in row:
                board[-3] += piece.display()[0] + ["│"]
                board[-2] += piece.display()[1] + ["│"]
                board[-1] += piece.display()[2] + ["│"]
            middle = ["─"] * WIDTH + ["┼"]
            board += [["├"] + middle * 7 + ["─"] * WIDTH + ["┤"]]
        _ = board.pop()
        middle = ["─"] * WIDTH + ["┴"]
        board += [["└"] + middle * 7 + ["─"] * WIDTH + ["┘"]]
        # add the row and column numbers, NOTE: need to readjust if changing piece size
        for i in range(len(board)):
            if self.args.flip:
                board[i] = [str(9 - ((i + 2) // 4)) if (i + 2) % 4 == 0 else " "] + board[i] + [str(9 - ((i + 2) // 4)) if (i + 2) % 4 == 0 else " "]
            else:
                board[i] = [str((i + 2) // 4) if (i + 2) % 4 == 0 else " "] + board[i] + [str((i + 2) // 4) if (i + 2) % 4 == 0 else " "]
        board = [[" ", " ", " "] + list("     ".join(list("abcdefgh"))) + [" ", " ", " "]] + board
        board += [[" ", " ", " "] + list("     ".join(list("abcdefgh"))) + [" ", " ", " "]]
        return board

    def move_piece(self, source: "Piece", dest: "Piece") -> bool:
        """moves piece and returns whether move is successful"""
        if not source.move_is_valid(dest):
            return False
        # check if the move is a capture and move the piece
        self.board[dest.row][dest.col].perish(conway=False)
        self.board[dest.row][dest.col] = source
        # replace the old position with an empty piece
        self.board[source.row][source.col] = Empty()
        self.board[source.row][source.col].set_position(source.row, source.col)
        # update the position of the moved piece
        source.set_position(dest.row, dest.col)
        return True


class Piece:
    def __init__(self, side) -> None:
        """common attributes (required by every chess piece)"""
        self.side = side
        self.row = -1
        self.col = -1
        self.death_counter = 0
        self.birth_counter_white = 0
        self.birth_counter_black = 0
        self.surrounding_white = 0
        self.surrounding_black = 0

    def __str__(self) -> str:
        """for displaying entities on the map ui"""
        raise NotImplementedError()

    def set_position(self, row: int, col: int) -> None:
        """set the position of the piece"""
        self.row = row
        self.col = col

    def get_position(self) -> tuple[int, int]:
        """get the position of the piece"""
        return self.row, self.col

    def display(self) -> list[list[str]]:
        """get a 3x3 list of chars of the piece suitable for printing to the ui"""
        white_reproduction = "w" if self.side == "empty" and self.surrounding_white == 3 else " "
        black_reproduction = "l" if self.side == "empty" and self.surrounding_black == 3 else " "
        over_under_population = " "
        if self.side == "white":
            if self.surrounding_white < 2:
                over_under_population = "u"
            elif self.surrounding_white > 3:
                over_under_population = "o"
        elif self.side == "black":
            if self.surrounding_black < 2:
                over_under_population = "u"
            elif self.surrounding_black > 3:
                over_under_population = "o"
        chars_to_print = [
            [" " if white_reproduction == " " else str(self.birth_counter_white), " ", " ", " ", white_reproduction],
            [" " if over_under_population == " " else str(self.death_counter), " ", str(self), " ", over_under_population],
            [" " if black_reproduction == " " else str(self.birth_counter_black), " ", " ", " ", black_reproduction]
        ]
        return chars_to_print

    def move_is_valid(self, dest_piece: "Piece") -> bool:
        """check if the move is valid, TODO: check with chess logic, and add special moves"""
        if dest_piece.side == self.side:
            return False
        elif dest_piece.side == "empty":
            return True
        else:
            return True

    def tick(self, surrounding_pieces: list["Piece"], current_turn: str, update_counters: bool) -> None:
        """perform next step in life cycle, only ticks for players pieces before their turn"""
        self.surrounding_white = 0
        self.surrounding_black = 0
        for piece in surrounding_pieces:
            if piece.side == "white":
                self.surrounding_white += 1
            elif piece.side == "black":
                self.surrounding_black += 1
        if not update_counters:
            return
        if self.side == "empty":
            if current_turn == "white":
                if self.surrounding_white == 3:
                    self.birth_counter_white += 1
                else:
                    self.birth_counter_white = 0
            if current_turn == "black":
                if self.surrounding_black == 3:
                    self.birth_counter_black += 1
                else:
                    self.birth_counter_black = 0
        elif current_turn == self.side:
            same_pieces = 0
            for piece in surrounding_pieces:
                if piece.side == self.side:
                    same_pieces += 1
            if same_pieces < 2 or same_pieces > 3:
                self.death_counter += 1
            else:
                self.death_counter = 0

    def perish(self, conway: bool) -> None:
        """piece perished due to over/under population (conway=True) or capture (conway=False)"""
        pass

class Empty(Piece):
    def __init__(self) -> None:
        """empty space on the map"""
        super().__init__("empty")
    
    def __str__(self) -> str:
        return " "

    def move_is_valid(self, dest_piece: Piece) -> bool:
        return False

class Pawn(Piece):
    def __init__(self, side) -> None:
        """pawn chess piece"""
        super().__init__(side)
    
    def __str__(self) -> str:
        return "P" if self.side == "white" else "p"

class Knight(Piece):
    def __init__(self, side) -> None:
        """knight chess piece"""
        super().__init__(side)
    
    def __str__(self) -> str:
        return "N" if self.side == "white" else "n"

class Bishop(Piece):
    def __init__(self, side) -> None:
        """bishop chess piece"""
        super().__init__(side)
    
    def __str__(self) -> str:
        return "B" if self.side == "white" else "b"

class Rook(Piece):
    def __init__(self, side) -> None:
        """rook chess piece"""
        super().__init__(side)
    
    def __str__(self) -> str:
        return "R" if self.side == "white" else "r"

class Queen(Piece):
    def __init__(self, side) -> None:
        """queen chess piece"""
        super().__init__(side)
    
    def __str__(self) -> str:
        return "Q" if self.side == "white" else "q"

class King(Piece):
    def __init__(self, side) -> None:
        """king chess piece"""
        super().__init__(side)
    
    def __str__(self) -> str:
        return "K" if self.side == "white" else "k"

    def perish(self, conway) -> None:
        winning_side = "white" if self.side == "black" else "black"
        losing_side = "Black" if self.side == "black" else "White"
        if conway:
            raise Exception(f"{losing_side} king perished, {winning_side} wins!")
        else:
            raise Exception(f"{losing_side} king was captured, {winning_side} wins!")


if __name__ == "__main__":
    sys.exit(start_cli())

conway_chess.py
elesiuta/conways-game-of-chess