Example #1
0
class BaseGame:
    """Represents a game of go, including an implementation of capture rules."""

    DEFAULT_PROPERTIES = {"GM": 1, "FF": 4}

    def __init__(
        self,
        katrain,
        move_tree: GameNode = None,
        game_properties: Optional[Dict] = None,
        sgf_filename=None,
    ):
        self.katrain = katrain
        self._lock = threading.Lock()
        self.game_id = datetime.strftime(datetime.now(), "%Y-%m-%d %H %M %S")
        self.sgf_filename = sgf_filename

        self.insert_mode = False
        self.external_game = False  # not generated by katrain at some point
        if move_tree:
            self.root = move_tree
            self.external_game = PROGRAM_NAME not in self.root.get_property(
                "AP", "")
            self.komi = self.root.komi
            handicap = int(self.root.handicap)
            num_starting_moves_black = 0
            node = self.root
            while node.children:
                node = node.children[0]
                if node.player == "B":
                    num_starting_moves_black += 1
                else:
                    break

            if (
                    handicap >= 2 and not self.root.placements
                    and not (num_starting_moves_black == handicap)
                    and not (self.root.children
                             and self.root.children[0].placements)
            ):  # not really according to sgf, and not sure if still needed, last clause for fox
                self.root.place_handicap_stones(handicap)
        else:
            board_size = katrain.config("game/size")
            rules = katrain.config("game/rules")
            self.komi = katrain.config("game/komi")
            self.root = GameNode(
                properties={
                    **Game.DEFAULT_PROPERTIES,
                    **{
                        "SZ": board_size,
                        "KM": self.komi,
                        "DT": self.game_id,
                        "RU": rules
                    },
                    **(game_properties or {}),
                })
            handicap = katrain.config("game/handicap")
            if handicap:
                self.root.place_handicap_stones(handicap)

        if not self.root.get_property("RU"):
            self.root.set_property("RU", katrain.config("game/rules"))

        self.set_current_node(self.root)
        self.main_time_used = 0

        # restore shortcuts
        shortcut_id_to_node = {
            node.get_property("KTSID", None): node
            for node in self.root.nodes_in_tree
        }
        for node in self.root.nodes_in_tree:
            shortcut_id = node.get_property("KTSF", None)
            if shortcut_id and shortcut_id in shortcut_id_to_node:
                shortcut_id_to_node[shortcut_id].add_shortcut(node)

    # -- move tree functions --
    def _init_state(self):
        board_size_x, board_size_y = self.board_size
        self.board = [[-1 for _x in range(board_size_x)]
                      for _y in range(board_size_y)
                      ]  # type: List[List[int]]  #  board pos -> chain id
        self.chains = []  # type: List[List[Move]]  #   chain id -> chain
        self.prisoners = []  # type: List[Move]
        self.last_capture = []  # type: List[Move]

    def _calculate_groups(self):
        with self._lock:
            self._init_state()
            try:
                for node in self.current_node.nodes_from_root:
                    for m in node.move_with_placements:
                        self._validate_move_and_update_chains(
                            m, True
                        )  # ignore ko since we didn't know if it was forced
                    if node.clear_placements:  # handle AE by playing all moves left from empty board
                        clear_coords = {
                            c.coords
                            for c in node.clear_placements
                        }
                        stones = [
                            m for c in self.chains for m in c
                            if m.coords not in clear_coords
                        ]
                        self._init_state()
                        for m in stones:
                            self._validate_move_and_update_chains(m, True)
            except IllegalMoveException as e:
                raise Exception(f"Unexpected illegal move ({str(e)})")

    def _validate_move_and_update_chains(self, move: Move, ignore_ko: bool):
        board_size_x, board_size_y = self.board_size

        def neighbours(moves):
            return {
                self.board[m.coords[1] + dy][m.coords[0] + dx]
                for m in moves for dy, dx in [(-1, 0), (1, 0), (0, -1), (0, 1)]
                if 0 <= m.coords[0] + dx < board_size_x and 0 <= m.coords[1] +
                dy < board_size_y
            }

        ko_or_snapback = len(
            self.last_capture) == 1 and self.last_capture[0] == move
        self.last_capture = []

        if move.is_pass:
            return

        if self.board[move.coords[1]][move.coords[0]] != -1:
            raise IllegalMoveException("Space occupied")

        # merge chains connected by this move, or create a new one
        nb_chains = list({
            c
            for c in neighbours([move])
            if c >= 0 and self.chains[c][0].player == move.player
        })
        if nb_chains:
            this_chain = nb_chains[0]
            self.board = [[
                nb_chains[0] if sq in nb_chains else sq for sq in line
            ] for line in self.board]
            for oc in nb_chains[1:]:
                self.chains[nb_chains[0]] += self.chains[oc]
                self.chains[oc] = []
            self.chains[nb_chains[0]].append(move)
        else:
            this_chain = len(self.chains)
            self.chains.append([move])
        self.board[move.coords[1]][move.coords[0]] = this_chain

        # check captures
        opp_nb_chains = {
            c
            for c in neighbours([move])
            if c >= 0 and self.chains[c][0].player != move.player
        }
        for c in opp_nb_chains:
            if -1 not in neighbours(self.chains[c]):  # no liberties
                self.last_capture += self.chains[c]
                for om in self.chains[c]:
                    self.board[om.coords[1]][om.coords[0]] = -1
                self.chains[c] = []
        if ko_or_snapback and len(self.last_capture) == 1 and not ignore_ko:
            raise IllegalMoveException("Ko")
        self.prisoners += self.last_capture

        # suicide: check rules and throw exception if needed
        if -1 not in neighbours(self.chains[this_chain]):
            rules = self.rules
            if len(
                    self.chains[this_chain]
            ) == 1:  # even in new zealand rules, single stone suicide is not allowed
                raise IllegalMoveException("Single stone suicide")
            elif (isinstance(rules, str)
                  and rules in ["tromp-taylor", "new zealand"]) or (isinstance(
                      rules, dict) and rules.get("suicide", False)):
                self.last_capture += self.chains[this_chain]
                for om in self.chains[this_chain]:
                    self.board[om.coords[1]][om.coords[0]] = -1
                self.chains[this_chain] = []
                self.prisoners += self.last_capture
            else:  # suicide not allowed by rules
                raise IllegalMoveException("Suicide")

    # Play a Move from the current position, raise IllegalMoveException if invalid.
    def play(self, move: Move, ignore_ko: bool = False):
        board_size_x, board_size_y = self.board_size
        if not move.is_pass and not (0 <= move.coords[0] < board_size_x
                                     and 0 <= move.coords[1] < board_size_y):
            raise IllegalMoveException(
                f"Move {move} outside of board coordinates")
        try:
            self._validate_move_and_update_chains(move, ignore_ko)
        except IllegalMoveException:
            self._calculate_groups()
            raise
        with self._lock:
            played_node = self.current_node.play(move)
            self.current_node = played_node
        return played_node

    # Insert a list of moves from root, often just adding one.
    def sync_branch(self, moves: List[Move]):
        node = self.root
        with self._lock:
            for move in moves:
                node = node.play(move)
        return node

    def set_current_node(self, node):
        self.current_node = node
        self._calculate_groups()

    def undo(self, n_times=1, stop_on_mistake=None):
        break_on_branch = False
        cn = self.current_node  # avoid race conditions
        break_on_main_branch = False
        last_branching_node = cn
        if n_times == "branch":
            n_times = 9999
            break_on_branch = True
        elif n_times == "main-branch":
            n_times = 9999
            break_on_main_branch = True
        for move in range(n_times):
            if (stop_on_mistake is not None and cn.points_lost is not None
                    and cn.points_lost >= stop_on_mistake
                    and self.katrain.players_info[cn.player].player_type !=
                    PLAYER_AI):
                self.set_current_node(cn.parent)
                return
            previous_cn = cn
            if cn.shortcut_from:
                cn = cn.shortcut_from
            elif not cn.is_root:
                cn = cn.parent
            else:
                break  # root
            if break_on_branch and len(cn.children) > 1:
                break
            elif break_on_main_branch and cn.ordered_children[
                    0] != previous_cn:  # implies > 1 child
                last_branching_node = cn
        if break_on_main_branch:
            cn = last_branching_node
        if cn is not self.current_node:
            self.set_current_node(cn)

    def redo(self, n_times=1, stop_on_mistake=None):
        cn = self.current_node  # avoid race conditions
        for move in range(n_times):
            if cn.children:
                child = cn.ordered_children[0]
                shortcut_to = [m for m, v in cn.shortcuts_to if child == v
                               ]  # are we about to go to a shortcut node?
                if shortcut_to:
                    child = shortcut_to[0]
                cn = child
            if (move > 0 and stop_on_mistake is not None
                    and cn.points_lost is not None
                    and cn.points_lost >= stop_on_mistake
                    and self.katrain.players_info[cn.player].player_type !=
                    PLAYER_AI):
                self.set_current_node(cn.parent)
                return
        if stop_on_mistake is None:
            self.set_current_node(cn)

    @property
    def board_size(self):
        return self.root.board_size

    @property
    def stones(self):
        with self._lock:
            return sum(self.chains, [])

    @property
    def end_result(self):
        if self.current_node.end_state:
            return self.current_node.end_state
        if self.current_node.parent and self.current_node.is_pass and self.current_node.parent.is_pass:
            return self.manual_score or i18n._("board-game-end")

    @property
    def prisoner_count(
        self,
    ) -> Dict:  # returns prisoners that are of a certain colour as {B: black stones captures, W: white stones captures}
        return {
            player: sum([m.player == player for m in self.prisoners])
            for player in Move.PLAYERS
        }

    @property
    def rules(self):
        return KataGoEngine.get_rules(self.root.ruleset)

    @property
    def manual_score(self):
        rules = self.rules
        if (not self.current_node.ownership
                or str(rules).lower() not in ["jp", "japanese"]
                or not self.current_node.parent
                or not self.current_node.parent.ownership):
            if not self.current_node.score:
                return None
            return self.current_node.format_score(
                round(2 * self.current_node.score) / 2) + "?"
        board_size_x, board_size_y = self.board_size
        mean_ownership = [(c + p) / 2 for c, p in zip(
            self.current_node.ownership, self.current_node.parent.ownership)]
        ownership_grid = var_to_grid(mean_ownership,
                                     (board_size_x, board_size_y))
        stones = {m.coords: m.player for m in self.stones}
        lo_threshold = 0.15
        hi_threshold = 0.85
        max_unknown = 10
        max_dame = 4 * (board_size_x + board_size_y)

        def japanese_score_square(square, owner):
            player = stones.get(square, None)
            if ((player == "B" and owner > hi_threshold)
                    or (player == "W" and owner < -hi_threshold)
                    or abs(owner) < lo_threshold):
                return 0  # dame or own stones
            if player is None and abs(owner) >= hi_threshold:
                return round(owner)  # surrounded empty intersection
            if (player == "B"
                    and owner < -hi_threshold) or (player == "W"
                                                   and owner > hi_threshold):
                return 2 * round(owner)  # captured stone
            return math.nan  # unknown!

        scored_squares = [
            japanese_score_square((x, y), ownership_grid[y][x])
            for y in range(board_size_y) for x in range(board_size_x)
        ]
        num_sq = {
            t: sum([s == t for s in scored_squares])
            for t in [-2, -1, 0, 1, 2]
        }
        num_unkn = sum(math.isnan(s) for s in scored_squares)
        prisoners = self.prisoner_count
        score = sum([t * n for t, n in num_sq.items()
                     ]) + prisoners["W"] - prisoners["B"] - self.komi
        self.katrain.log(
            f"Manual Scoring: {num_sq} score by square with {num_unkn} unknown, {prisoners} captures, and {self.komi} komi -> score = {score}",
            OUTPUT_DEBUG,
        )
        if num_unkn > max_unknown or (num_sq[0] - len(stones)) > max_dame:
            return None
        return self.current_node.format_score(score)

    def __repr__(self):
        return ("\n".join("".join(self.chains[c][0].player if c >= 0 else "-"
                                  for c in line) for line in self.board) +
                f"\ncaptures: {self.prisoner_count}")

    def update_root_properties(self):
        def player_name(player_info):
            if player_info.name and player_info.player_type == PLAYER_HUMAN:
                return player_info.name
            else:
                return f"{i18n._(player_info.player_type)} ({i18n._(player_info.player_subtype)}){SGF_INTERNAL_COMMENTS_MARKER}"

        root_properties = self.root.properties
        x_properties = {}
        for bw in "BW":
            if not self.external_game:
                x_properties["P" + bw] = player_name(
                    self.katrain.players_info[bw])
                player_info = self.katrain.players_info[bw]
                if player_info.player_type == PLAYER_AI:
                    x_properties[bw + "R"] = rank_label(
                        player_info.calculated_rank)
        if "+" in str(self.end_result):
            x_properties["RE"] = self.end_result
        self.root.properties = {
            **root_properties,
            **{k: [v]
               for k, v in x_properties.items()}
        }

    def generate_filename(self):
        self.update_root_properties()
        player_names = {
            bw: re.sub(r"[\u200b\u3164'<>:\"/\\|?*]", "",
                       self.root.get_property("P" + bw, bw))
            for bw in "BW"
        }
        base_game_name = f"{PROGRAM_NAME}_{player_names['B']} vs {player_names['W']}"
        return f"{base_game_name} {self.game_id}.sgf"

    def write_sgf(self, filename: str, trainer_config: Optional[Dict] = None):
        if trainer_config is None:
            trainer_config = self.katrain.config("trainer", {})
        save_feedback = trainer_config.get("save_feedback", False)
        eval_thresholds = trainer_config["eval_thresholds"]
        save_analysis = trainer_config.get("save_analysis", False)
        save_marks = trainer_config.get("save_marks", False)
        self.update_root_properties()
        show_dots_for = {
            bw: trainer_config.get("eval_show_ai", True)
            or self.katrain.players_info[bw].human
            for bw in "BW"
        }
        sgf = self.root.sgf(
            save_comments_player=show_dots_for,
            save_comments_class=save_feedback,
            eval_thresholds=eval_thresholds,
            save_analysis=save_analysis,
            save_marks=save_marks,
        )
        self.sgf_filename = filename
        os.makedirs(os.path.dirname(filename), exist_ok=True)
        with open(filename, "w", encoding="utf-8") as f:
            f.write(sgf)
        return i18n._("sgf written").format(file_name=filename)
