import os import random from datetime import datetime import copy from sgfparser import Move, SGFNode, SGF from typing import List class IllegalMoveException(Exception): pass class KaTrainSGFNode(SGFNode): _node_id_counter = -1 def __init__(self,parent=None, properties=None, move=None): super().__init__(parent=parent,properties=properties,move=move) KaTrainSGFNode._node_id_counter += 1 self.id = KaTrainSGFNode._node_id_counter self.analysis = None self.pass_analysis = None self.ownership = None self.x_comment = {} self.auto_undid = False self.move_number = 0 self.undo_threshold = random.random() # for fractional undos, store the random threshold in the move itself for consistency @property def sgf_properties(self): best_sq = [] properties = copy.copy(super().sgf_properties) if 'SQ' not in properties: properties['SQ'] = best_sq properties['C'] += self.comment(sgf=True) return properties def update_top_move_evaluation(self): # a move's outdated analysis if self.analysis and self.parent and self.parent.analysis: for move_dict in self.parent.analysis: if move_dict["move"] == self.gtp(): move_dict["outdatedScoreLead"] = move_dict["scoreLead"] move_dict["scoreLead"] = self.analysis[0]["scoreLead"] self.parent.update_top_move_evaluation() return # various analysis functions def set_analysis(self, analysis_blob, is_pass): if is_pass: self.pass_analysis = analysis_blob["moveInfos"] else: self.analysis = analysis_blob["moveInfos"] self.ownership = analysis_blob["ownership"] if self.children: self.children[0].update_top_move_evaluation() self.update_top_move_evaluation() @property def analysis_ready(self): return self.analysis and self.pass_analysis def format_score(self, score=None): score = score or self.score return f"{'B' if score >= 0 else 'W'}+{abs(score):.1f}" def comment(self, sgf=False, eval=False, hints=False): move = self.move if not self.parent or not move: # root return "" if eval and not sgf and self.children: # show undos and on previous move as well while playing text = "".join(f"Auto undid move {m.gtp()} ({-self.temperature_stats[2] * (1-m.evaluation):.1f} pt)\n" for m in self.children if m.auto_undid) if text: text += "\n" else: text = "" text += f"Move {move.player} {move.gtp()}\n" text += "\n".join(self.x_comment.values()) if self.analysis_ready: score, _, temperature = self.temperature_stats if sgf: text += f"Score: {self.format_score(score)}\n" if self.parent and self.parent.analysis_ready: prev_best_score, prev_worst_score, prev_temperature = self.parent.temperature_stats if sgf or hints: text += f"Top move was {self.parent.analysis[0]['move']} ({self.format_score(prev_best_score)})\n" text += f"Pass score was {self.format_score(prev_worst_score)}\n" text += f"Previous temperature: {prev_temperature:.1f}\n" if prev_temperature < 0.5: text += f"Previous temperature ({prev_temperature:.1f}) too low for evaluation\n" elif not move.is_pass and self.parent.analysis[0]["move"] != move.gtp(): if sgf: # shown in stats anyway text += f"Evaluation: {self.evaluation:.1%} efficient\n" outdated_evaluation, outdated_details = self.outdated_evaluation if outdated_evaluation and outdated_evaluation > self.evaluation and outdated_evaluation > self.evaluation + 0.05: text += f"(Was considered last move as {outdated_evaluation:.0%})\n" points_lost = self.player_sign * (prev_best_score - score) if points_lost > 0.5: text += f"Estimated point loss: {points_lost:.1f}\n" if eval or sgf: # show undos on move itself in both sgf and while playing undids = [m.gtp() + (f"({m.evaluation_info[0]:.1%} efficient)" if m.evaluation_info[0] else "") for m in self.parent.children if m != self] if undids: text += "Other attempted move(s): " + ", ".join(undids) + "\n" else: text = "No analysis available" if sgf else "Analyzing move..." return text # returns evaluation, temperature scale or None, None when not ready @property def evaluation_info(self): if self.parent and self.parent.analysis_ready and self.analysis_ready: return self.evaluation, self.parent.temperature_stats[2] else: return None, None # needing own analysis ready @property def temperature_stats(self): best = self.analysis[0]["scoreLead"] worst = self.pass_analysis[0]["scoreLead"] return best, worst, max(-self.player_sign * (best - worst), 0) @property def score(self): return self.temperature_stats[0] @property def player_sign(self): return 1 if self.player == 0 else -1 # need parent analysis ready @property def evaluation(self): best, worst, temp = self.parent.temperature_stats return self.player_sign * (self.score - worst) / temp if temp > 0 else None @property def outdated_evaluation(self): def outdated_score(move_dict): return move_dict.get("outdatedScoreLead") or move_dict["scoreLead"] prev_analysis_current_move = [d for d in self.parent.analysis if d["move"] == self.gtp()] if prev_analysis_current_move: best_score = outdated_score(self.parent.analysis[0]) worst_score = self.parent.pass_analysis[0]["scoreLead"] prev_temp = max(self.player_sign * (best_score - worst_score), 0) score = outdated_score(prev_analysis_current_move[0]) return (self.player_sign * (score - worst_score) / prev_temp if prev_temp > 0 else None), prev_analysis_current_move else: return None, None @property def ai_moves(self): if not self.analysis_ready: return [] _, worst_score, temperature = self.temperature_stats analysis = copy.copy(self.analysis) # not deep, so eval is saved, but avoids race conditions for d in analysis: if temperature > 0.5: d["evaluation"] = -self.player_sign * (d["scoreLead"] - worst_score) / temperature else: d["evaluation"] = int(-self.player_sign * d["scoreLead"] >= -self.player_sign * self.analysis[0]["scoreLead"]) return analysis class Board: def __init__(self, board_size=19, move_tree=None): self.game_id = datetime.strftime(datetime.now(), "%Y-%m-%d %H %M %S") self.board_size = board_size if move_tree: self.root = move_tree else: self.root = KaTrainSGFNode(properties={'RU':'JP','SZ':board_size}) # TODO: Komi, etc? self.current_node = self.root self._node_by_id = {m.id:m for m in self.root.nodes_in_tree} self._init_chains() # -- move tree functions -- def _init_chains(self): self.board = [[-1 for _x in range(self.board_size)] for _y in range(self.board_size)] # 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: 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): 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 < self.board_size and 0 <= m.coords[1] + dy < self.board_size } 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]): raise IllegalMoveException("Suicide") # Play a Move from the current position, raise IllegalMoveException if invalid. def play(self, move: Move, ignore_ko: bool=False): if not move.is_pass and not (0 <= move.coords[0] < self.board_size and 0 <= move.coords[1] < self.board_size): raise IllegalMoveException(f"Move {move} outside of board coordinates") played_node = self.current_node.play(move) try: self._validate_move_and_update_chains(played_node.move, ignore_ko) except IllegalMoveException: self.current_node.children = [m for m in self.current_node.children if m != played_node] self._init_chains() # restore raise self._node_by_id[played_node.id] = played_node self.current_node = played_node return played_node def undo(self): if self.current_node is not self.root: self.current_node = self.current_node.parent self._init_chains() def redo(self): if self.current_node.children: self.play(self.current_node.children[-1]) def switch_branch(self, direction): cm = self.current_node # avoid race conditions if cm.parent and len(cm.parent.children) > 1: ix = cm.parent.children.index(cm) self.current_node = cm.parent.children[(ix + direction) % len(cm.parent.children)] self._init_chains() def place_handicap_stones(self, n_handicaps): near = 3 if self.board_size >= 13 else 2 far = self.board_size - 1 - near middle = self.board_size // 2 stones = [(far, far), (near, near), (far, near), (near, far)] if n_handicaps % 2 == 1: stones.append((middle, middle)) stones += [(near, middle), (far, middle), (middle, near), (middle, far)] self.root['AB'] =[Move(stone).sgf(board_size=self.board_size) for stone in stones[:n_handicaps]] @property def moves(self) -> list: # flat list of moves to current, including placements return sum([node.move_with_placements for node in self.current_node.nodes_from_root],[]) @property def next_player(self): return self.current_node.next_player def store_analysis(self, json): if json["id"].startswith("AA:"): # board sweep analyze all _, move_id, gtpcoords = json["id"].split(":") move = self._node_by_id.get(int(move_id)) if not move.analysis: return # should have been prevented, but better not to crash cur_analysis = [d for d in move.analysis if d["move"] == gtpcoords] move_analysis = {k: v for k, v in json["moveInfos"][0].items() if k not in {"move", "pv"}} move_analysis["visits"] = sum(d["visits"] for d in json["moveInfos"]) # TODO: ?? if cur_analysis: if cur_analysis[0]["visits"] < move_analysis["visits"]: cur_analysis[0].update(move_analysis) else: move.analysis.append({"move": gtpcoords, **move_analysis}) return if json["id"].startswith("PASS_"): move_id = int(json["id"].lstrip("PASS_")) is_pass = True else: move_id = int(json["id"]) is_pass = False move = self._node_by_id.get(move_id) if move: # else this should be old move.set_analysis(json, is_pass) else: print("WARNING: ORPHANED ANALYSIS FOUND - RECENT NEW GAME?") @property def stones(self): return sum(self.chains, []) @property def game_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): return [sum([m.player == player for m in self.prisoners]) for player in Move.PLAYERS] def __repr__(self): return "\n".join("".join(Move.PLAYERS[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, file_name=None): file_name = file_name or f"sgfout/katrain_{self.game_id}.sgf" os.makedirs(os.path.dirname(file_name),exist_ok=True) with open(file_name, "w") as f: f.write(self.root.sgf()) return f"SGF with analysis written to {file_name}" class KaTrainSGF(SGF): _MOVE_CLASS = KaTrainSGFNode