Example #2
0
class Game:
    """Represents a game of go, including an implementation of capture rules."""

    DEFAULT_PROPERTIES = {
        "GM": 1,
        "FF": 4,
        "AP": f"KaTrain:{HOMEPAGE}",
        "CA": "UTF-8"
    }

    def __init__(
        self,
        katrain,
        engine: Union[Dict, KataGoEngine],
        move_tree: GameNode = None,
        analyze_fast=False,
        game_properties: Optional[Dict] = None,
    ):
        self.katrain = katrain
        self._lock = threading.Lock()
        if not isinstance(engine, Dict):
            engine = {"B": engine, "W": engine}
        self.engines = engine
        self.game_id = datetime.strftime(datetime.now(), "%Y-%m-%d %H %M %S")

        if move_tree:
            self.root = move_tree
            self.komi = self.root.komi
            handicap = int(self.root.get_property("HA", 0))
            if (
                    handicap >= 2 and not self.root.placements
                    and not (not self.root.move_with_placements
                             and self.root.children
                             and self.root.children[0].placements)
            ):  # not really according to sgf, and not sure if still needed, last clause for fox
                self.root.place_handicap_stones(handicap)
        else:
            board_size = katrain.config("game/size")
            self.komi = katrain.config("game/komi")
            self.root = GameNode(
                properties={
                    **Game.DEFAULT_PROPERTIES,
                    **{
                        "SZ": board_size,
                        "KM": self.komi,
                        "DT": self.game_id
                    },
                    **(game_properties or {}),
                })
            handicap = katrain.config("game/handicap")
            if handicap:
                self.root.place_handicap_stones(handicap)

        if not self.root.get_property("RU"):
            self.root.set_property("RU", katrain.config("game/rules"))

        self.set_current_node(self.root)
        threading.Thread(
            target=lambda: self.analyze_all_nodes(-1_000_000,
                                                  analyze_fast=analyze_fast),
            daemon=True).start()  # return faster, but bypass Kivy Clock

    def analyze_all_nodes(self, priority=0, analyze_fast=False):
        for node in self.root.nodes_in_tree:
            node.analyze(self.engines[node.next_player],
                         priority=priority,
                         analyze_fast=analyze_fast)

    # -- move tree functions --
    def _calculate_groups(self):
        board_size_x, board_size_y = self.board_size
        with self._lock:
            self.board = [[-1 for _x in range(board_size_x)]
                          for _y in range(board_size_y)
                          ]  # type: List[List[int]]  #  board pos -> chain id
            self.chains = []  # type: List[List[Move]]  #   chain id -> chain
            self.prisoners = []  # type: List[Move]
            self.last_capture = []  # type: List[Move]
            try:
                for node in self.current_node.nodes_from_root:
                    for m in node.move_with_placements:
                        self._validate_move_and_update_chains(
                            m, True
                        )  # ignore ko since we didn't know if it was forced
            except IllegalMoveException as e:
                raise Exception(f"Unexpected illegal move ({str(e)})")

    def _validate_move_and_update_chains(self, move: Move, ignore_ko: bool):
        board_size_x, board_size_y = self.board_size

        def neighbours(moves):
            return {
                self.board[m.coords[1] + dy][m.coords[0] + dx]
                for m in moves for dy, dx in [(-1, 0), (1, 0), (0, -1), (0, 1)]
                if 0 <= m.coords[0] + dx < board_size_x and 0 <= m.coords[1] +
                dy < board_size_y
            }

        ko_or_snapback = len(
            self.last_capture) == 1 and self.last_capture[0] == move
        self.last_capture = []

        if move.is_pass:
            return

        if self.board[move.coords[1]][move.coords[0]] != -1:
            raise IllegalMoveException("Space occupied")

        nb_chains = list({
            c
            for c in neighbours([move])
            if c >= 0 and self.chains[c][0].player == move.player
        })
        if nb_chains:
            this_chain = nb_chains[0]
            self.board = [[
                nb_chains[0] if sq in nb_chains else sq for sq in line
            ] for line in self.board]  # merge chains connected by this move
            for oc in nb_chains[1:]:
                self.chains[nb_chains[0]] += self.chains[oc]
                self.chains[oc] = []
            self.chains[nb_chains[0]].append(move)
        else:
            this_chain = len(self.chains)
            self.chains.append([move])
        self.board[move.coords[1]][move.coords[0]] = this_chain

        opp_nb_chains = {
            c
            for c in neighbours([move])
            if c >= 0 and self.chains[c][0].player != move.player
        }
        for c in opp_nb_chains:
            if -1 not in neighbours(self.chains[c]):
                self.last_capture += self.chains[c]
                for om in self.chains[c]:
                    self.board[om.coords[1]][om.coords[0]] = -1
                self.chains[c] = []
        if ko_or_snapback and len(self.last_capture) == 1 and not ignore_ko:
            raise IllegalMoveException("Ko")
        self.prisoners += self.last_capture

        if -1 not in neighbours(self.chains[this_chain]):  # TODO: NZ rules?
            raise IllegalMoveException("Suicide")

    # Play a Move from the current position, raise IllegalMoveException if invalid.
    def play(self, move: Move, ignore_ko: bool = False, analyze=True):
        board_size_x, board_size_y = self.board_size
        if not move.is_pass and not (0 <= move.coords[0] < board_size_x
                                     and 0 <= move.coords[1] < board_size_y):
            raise IllegalMoveException(
                f"Move {move} outside of board coordinates")
        try:
            self._validate_move_and_update_chains(move, ignore_ko)
        except IllegalMoveException:
            self._calculate_groups()
            raise
        with self._lock:
            played_node = self.current_node.play(move)
            self.current_node = played_node
        if analyze:
            played_node.analyze(self.engines[played_node.next_player])
        return played_node

    def set_current_node(self, node):
        self.current_node = node
        self._calculate_groups()

    def undo(self, n_times=1):
        cn = self.current_node  # avoid race conditions
        for _ in range(n_times):
            if not cn.is_root:
                cn = cn.parent
        self.set_current_node(cn)

    def redo(self, n_times=1):
        cn = self.current_node  # avoid race conditions
        for _ in range(n_times):
            if cn.children:
                cn = cn.ordered_children[0]
        self.set_current_node(cn)

    def cycle_children(self, direction):
        cn = self.current_node  # avoid race conditions
        if cn.parent and len(cn.parent.children) > 1:
            ordered_children = cn.parent.ordered_children
            ix = (ordered_children.index(cn) + len(ordered_children) +
                  direction) % len(ordered_children)
            self.set_current_node(ordered_children[ix])

    @property
    def board_size(self):
        return self.root.board_size

    @property
    def stones(self):
        with self._lock:
            return sum(self.chains, [])

    @property
    def end_result(self):
        if self.current_node.end_state:
            return self.current_node.end_state
        if self.current_node.parent and self.current_node.is_pass and self.current_node.parent.is_pass:
            return self.manual_score or i18n._("board-game-end")

    @property
    def prisoner_count(
        self,
    ) -> Dict:  # returns prisoners that are of a certain colour as {B: black stones captures, W: white stones captures}
        return {
            player: sum([m.player == player for m in self.prisoners])
            for player in Move.PLAYERS
        }

    @property
    def manual_score(self):
        rules = self.engines["B"].get_rules(self.root)
        if not self.current_node.ownership or rules != "japanese":
            if not self.current_node.score:
                return None
            self.katrain.log(
                f"rules '{rules}' are not japanese, or no ownership available ({not self.current_node.ownership}) -> no manual score available",
                OUTPUT_DEBUG,
            )
            return self.current_node.format_score(
                round(2 * self.current_node.score) / 2) + "?"
        board_size_x, board_size_y = self.board_size
        ownership_grid = var_to_grid(self.current_node.ownership,
                                     (board_size_x, board_size_y))
        stones = {m.coords: m.player for m in self.stones}
        lo_threshold = 0.15
        hi_threshold = 0.85
        max_unknown = 10
        max_dame = 4 * (board_size_x + board_size_y)

        def japanese_score_square(square, owner):
            player = stones.get(square, None)
            if ((player == "B" and owner > hi_threshold)
                    or (player == "W" and owner < -hi_threshold)
                    or abs(owner) < lo_threshold):
                return 0  # dame or own stones
            if player is None and abs(owner) >= hi_threshold:
                return round(owner)  # surrounded empty intersection
            if (player == "B"
                    and owner < -hi_threshold) or (player == "W"
                                                   and owner > hi_threshold):
                return 2 * round(owner)  # captured stone
            return math.nan  # unknown!

        scored_squares = [
            japanese_score_square((x, y), ownership_grid[y][x])
            for y in range(board_size_y) for x in range(board_size_x)
        ]
        num_sq = {
            t: sum([s == t for s in scored_squares])
            for t in [-2, -1, 0, 1, 2]
        }
        num_unkn = sum(math.isnan(s) for s in scored_squares)
        prisoners = self.prisoner_count
        score = sum([t * n for t, n in num_sq.items()
                     ]) + prisoners["W"] - prisoners["B"] - self.komi
        self.katrain.log(
            f"Manual Scoring: {num_sq} score by square with {num_unkn} unknown, {prisoners} captures, and {self.komi} komi -> score = {score}",
            OUTPUT_DEBUG,
        )
        if num_unkn > max_unknown or (num_sq[0] - len(stones)) > max_dame:
            return None
        return self.current_node.format_score(score)

    def __repr__(self):
        return ("\n".join("".join(self.chains[c][0].player if c >= 0 else "-"
                                  for c in line) for line in self.board) +
                f"\ncaptures: {self.prisoner_count}")

    def write_sgf(
        self,
        path: str,
        trainer_config: Optional[Dict] = None,
    ):
        if trainer_config is None:
            trainer_config = self.katrain.config("trainer")
        save_feedback = trainer_config["save_feedback"]
        eval_thresholds = trainer_config["eval_thresholds"]

        def player_name(player_info):
            return f"{i18n._(player_info.player_type)} ({i18n._(player_info.player_subtype)})"

        root_properties = self.root.properties
        x_properties = {}
        if "KaTrain" in self.root.get_property("AP", ""):
            for bw in "BW":
                x_properties["P" + bw] = player_name(
                    self.katrain.players_info[bw])
                player_info = self.katrain.players_info[bw]
                if player_info.player_type == PLAYER_AI:
                    x_properties[bw + "R"] = rank_label(
                        player_info.calculated_rank)
        if "+" in str(self.end_result):
            x_properties["RE"] = self.end_result
        self.root.properties = {
            **root_properties,
            **{k: [v]
               for k, v in x_properties.items()}
        }
        player_names = {
            bw: re.sub(r"['<>:\"/\\|?*]", "",
                       self.root.get_property("P" + bw, bw))
            for bw in "BW"
        }
        game_name = f"katrain_{player_names['B']} vs {player_names['W']} {self.game_id}"
        file_name = os.path.abspath(os.path.join(path, f"{game_name}.sgf"))
        os.makedirs(os.path.dirname(file_name), exist_ok=True)

        show_dots_for = {
            bw: trainer_config.get("eval_show_ai", True)
            or self.katrain.players_info[bw].human
            for bw in "BW"
        }
        sgf = self.root.sgf(save_comments_player=show_dots_for,
                            save_comments_class=save_feedback,
                            eval_thresholds=eval_thresholds)
        with open(file_name, "w", encoding="utf-8") as f:
            f.write(sgf)
        self.root.properties = root_properties
        return i18n._("sgf written").format(file_name=file_name)

    def analyze_extra(self, mode, **kwargs):
        stones = {s.coords for s in self.stones}
        cn = self.current_node

        engine = self.engines[cn.next_player]
        Clock.schedule_once(self.katrain.analysis_controls.hints.activate, 0)

        if mode == "extra":
            if kwargs.get("continuous", False):
                visits = min(
                    1_000_000_000,
                    max(engine.config["max_visits"],
                        math.ceil(cn.analysis_visits_requested * 1.25)))
            else:
                visits = cn.analysis_visits_requested + engine.config[
                    "max_visits"]
            self.katrain.controls.set_status(
                i18n._("extra analysis").format(visits=visits),
                STATUS_ANALYSIS)
            cn.analyze(engine,
                       visits=visits,
                       priority=-1_000,
                       time_limit=False)
            return
        if mode == "game":
            nodes = self.root.nodes_in_tree
            if "visits" in kwargs:
                visits = kwargs["visits"]
            else:
                min_visits = min(node.analysis_visits_requested
                                 for node in nodes)
                visits = min_visits + engine.config["max_visits"]
            for node in nodes:
                node.analyze(engine,
                             visits=visits,
                             priority=-1_000_000,
                             time_limit=False)
            self.katrain.controls.set_status(
                i18n._("game re-analysis").format(visits=visits),
                STATUS_ANALYSIS)
            return

        elif mode == "sweep":
            board_size_x, board_size_y = self.board_size
            if cn.analysis_ready:
                policy_grid = (var_to_grid(self.current_node.policy,
                                           size=(board_size_x, board_size_y))
                               if self.current_node.policy else None)
                analyze_moves = sorted(
                    [
                        Move(coords=(x, y), player=cn.next_player)
                        for x in range(board_size_x)
                        for y in range(board_size_y)
                        if (policy_grid is None and
                            (x, y) not in stones) or policy_grid[y][x] >= 0
                    ],
                    key=lambda mv: -policy_grid[mv.coords[1]][mv.coords[0]],
                )
            else:
                analyze_moves = [
                    Move(coords=(x, y), player=cn.next_player)
                    for x in range(board_size_x) for y in range(board_size_y)
                    if (x, y) not in stones
                ]
            visits = engine.config["fast_visits"]
            self.katrain.controls.set_status(
                i18n._("sweep analysis").format(visits=visits),
                STATUS_ANALYSIS)
            priority = -1_000_000_000
        elif mode in ["equalize", "alternative"]:
            if not cn.analysis_ready:
                self.katrain.controls.set_status(
                    i18n._("wait-before-equalize"), STATUS_INFO,
                    self.current_node)
                return

            analyze_moves = [
                Move.from_gtp(gtp, player=cn.next_player)
                for gtp, _ in cn.analysis["moves"].items()
            ]
            priority = -1_000
            if mode == "alternative":  # also do a quick update on current candidates so it doesn't look too weird
                self.katrain.controls.set_status(
                    i18n._("alternative analysis"), STATUS_ANALYSIS)
                cn.analyze(engine,
                           priority=-500,
                           time_limit=False,
                           find_alternatives=True)
                visits = engine.config["fast_visits"]
            else:
                visits = max(d["visits"]
                             for d in cn.analysis["moves"].values())
                self.katrain.controls.set_status(
                    i18n._("equalizing analysis").format(visits=visits),
                    STATUS_ANALYSIS)
        else:
            raise ValueError("Invalid analysis mode")
        for move in analyze_moves:
            if cn.analysis["moves"].get(move.gtp(),
                                        {"visits": 0})["visits"] < visits:
                cn.analyze(
                    engine,
                    priority,
                    visits=visits,
                    refine_move=move,
                    time_limit=False
                )  # explicitly requested so take as long as you need

    def analyze_undo(self, node):
        train_config = self.katrain.config("trainer")
        move = node.move
        if node != self.current_node or node.auto_undo is not None or not node.analysis_ready or not move:
            return
        points_lost = node.points_lost
        thresholds = train_config["eval_thresholds"]
        num_undo_prompts = train_config["num_undo_prompts"]
        i = 0
        while i < len(thresholds) and points_lost < thresholds[i]:
            i += 1
        num_undos = num_undo_prompts[i] if i < len(num_undo_prompts) else 0
        if num_undos == 0:
            undo = False
        elif num_undos < 1:  # probability
            undo = int(node.undo_threshold < num_undos) and len(
                node.parent.children) == 1
        else:
            undo = len(node.parent.children) <= num_undos

        node.auto_undo = undo
        if undo:
            self.undo(1)
            self.katrain.controls.set_status(
                i18n._("teaching undo message").format(
                    move=move.gtp(), points_lost=points_lost), STATUS_TEACHING)
            self.katrain.update_state()
Example #3
0
class Game:
    """Represents a game of go, including an implementation of capture rules."""

    DEFAULT_PROPERTIES = {"GM": 1, "FF": 4}

    def __init__(
        self,
        katrain,
        engine: Union[Dict, KataGoEngine],
        move_tree: GameNode = None,
        analyze_fast=False,
        game_properties: Optional[Dict] = None,
        sgf_filename=None,
    ):
        self.katrain = katrain
        self._lock = threading.Lock()
        if not isinstance(engine, Dict):
            engine = {"B": engine, "W": engine}
        self.engines = engine
        self.game_id = datetime.strftime(datetime.now(), "%Y-%m-%d %H %M %S")
        self.sgf_filename = sgf_filename

        self.insert_mode = False
        self.insert_after = None

        self.region_of_interest = None
        self.external_game = False  # not generated by katrain at some point
        if move_tree:
            self.root = move_tree
            self.external_game = PROGRAM_NAME not in self.root.get_property(
                "AP", "")
            self.komi = self.root.komi
            handicap = int(self.root.handicap)
            num_starting_moves_black = 0
            node = self.root
            while node.children:
                node = node.children[0]
                if node.player == "B":
                    num_starting_moves_black += 1
                else:
                    break

            if (
                    handicap >= 2 and not self.root.placements
                    and not (num_starting_moves_black == handicap)
                    and not (self.root.children
                             and self.root.children[0].placements)
            ):  # not really according to sgf, and not sure if still needed, last clause for fox
                self.root.place_handicap_stones(handicap)
        else:
            board_size = katrain.config("game/size")
            rules = katrain.config("game/rules")
            self.komi = katrain.config("game/komi")
            self.root = GameNode(
                properties={
                    **Game.DEFAULT_PROPERTIES,
                    **{
                        "SZ": board_size,
                        "KM": self.komi,
                        "DT": self.game_id,
                        "RU": rules
                    },
                    **(game_properties or {}),
                })
            handicap = katrain.config("game/handicap")
            if handicap:
                self.root.place_handicap_stones(handicap)

        if not self.root.get_property("RU"):
            self.root.set_property("RU", katrain.config("game/rules"))

        self.set_current_node(self.root)
        self.main_time_used = 0

        # restore shortcuts
        shortcut_id_to_node = {
            node.get_property("KTSID", None): node
            for node in self.root.nodes_in_tree
        }
        for node in self.root.nodes_in_tree:
            shortcut_id = node.get_property("KTSF", None)
            if shortcut_id and shortcut_id in shortcut_id_to_node:
                shortcut_id_to_node[shortcut_id].add_shortcut(node)

        threading.Thread(
            target=lambda: self.analyze_all_nodes(
                -1_000_000, analyze_fast=analyze_fast, even_if_present=False),
            daemon=True,
        ).start()  # return faster, but bypass Kivy Clock

    def analyze_all_nodes(self,
                          priority=0,
                          analyze_fast=False,
                          even_if_present=True):
        for node in self.root.nodes_in_tree:
            # forced, or not present, or something went wrong in loading
            if even_if_present or not node.analysis_from_sgf or not node.load_analysis(
            ):
                node.clear_analysis()
                node.analyze(self.engines[node.next_player],
                             priority=priority,
                             analyze_fast=analyze_fast)

    # -- move tree functions --
    def _calculate_groups(self):
        board_size_x, board_size_y = self.board_size
        with self._lock:
            self.board = [[-1 for _x in range(board_size_x)]
                          for _y in range(board_size_y)
                          ]  # type: List[List[int]]  #  board pos -> chain id
            self.chains = []  # type: List[List[Move]]  #   chain id -> chain
            self.prisoners = []  # type: List[Move]
            self.last_capture = []  # type: List[Move]
            try:
                for node in self.current_node.nodes_from_root:
                    for m in node.move_with_placements:
                        self._validate_move_and_update_chains(
                            m, True
                        )  # ignore ko since we didn't know if it was forced
            except IllegalMoveException as e:
                raise Exception(f"Unexpected illegal move ({str(e)})")

    def _validate_move_and_update_chains(self, move: Move, ignore_ko: bool):
        board_size_x, board_size_y = self.board_size

        def neighbours(moves):
            return {
                self.board[m.coords[1] + dy][m.coords[0] + dx]
                for m in moves for dy, dx in [(-1, 0), (1, 0), (0, -1), (0, 1)]
                if 0 <= m.coords[0] + dx < board_size_x and 0 <= m.coords[1] +
                dy < board_size_y
            }

        ko_or_snapback = len(
            self.last_capture) == 1 and self.last_capture[0] == move
        self.last_capture = []

        if move.is_pass:
            return

        if self.board[move.coords[1]][move.coords[0]] != -1:
            raise IllegalMoveException("Space occupied")

        nb_chains = list({
            c
            for c in neighbours([move])
            if c >= 0 and self.chains[c][0].player == move.player
        })
        if nb_chains:
            this_chain = nb_chains[0]
            self.board = [[
                nb_chains[0] if sq in nb_chains else sq for sq in line
            ] for line in self.board]  # merge chains connected by this move
            for oc in nb_chains[1:]:
                self.chains[nb_chains[0]] += self.chains[oc]
                self.chains[oc] = []
            self.chains[nb_chains[0]].append(move)
        else:
            this_chain = len(self.chains)
            self.chains.append([move])
        self.board[move.coords[1]][move.coords[0]] = this_chain

        opp_nb_chains = {
            c
            for c in neighbours([move])
            if c >= 0 and self.chains[c][0].player != move.player
        }
        for c in opp_nb_chains:
            if -1 not in neighbours(self.chains[c]):
                self.last_capture += self.chains[c]
                for om in self.chains[c]:
                    self.board[om.coords[1]][om.coords[0]] = -1
                self.chains[c] = []
        if ko_or_snapback and len(self.last_capture) == 1 and not ignore_ko:
            raise IllegalMoveException("Ko")
        self.prisoners += self.last_capture

        if -1 not in neighbours(self.chains[this_chain]):  # TODO: NZ rules?
            raise IllegalMoveException("Suicide")

    def set_insert_mode(self, mode):
        if mode == "toggle":
            mode = not self.insert_mode
        if mode == self.insert_mode:
            return
        self.insert_mode = mode
        if mode:
            children = self.current_node.ordered_children
            if not children:
                self.insert_mode = False
            else:
                self.insert_after = self.current_node.ordered_children[0]
                self.katrain.controls.set_status(
                    i18n._("starting insert mode"), STATUS_INFO)
        else:
            copy_from_node = self.insert_after
            copy_to_node = self.current_node
            num_copied = 0
            if copy_to_node != self.insert_after.parent:
                above_insertion_root = self.insert_after.parent.nodes_from_root
                already_inserted_moves = [
                    n.move for n in copy_to_node.nodes_from_root
                    if n not in above_insertion_root and n.move
                ]
                try:
                    while True:
                        if copy_from_node.move not in already_inserted_moves:
                            for m in copy_from_node.move_with_placements:
                                self._validate_move_and_update_chains(m, True)
                            # this inserts
                            copy_to_node = GameNode(
                                parent=copy_to_node,
                                properties=copy.deepcopy(
                                    copy_from_node.properties))
                            num_copied += 1
                        if not copy_from_node.children:
                            break
                        copy_from_node = copy_from_node.ordered_children[0]
                except IllegalMoveException:
                    pass  # illegal move = stop
                self._calculate_groups()  # recalculate groups
                self.katrain.controls.set_status(
                    i18n._("ending insert mode").format(num_copied=num_copied),
                    STATUS_INFO)
                self.analyze_all_nodes(analyze_fast=True,
                                       even_if_present=False)
            else:
                self.katrain.controls.set_status("", STATUS_INFO)
        self.katrain.controls.move_tree.insert_node = self.insert_after if self.insert_mode else None
        self.katrain.controls.move_tree.redraw()
        self.katrain.update_state(redraw_board=True)

    # Play a Move from the current position, raise IllegalMoveException if invalid.
    def play(self, move: Move, ignore_ko: bool = False, analyze=True):
        board_size_x, board_size_y = self.board_size
        if not move.is_pass and not (0 <= move.coords[0] < board_size_x
                                     and 0 <= move.coords[1] < board_size_y):
            raise IllegalMoveException(
                f"Move {move} outside of board coordinates")
        try:
            self._validate_move_and_update_chains(move, ignore_ko)
        except IllegalMoveException:
            self._calculate_groups()
            raise
        with self._lock:
            played_node = self.current_node.play(move)
            self.current_node = played_node
        if analyze:
            if self.region_of_interest:
                played_node.analyze(self.engines[played_node.next_player],
                                    analyze_fast=True)
                played_node.analyze(self.engines[played_node.next_player],
                                    region_of_interest=self.region_of_interest)
            else:
                played_node.analyze(self.engines[played_node.next_player])
        return played_node

    def set_current_node(self, node):
        if self.insert_mode:
            self.katrain.controls.set_status(
                i18n._("finish inserting before navigating"), STATUS_ERROR)
            return

        self.current_node = node
        self._calculate_groups()

    def undo(self, n_times=1, stop_on_mistake=None):
        # allow undo/delete only in insert mode
        cn = self.current_node  # avoid race conditions
        if self.insert_mode:  # in insert mode, undo = delete
            if n_times == 1 and cn not in self.insert_after.nodes_from_root:
                cn.parent.children = [c for c in cn.parent.children if c != cn]
                self.current_node = cn.parent
                self._calculate_groups()
            return
        break_on_branch = False
        break_on_main_branch = False
        last_branching_node = cn
        if n_times == "branch":
            n_times = 9999
            break_on_branch = True
        elif n_times == "main-branch":
            n_times = 9999
            break_on_main_branch = True
        for move in range(n_times):
            if (stop_on_mistake is not None and cn.points_lost is not None
                    and cn.points_lost >= stop_on_mistake
                    and self.katrain.players_info[cn.player].player_type !=
                    PLAYER_AI):
                self.set_current_node(cn.parent)
                return
            previous_cn = cn
            if cn.shortcut_from:
                cn = cn.shortcut_from
            elif not cn.is_root:
                cn = cn.parent
            else:
                break  # root
            if break_on_branch and len(cn.children) > 1:
                break
            elif break_on_main_branch and cn.ordered_children[
                    0] != previous_cn:  # implies > 1 child
                last_branching_node = cn
        if break_on_main_branch:
            cn = last_branching_node
        if cn is not self.current_node:
            self.set_current_node(cn)

    def redo(self, n_times=1, stop_on_mistake=None):
        if self.insert_mode:
            return
        cn = self.current_node  # avoid race conditions
        for move in range(n_times):
            if cn.children:
                child = cn.ordered_children[0]
                shortcut_to = [m for m, v in cn.shortcuts_to if child == v
                               ]  # are we about to go to a shortcut node?
                if shortcut_to:
                    child = shortcut_to[0]
                cn = child
            if (move > 0 and stop_on_mistake is not None
                    and cn.points_lost is not None
                    and cn.points_lost >= stop_on_mistake
                    and self.katrain.players_info[cn.player].player_type !=
                    PLAYER_AI):
                self.set_current_node(cn.parent)
                return
        if stop_on_mistake is None:
            self.set_current_node(cn)

    def cycle_children(self, direction):
        cn = self.current_node  # avoid race conditions
        if cn.parent and len(cn.parent.children) > 1:
            ordered_children = cn.parent.ordered_children
            ix = (ordered_children.index(cn) + len(ordered_children) +
                  direction) % len(ordered_children)
            self.set_current_node(ordered_children[ix])

    @property
    def board_size(self):
        return self.root.board_size

    @property
    def stones(self):
        with self._lock:
            return sum(self.chains, [])

    @property
    def end_result(self):
        if self.current_node.end_state:
            return self.current_node.end_state
        if self.current_node.parent and self.current_node.is_pass and self.current_node.parent.is_pass:
            return self.manual_score or i18n._("board-game-end")

    @property
    def prisoner_count(
        self,
    ) -> Dict:  # returns prisoners that are of a certain colour as {B: black stones captures, W: white stones captures}
        return {
            player: sum([m.player == player for m in self.prisoners])
            for player in Move.PLAYERS
        }

    @property
    def manual_score(self):
        rules = self.engines["B"].get_rules(self.root)
        if not self.current_node.ownership or rules != "japanese":
            if not self.current_node.score:
                return None
            self.katrain.log(
                f"rules '{rules}' are not japanese, or no ownership available ({not self.current_node.ownership}) -> no manual score available",
                OUTPUT_DEBUG,
            )
            return self.current_node.format_score(
                round(2 * self.current_node.score) / 2) + "?"
        board_size_x, board_size_y = self.board_size
        ownership_grid = var_to_grid(self.current_node.ownership,
                                     (board_size_x, board_size_y))
        stones = {m.coords: m.player for m in self.stones}
        lo_threshold = 0.15
        hi_threshold = 0.85
        max_unknown = 10
        max_dame = 4 * (board_size_x + board_size_y)

        def japanese_score_square(square, owner):
            player = stones.get(square, None)
            if ((player == "B" and owner > hi_threshold)
                    or (player == "W" and owner < -hi_threshold)
                    or abs(owner) < lo_threshold):
                return 0  # dame or own stones
            if player is None and abs(owner) >= hi_threshold:
                return round(owner)  # surrounded empty intersection
            if (player == "B"
                    and owner < -hi_threshold) or (player == "W"
                                                   and owner > hi_threshold):
                return 2 * round(owner)  # captured stone
            return math.nan  # unknown!

        scored_squares = [
            japanese_score_square((x, y), ownership_grid[y][x])
            for y in range(board_size_y) for x in range(board_size_x)
        ]
        num_sq = {
            t: sum([s == t for s in scored_squares])
            for t in [-2, -1, 0, 1, 2]
        }
        num_unkn = sum(math.isnan(s) for s in scored_squares)
        prisoners = self.prisoner_count
        score = sum([t * n for t, n in num_sq.items()
                     ]) + prisoners["W"] - prisoners["B"] - self.komi
        self.katrain.log(
            f"Manual Scoring: {num_sq} score by square with {num_unkn} unknown, {prisoners} captures, and {self.komi} komi -> score = {score}",
            OUTPUT_DEBUG,
        )
        if num_unkn > max_unknown or (num_sq[0] - len(stones)) > max_dame:
            return None
        return self.current_node.format_score(score)

    def __repr__(self):
        return ("\n".join("".join(self.chains[c][0].player if c >= 0 else "-"
                                  for c in line) for line in self.board) +
                f"\ncaptures: {self.prisoner_count}")

    def update_root_properties(self):
        def player_name(player_info):
            if player_info.name and player_info.player_type == PLAYER_HUMAN:
                return player_info.name
            else:
                return f"{i18n._(player_info.player_type)} ({i18n._(player_info.player_subtype)})"

        root_properties = self.root.properties
        x_properties = {}
        for bw in "BW":
            if not self.external_game:
                x_properties["P" +
                             bw] = player_name(self.katrain.players_info[bw]
                                               ) + SGF_INTERNAL_COMMENTS_MARKER
                player_info = self.katrain.players_info[bw]
                if player_info.player_type == PLAYER_AI:
                    x_properties[bw + "R"] = rank_label(
                        player_info.calculated_rank)
        if "+" in str(self.end_result):
            x_properties["RE"] = self.end_result
        self.root.properties = {
            **root_properties,
            **{k: [v]
               for k, v in x_properties.items()}
        }

    def generate_filename(self):
        self.update_root_properties()
        player_names = {
            bw: re.sub(r"[\u200b\u3164'<>:\"/\\|?*]", "",
                       self.root.get_property("P" + bw, bw))
            for bw in "BW"
        }
        base_game_name = f"{PROGRAM_NAME}_{player_names['B']} vs {player_names['W']}"
        return f"{base_game_name} {self.game_id}.sgf"

    def write_sgf(self, filename: str, trainer_config: Optional[Dict] = None):
        if trainer_config is None:
            trainer_config = self.katrain.config("trainer", {})
        save_feedback = trainer_config.get("save_feedback", False)
        eval_thresholds = trainer_config["eval_thresholds"]
        save_analysis = trainer_config.get("save_analysis", False)
        self.update_root_properties()
        show_dots_for = {
            bw: trainer_config.get("eval_show_ai", True)
            or self.katrain.players_info[bw].human
            for bw in "BW"
        }
        sgf = self.root.sgf(
            save_comments_player=show_dots_for,
            save_comments_class=save_feedback,
            eval_thresholds=eval_thresholds,
            save_analysis=save_analysis,
        )
        self.sgf_filename = filename
        os.makedirs(os.path.dirname(filename), exist_ok=True)
        with open(filename, "w", encoding="utf-8") as f:
            f.write(sgf)
        return i18n._("sgf written").format(file_name=filename)

    def set_region_of_interest(self, region_of_interest):
        x1, x2, y1, y2 = region_of_interest
        xmin, xmax = min(x1, x2), max(x1, x2)
        ymin, ymax = min(y1, y2), max(y1, y2)
        szx, szy = self.board_size
        if not (xmin == xmax and ymin == ymax) and not (
                xmax - xmin + 1 >= szx and ymax - ymin + 1 >= szy):
            self.region_of_interest = [xmin, xmax, ymin, ymax]
        else:
            self.region_of_interest = None
        self.katrain.controls.set_status("", OUTPUT_INFO)

    def analyze_extra(self, mode, **kwargs):
        stones = {s.coords for s in self.stones}
        cn = self.current_node

        engine = self.engines[cn.next_player]
        Clock.schedule_once(self.katrain.analysis_controls.hints.activate, 0)

        if mode == "extra":
            if kwargs.get("continuous", False):
                visits = min(
                    1_000_000_000,
                    max(engine.config["max_visits"],
                        math.ceil(cn.analysis_visits_requested * 1.25)))
            else:
                visits = cn.analysis_visits_requested + engine.config[
                    "max_visits"]
                self.katrain.controls.set_status(
                    i18n._("extra analysis").format(visits=visits),
                    STATUS_ANALYSIS)
            self.katrain.controls.set_status(
                i18n._("extra analysis").format(visits=visits),
                STATUS_ANALYSIS)
            cn.analyze(engine,
                       visits=visits,
                       priority=-1_000,
                       region_of_interest=self.region_of_interest,
                       time_limit=False)
            return
        if mode == "game":
            nodes = self.root.nodes_in_tree
            if "visits" in kwargs:
                visits = kwargs["visits"]
            else:
                min_visits = min(node.analysis_visits_requested
                                 for node in nodes)
                visits = min_visits + engine.config["max_visits"]
            for node in nodes:
                node.analyze(engine,
                             visits=visits,
                             priority=-1_000_000,
                             time_limit=False,
                             report_every=None)
            self.katrain.controls.set_status(
                i18n._("game re-analysis").format(visits=visits),
                STATUS_ANALYSIS)
            return

        elif mode == "sweep":
            board_size_x, board_size_y = self.board_size
            if cn.analysis_exists:
                policy_grid = (var_to_grid(self.current_node.policy,
                                           size=(board_size_x, board_size_y))
                               if self.current_node.policy else None)
                analyze_moves = sorted(
                    [
                        Move(coords=(x, y), player=cn.next_player)
                        for x in range(board_size_x)
                        for y in range(board_size_y)
                        if (policy_grid is None and
                            (x, y) not in stones) or policy_grid[y][x] >= 0
                    ],
                    key=lambda mv: -policy_grid[mv.coords[1]][mv.coords[0]],
                )
            else:
                analyze_moves = [
                    Move(coords=(x, y), player=cn.next_player)
                    for x in range(board_size_x) for y in range(board_size_y)
                    if (x, y) not in stones
                ]
            visits = engine.config["fast_visits"]
            self.katrain.controls.set_status(
                i18n._("sweep analysis").format(visits=visits),
                STATUS_ANALYSIS)
            priority = -1_000_000_000
        elif mode in ["equalize", "alternative", "local"]:
            if not cn.analysis_complete and mode != "local":
                self.katrain.controls.set_status(
                    i18n._("wait-before-extra-analysis"), STATUS_INFO,
                    self.current_node)
                return
            if mode == "alternative":  # also do a quick update on current candidates so it doesn't look too weird
                self.katrain.controls.set_status(
                    i18n._("alternative analysis"), STATUS_ANALYSIS)
                cn.analyze(engine,
                           priority=-500,
                           time_limit=False,
                           find_alternatives="alternative")
                visits = engine.config["fast_visits"]
            else:  # equalize
                visits = max(d["visits"]
                             for d in cn.analysis["moves"].values())
                self.katrain.controls.set_status(
                    i18n._("equalizing analysis").format(visits=visits),
                    STATUS_ANALYSIS)
            priority = -1_000
            analyze_moves = [
                Move.from_gtp(gtp, player=cn.next_player)
                for gtp, _ in cn.analysis["moves"].items()
            ]
        else:
            raise ValueError("Invalid analysis mode")
        for move in analyze_moves:
            if cn.analysis["moves"].get(move.gtp(),
                                        {"visits": 0})["visits"] < visits:
                cn.analyze(
                    engine,
                    priority=priority,
                    visits=visits,
                    refine_move=move,
                    time_limit=False
                )  # explicitly requested so take as long as you need

    def play_to_end(self):
        cn = self.current_node
        count = 0
        if not cn.analysis_exists:
            self.katrain.controls.set_status(
                i18n._("wait-before-extra-analysis"), STATUS_INFO, cn)
            return

        def analyze_and_play_policy(node):
            nonlocal count, cn
            cand = node.candidate_moves
            if self.katrain.game is not self:
                return  # a new game happened
            if cand:
                move = Move.from_gtp(cand[0]["move"], player=node.next_player)
            else:
                polmoves = node.policy_ranking
                move = polmoves[0][1] if polmoves else Move(None)

            if move.is_pass:
                if self.current_node == cn:
                    self.set_current_node(node)
                return
            count += 1
            new_node = GameNode(parent=node, move=move)
            if node != cn:
                node.remove_shortcut()
            cn.add_shortcut(new_node)
            self.katrain.controls.move_tree.redraw_tree_trigger()

            def set_analysis(result, _partial):
                new_node.set_analysis(result)
                analyze_and_play_policy(new_node)

            self.engines[node.next_player].request_analysis(
                new_node,
                callback=set_analysis,
                priority=-1000,
                analyze_fast=True)

        analyze_and_play_policy(cn)

    def analyze_undo(self, node):
        train_config = self.katrain.config("trainer")
        move = node.move
        if node != self.current_node or node.auto_undo is not None or not node.analysis_complete or not move:
            return
        points_lost = node.points_lost
        thresholds = train_config["eval_thresholds"]
        num_undo_prompts = train_config["num_undo_prompts"]
        i = 0
        while i < len(thresholds) and points_lost < thresholds[i]:
            i += 1
        num_undos = num_undo_prompts[i] if i < len(num_undo_prompts) else 0
        if num_undos == 0:
            undo = False
        elif num_undos < 1:  # probability
            undo = int(node.undo_threshold < num_undos) and len(
                node.parent.children) == 1
        else:
            undo = len(node.parent.children) <= num_undos

        node.auto_undo = undo
        if undo:
            self.undo(1)
            self.katrain.controls.set_status(
                i18n._("teaching undo message").format(
                    move=move.gtp(), points_lost=points_lost), STATUS_TEACHING)
            self.katrain.update_state()
Example #4
0
class Game:
    """Represents a game of go, including an implementation of capture rules."""

    DEFAULT_PROPERTIES = {"GM": 1, "FF": 4, "AP": f"KaTrain:{HOMEPAGE}"}

    def __init__(self, katrain, engine: Union[Dict, KataGoEngine], move_tree: GameNode = None, analyze_fast=False):
        self.katrain = katrain
        if not isinstance(engine, Dict):
            engine = {"B": engine, "W": engine}
        self.engines = engine
        self.game_id = datetime.strftime(datetime.now(), "%Y-%m-%d %H %M %S")

        if move_tree:
            self.root = move_tree
            self.komi = self.root.komi
            handicap = int(self.root.get_property("HA", 0))
            if handicap and not self.root.placements:
                self.place_handicap_stones(handicap)
        else:
            board_size = katrain.config("game/size")
            self.komi = katrain.config("game/komi")
            self.root = GameNode(
                properties={**Game.DEFAULT_PROPERTIES, **{"SZ": board_size, "KM": self.komi, "DT": self.game_id}}
            )
            handicap = katrain.config("game/handicap")
            if handicap:
                self.place_handicap_stones(handicap)

        if not self.root.get_property("RU"):
            self.root.set_property("RU", katrain.config("game/rules"))

        self.set_current_node(self.root)
        threading.Thread(
            target=lambda: self.analyze_all_nodes(-1_000_000, analyze_fast=analyze_fast), daemon=True
        ).start()  # return faster, but bypass Kivy Clock

    def analyze_all_nodes(self, priority=0, analyze_fast=False):
        for node in self.root.nodes_in_tree:
            node.analyze(self.engines[node.next_player], priority=priority, analyze_fast=analyze_fast)

    # -- move tree functions --
    def _calculate_groups(self):
        board_size_x, board_size_y = self.board_size
        self.board = [
            [-1 for _x in range(board_size_x)] for _y in range(board_size_y)
        ]  # type: List[List[int]]  #  board pos -> chain id
        self.chains = []  # type: List[List[Move]]  #   chain id -> chain
        self.prisoners = []  # type: List[Move]
        self.last_capture = []  # type: List[Move]
        try:
            #            for m in self.moves:
            for node in self.current_node.nodes_from_root:
                for m in node.move_with_placements:
                    self._validate_move_and_update_chains(m, True)  # ignore ko since we didn't know if it was forced
        except IllegalMoveException as e:
            raise Exception(f"Unexpected illegal move ({str(e)})")

    def _validate_move_and_update_chains(self, move: Move, ignore_ko: bool):
        board_size_x, board_size_y = self.board_size

        def neighbours(moves):
            return {
                self.board[m.coords[1] + dy][m.coords[0] + dx]
                for m in moves
                for dy, dx in [(-1, 0), (1, 0), (0, -1), (0, 1)]
                if 0 <= m.coords[0] + dx < board_size_x and 0 <= m.coords[1] + dy < board_size_y
            }

        ko_or_snapback = len(self.last_capture) == 1 and self.last_capture[0] == move
        self.last_capture = []

        if move.is_pass:
            return

        if self.board[move.coords[1]][move.coords[0]] != -1:
            raise IllegalMoveException("Space occupied")

        nb_chains = list({c for c in neighbours([move]) if c >= 0 and self.chains[c][0].player == move.player})
        if nb_chains:
            this_chain = nb_chains[0]
            self.board = [
                [nb_chains[0] if sq in nb_chains else sq for sq in line] for line in self.board
            ]  # merge chains connected by this move
            for oc in nb_chains[1:]:
                self.chains[nb_chains[0]] += self.chains[oc]
                self.chains[oc] = []
            self.chains[nb_chains[0]].append(move)
        else:
            this_chain = len(self.chains)
            self.chains.append([move])
        self.board[move.coords[1]][move.coords[0]] = this_chain

        opp_nb_chains = {c for c in neighbours([move]) if c >= 0 and self.chains[c][0].player != move.player}
        for c in opp_nb_chains:
            if -1 not in neighbours(self.chains[c]):
                self.last_capture += self.chains[c]
                for om in self.chains[c]:
                    self.board[om.coords[1]][om.coords[0]] = -1
                self.chains[c] = []
        if ko_or_snapback and len(self.last_capture) == 1 and not ignore_ko:
            raise IllegalMoveException("Ko")
        self.prisoners += self.last_capture

        if -1 not in neighbours(self.chains[this_chain]):  # TODO: NZ rules?
            raise IllegalMoveException("Suicide")

    # Play a Move from the current position, raise IllegalMoveException if invalid.
    def play(self, move: Move, ignore_ko: bool = False, analyze=True):
        board_size_x, board_size_y = self.board_size
        if not move.is_pass and not (0 <= move.coords[0] < board_size_x and 0 <= move.coords[1] < board_size_y):
            raise IllegalMoveException(f"Move {move} outside of board coordinates")
        try:
            self._validate_move_and_update_chains(move, ignore_ko)
        except IllegalMoveException:
            self._calculate_groups()
            raise
        played_node = self.current_node.play(move)
        self.current_node = played_node
        if analyze:
            played_node.analyze(self.engines[played_node.next_player])
        return played_node

    def set_current_node(self, node):
        self.current_node = node
        self._calculate_groups()

    def undo(self, n_times=1):
        cn = self.current_node  # avoid race conditions
        for _ in range(n_times):
            if not cn.is_root:
                cn.parent.set_favourite_child(cn)
                cn = cn.parent
        self.set_current_node(cn)

    def redo(self, n_times=1):
        cn = self.current_node  # avoid race conditions
        for _ in range(n_times):
            if cn.children:
                cn = cn.favourite_child
        self.set_current_node(cn)

    def switch_branch(self, direction):
        cn = self.current_node  # avoid race conditions
        if cn.parent and len(cn.parent.children) > 1:
            ix = cn.parent.children.index(cn)
            self.set_current_node(cn.parent.children[(ix + direction) % len(cn.parent.children)])

    def place_handicap_stones(self, n_handicaps):
        board_size_x, board_size_y = self.board_size
        near_x = 3 if board_size_x >= 13 else min(2, board_size_x - 1)
        near_y = 3 if board_size_y >= 13 else min(2, board_size_y - 1)
        far_x = board_size_x - 1 - near_x
        far_y = board_size_y - 1 - near_y
        middle_x = board_size_x // 2  # what for even sizes?
        middle_y = board_size_y // 2
        if n_handicaps > 9 and board_size_x == board_size_y:
            stones_per_row = math.ceil(math.sqrt(n_handicaps))
            spacing = (far_x - near_x) / (stones_per_row - 1)
            if spacing < near_x:
                far_x += 1
                near_x -= 1
                spacing = (far_x - near_x) / (stones_per_row - 1)
            coords = list({math.floor(0.5 + near_x + i * spacing) for i in range(stones_per_row)})
            stones = sorted(
                [(x, y) for x in coords for y in coords],
                key=lambda xy: -((xy[0] - (board_size_x - 1) / 2) ** 2 + (xy[1] - (board_size_y - 1) / 2) ** 2),
            )
        else:  # max 9
            stones = [(far_x, far_y), (near_x, near_y), (far_x, near_y), (near_x, far_y)]
            if n_handicaps % 2 == 1:
                stones.append((middle_x, middle_y))
            stones += [(near_x, middle_y), (far_x, middle_y), (middle_x, near_y), (middle_x, far_y)]
        self.root.set_property(
            "AB", list({Move(stone).sgf(board_size=(board_size_x, board_size_y)) for stone in stones[:n_handicaps]})
        )

    @property
    def board_size(self):
        return self.root.board_size

    @property
    def stones(self):
        return sum(self.chains, [])

    @property
    def ended(self):
        return self.current_node.parent and self.current_node.is_pass and self.current_node.parent.is_pass

    @property
    def prisoner_count(
        self,
    ) -> Dict:  # returns prisoners that are of a certain colour as {B: black stones captures, W: white stones captures}
        return {player: sum([m.player == player for m in self.prisoners]) for player in Move.PLAYERS}

    @property
    def manual_score(self):
        rules = self.engines["B"].get_rules(self.root)
        if not self.current_node.ownership or rules != "japanese":
            if not self.current_node.score:
                return None
            self.katrain.log(
                f"rules '{rules}' are not japanese, or no ownership available ({not self.current_node.ownership}) -> no manual score available",
                OUTPUT_DEBUG,
            )
            return self.current_node.format_score(round(2 * self.current_node.score) / 2) + "?"
        board_size_x, board_size_y = self.board_size
        ownership_grid = var_to_grid(self.current_node.ownership, (board_size_x, board_size_y))
        stones = {m.coords: m.player for m in self.stones}
        lo_threshold = 0.15
        hi_threshold = 0.85
        max_unknown = 10
        max_dame = 4 * (board_size_x + board_size_y)

        def japanese_score_square(square, owner):
            player = stones.get(square, None)
            if (
                (player == "B" and owner > hi_threshold)
                or (player == "W" and owner < -hi_threshold)
                or abs(owner) < lo_threshold
            ):
                return 0  # dame or own stones
            if player is None and abs(owner) >= hi_threshold:
                return round(owner)  # surrounded empty intersection
            if (player == "B" and owner < -hi_threshold) or (player == "W" and owner > hi_threshold):
                return 2 * round(owner)  # captured stone
            return math.nan  # unknown!

        scored_squares = [
            japanese_score_square((x, y), ownership_grid[y][x])
            for y in range(board_size_y)
            for x in range(board_size_x)
        ]
        num_sq = {t: sum([s == t for s in scored_squares]) for t in [-2, -1, 0, 1, 2]}
        num_unkn = sum(math.isnan(s) for s in scored_squares)
        prisoners = self.prisoner_count
        score = sum([t * n for t, n in num_sq.items()]) + prisoners["W"] - prisoners["B"] - self.komi
        self.katrain.log(
            f"Manual Scoring: {num_sq} score by square with {num_unkn} unknown, {prisoners} captures, and {self.komi} komi -> score = {score}",
            OUTPUT_DEBUG,
        )
        if num_unkn > max_unknown or (num_sq[0] - len(stones)) > max_dame:
            return None
        return self.current_node.format_score(score)

    def __repr__(self):
        return (
            "\n".join("".join(self.chains[c][0].player if c >= 0 else "-" for c in line) for line in self.board)
            + f"\ncaptures: {self.prisoner_count}"
        )

    def write_sgf(
        self,
        path: str,
        trainer_config: Optional[Dict] = None,
        save_feedback: Optional[List] = None,
        eval_thresholds: Optional[List] = None,
    ):
        if trainer_config is None:
            trainer_config = self.katrain.config("trainer")
        if save_feedback is None:
            save_feedback = self.katrain.config("trainer/save_feedback")
        if eval_thresholds is None:
            eval_thresholds = self.katrain.config("trainer/eval_thresholds")

        def player_name(player_info):
            return f"{i18n._(player_info.player_type)} ({i18n._(player_info.player_subtype)})"

        player_names = {
            bw: re.sub(
                r"['<>:\"/\\|?*]", "", self.root.get_property("P" + bw) or player_name(self.katrain.players_info[bw])
            )
            for bw in "BW"
        }
        game_name = f"katrain_{player_names['B']} vs {player_names['W']} {self.game_id}"
        file_name = os.path.abspath(os.path.join(path, f"{game_name}.sgf"))
        os.makedirs(os.path.dirname(file_name), exist_ok=True)

        show_dots_for = {
            bw: trainer_config.get("eval_show_ai", True) or pl.human for bw, pl in self.katrain.players_info.items()
        }
        sgf = self.root.sgf(
            save_comments_player=show_dots_for, save_comments_class=save_feedback, eval_thresholds=eval_thresholds
        )
        with open(file_name, "w") as f:
            f.write(sgf)
        return i18n._("sgf written").format(file_name=file_name)

    def analyze_extra(self, mode):
        stones = {s.coords for s in self.stones}
        cn = self.current_node

        engine = self.engines[cn.next_player]
        if mode == "extra":
            visits = cn.analysis_visits_requested + engine.config["max_visits"]
            self.katrain.controls.set_status(f"Performing additional analysis to {visits} visits")
            cn.analyze(engine, visits=visits, priority=-1_000, time_limit=False)
            return
        elif mode == "sweep":
            board_size_x, board_size_y = self.board_size
            if cn.analysis_ready:
                policy_grid = (
                    var_to_grid(self.current_node.policy, size=(board_size_x, board_size_y))
                    if self.current_node.policy
                    else None
                )
                analyze_moves = sorted(
                    [
                        Move(coords=(x, y), player=cn.next_player)
                        for x in range(board_size_x)
                        for y in range(board_size_y)
                        if (policy_grid is None and (x, y) not in stones) or policy_grid[y][x] >= 0
                    ],
                    key=lambda mv: -policy_grid[mv.coords[1]][mv.coords[0]],
                )
            else:
                analyze_moves = [
                    Move(coords=(x, y), player=cn.next_player)
                    for x in range(board_size_x)
                    for y in range(board_size_y)
                    if (x, y) not in stones
                ]
            visits = engine.config["fast_visits"]
            self.katrain.controls.set_status(f"Refining analysis of entire board to {visits} visits")
            priority = -1_000_000_000
        else:  # mode=='equalize':
            if not cn.analysis_ready:
                self.katrain.controls.set_status(i18n._("wait-before-equalize"), self.current_node)
                return

            analyze_moves = [Move.from_gtp(gtp, player=cn.next_player) for gtp, _ in cn.analysis["moves"].items()]
            visits = max(d["visits"] for d in cn.analysis["moves"].values())
            self.katrain.controls.set_status(f"Equalizing analysis of candidate moves to {visits} visits")
            priority = -1_000
        for move in analyze_moves:
            cn.analyze(
                engine, priority, visits=visits, refine_move=move, time_limit=False
            )  # explicitly requested so take as long as you need

    def analyze_undo(self, node):
        train_config = self.katrain.config("trainer")
        move = node.move
        if node != self.current_node or node.auto_undo is not None or not node.analysis_ready or not move:
            return
        points_lost = node.points_lost
        thresholds = train_config["eval_thresholds"]
        num_undo_prompts = train_config["num_undo_prompts"]
        i = 0
        while i < len(thresholds) and points_lost < thresholds[i]:
            i += 1
        num_undos = num_undo_prompts[i] if i < len(num_undo_prompts) else 0
        if num_undos == 0:
            undo = False
        elif num_undos < 1:  # probability
            undo = int(node.undo_threshold < num_undos) and len(node.parent.children) == 1
        else:
            undo = len(node.parent.children) <= num_undos

        node.auto_undo = undo
        if undo:
            self.undo(1)
            self.katrain.controls.set_status(
                i18n._("teaching undo message").format(move=move.gtp(), points_lost=points_lost)
            )
            self.katrain.update_state()