import copy import math import re from collections import defaultdict from typing import Any, Dict, List, Optional, Tuple class ParseError(Exception): """Exception raised on a parse error""" pass class Move: GTP_COORD = list("ABCDEFGHJKLMNOPQRSTUVWXYZ") + [ xa + c for xa in "AB" for c in "ABCDEFGHJKLMNOPQRSTUVWXYZ" ] # board size 52+ support PLAYERS = "BW" SGF_COORD = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ".lower()) + list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") # sgf goes to 52 @classmethod def from_gtp(cls, gtp_coords, player="B"): """Initialize a move from GTP coordinates and player""" if "pass" in gtp_coords.lower(): return cls(coords=None, player=player) match = re.match(r"([A-Z]+)(\d+)", gtp_coords) return cls(coords=(Move.GTP_COORD.index(match[1]), int(match[2]) - 1), player=player) @classmethod def from_sgf(cls, sgf_coords, board_size, player="B"): """Initialize a move from SGF coordinates and player""" if sgf_coords == "" or Move.SGF_COORD.index(sgf_coords[0]) == board_size[0]: # some servers use [tt] for pass return cls(coords=None, player=player) return cls( coords=(Move.SGF_COORD.index(sgf_coords[0]), board_size[1] - Move.SGF_COORD.index(sgf_coords[1]) - 1), player=player, ) def __init__(self, coords: Optional[Tuple[int, int]] = None, player: str = "B"): """Initialize a move from zero-based coordinates and player""" self.player = player self.coords = coords def __repr__(self): return f"Move({self.player}{self.gtp()})" def __eq__(self, other): return self.coords == other.coords and self.player == other.player def gtp(self): """Returns GTP coordinates of the move""" if self.is_pass: return "pass" return Move.GTP_COORD[self.coords[0]] + str(self.coords[1] + 1) def sgf(self, board_size): """Returns SGF coordinates of the move""" if self.is_pass: return "" return f"{Move.SGF_COORD[self.coords[0]]}{Move.SGF_COORD[board_size[1] - self.coords[1] - 1]}" @property def is_pass(self): """Returns True if the move is a pass""" return self.coords is None @property def opponent(self): """Returns the opposing player, i.e. W <-> B""" return "W" if self.player == "B" else "B" class SGFNode: def __init__(self, parent=None, properties=None, move=None): self.children = [] self.properties = defaultdict(list) if properties: for k, v in properties.items(): self.set_property(k, v) self.parent = parent if self.parent: self.parent.children.append(self) if parent and move: self.set_property(move.player, move.sgf(self.board_size)) def sgf_properties(self, **xargs) -> Dict: """For hooking into in a subclass and overriding/formatting any additional properties to be output.""" return copy.deepcopy(self.properties) @staticmethod def order_children(children): """For hooking into in a subclass and overriding branch order.""" return children @property def ordered_children(self): return self.order_children(self.children) @staticmethod def _escape_value(value): return re.sub(r"([\]\\])", r"\\\1", value) if isinstance(value, str) else value # escape \ and ] @staticmethod def _unescape_value(value): return re.sub(r"\\([\]\\])", r"\1", value) if isinstance(value, str) else value # unescape \ and ] def sgf(self, **xargs) -> str: """Generates an SGF, calling sgf_properties on each node with the given xargs, so it can filter relevant properties if needed.""" def node_sgf_str(node): return ";" + "".join( [ prop + "".join(f"[{self._escape_value(v)}]" for v in values) for prop, values in node.sgf_properties(**xargs).items() if values ] ) stack = [")", self, "("] sgf_str = "" while stack: item = stack.pop() if isinstance(item, str): sgf_str += item else: sgf_str += node_sgf_str(item) if len(item.children) == 1: stack.append(item.children[0]) elif item.children: stack += sum([[")", c, "("] for c in item.ordered_children[::-1]], []) return sgf_str def add_list_property(self, property: str, values: List): """Add some values to the property list.""" # SiZe[19] ==> SZ[19] etc. for old SGF normalized_property = re.sub("[a-z]", "", property) self.properties[normalized_property] += values def get_list_property(self, property, default=None) -> Any: """Get the list of values for a property.""" return self.properties.get(property, default) def set_property(self, property: str, value: Any): """Add some values to the property. If not a list, it will be made into a single-value list.""" if not isinstance(value, list): value = [value] self.properties[property] = value def get_property(self, property, default=None) -> Any: """Get the first value of the property, typically when exactly one is expected.""" return self.properties.get(property, [default])[0] @property def parent(self) -> Optional["SGFNode"]: """Returns the parent node""" return self._parent @parent.setter def parent(self, parent_node): self._parent = parent_node self._root = None self._depth = None @property def root(self) -> "SGFNode": """Returns the root of the tree, cached for speed""" if self._root is None: self._root = self.parent.root if self.parent else self return self._root @property def depth(self) -> int: """Returns the depth of this node, where root is 0, cached for speed""" if self._depth is None: if self.is_root: self._depth = 0 else: self._depth = self.parent.depth + 1 return self._depth @property def board_size(self) -> Tuple[int, int]: """Retrieves the root's SZ property, or 19 if missing. Parses it, and returns board size as a tuple x,y""" size = str(self.root.get_property("SZ", "19")) if ":" in size: x, y = map(int, size.split(":")) else: x = int(size) y = x return x, y @property def komi(self) -> float: """Retrieves the root's KM property, or 6.5 if missing""" return float(self.root.get_property("KM", 6.5)) @property def ruleset(self) -> str: """Retrieves the root's RU property, or 'japanese' if missing""" return self.root.get_property("RU", "japanese") @property def moves(self) -> List[Move]: """Returns all moves in the node - typically 'move' will be better.""" return [ Move.from_sgf(move, player=pl, board_size=self.board_size) for pl in Move.PLAYERS for move in self.get_list_property(pl, []) ] @property def placements(self) -> List[Move]: """Returns all placements (AB/AW) in the node.""" return [ Move.from_sgf(sgf_coords, player=pl, board_size=self.board_size) for pl in Move.PLAYERS for sgf_coords in self.get_list_property("A" + pl, []) ] @property def move_with_placements(self) -> List[Move]: """Returns all moves (B/W) and placements (AB/AW) in the node.""" return self.placements + self.moves @property def move(self) -> Optional[Move]: """Returns the single move for the node if one exists, or None if no moves (or multiple ones) exist.""" moves = self.moves if len(moves) == 1: return moves[0] @property def is_root(self) -> bool: """Returns true if node is a root""" return self.parent is None @property def is_pass(self) -> bool: """Returns true if associated move is pass""" return not self.placements and self.move and self.move.is_pass @property def empty(self) -> bool: """Returns true if node has no children or properties""" return not self.children and not self.properties @property def nodes_in_tree(self) -> List: """Returns all nodes in the tree rooted at this node""" stack = [self] nodes = [] while stack: item = stack.pop(0) nodes.append(item) stack += item.children return nodes @property def nodes_from_root(self) -> List: """Returns all nodes from the root up to this node, i.e. the moves played in the current branch of the game""" nodes = [self] n = self while not n.is_root: n = n.parent nodes.append(n) return nodes[::-1] def play(self, move) -> "SGFNode": """Either find an existing child or create a new one with the given move.""" for c in self.children: if c.move == move: return c return self.__class__(parent=self, move=move) @property def next_player(self): """Returns player to move""" if "B" in self.properties or "AB" in self.properties: # root or black moved return "W" else: return "B" @property def player(self): """Returns player that moved last. nb root is considered white played if no handicap stones are placed""" if "B" in self.properties or "AB" in self.properties: return "B" else: return "W" def place_handicap_stones(self, n_handicaps, tygem=False): 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)] if tygem: stones[2], stones[3] = stones[3], stones[2] self.set_property( "AB", list({Move(stone).sgf(board_size=(board_size_x, board_size_y)) for stone in stones[:n_handicaps]}) ) class SGF: _NODE_CLASS = SGFNode # Class used for SGF Nodes, can change this to something that inherits from SGFNode # https://xkcd.com/1171/ SGFPROP_PAT = re.compile(r"\s*(?:\(|\)|;|(\w+)((\s*\[([^\]\\]|\\.)*\])+))", flags=re.DOTALL) SGF_PAT = re.compile(r"\(;.*\)", flags=re.DOTALL) @classmethod def parse_sgf(cls, input_str) -> SGFNode: """Parse a string as SGF.""" match = re.search(cls.SGF_PAT, input_str) clipped_str = match.group() if match else input_str root = cls(clipped_str).root # Fix weird FoxGo server KM values if "foxwq" in root.get_list_property("AP", []): if int(root.get_property("HA", 0)) >= 1: corrected_komi = 0.5 elif root.get_property("RU").lower() in ["chinese", "cn"]: corrected_komi = 7.5 else: corrected_komi = 6.5 root.set_property("KM", corrected_komi) return root @classmethod def parse_file(cls, filename, encoding=None) -> SGFNode: is_gib = filename.lower().endswith(".gib") is_ngf = filename.lower().endswith(".ngf") """Parse a file as SGF, encoding will be detected if not given.""" with open(filename, "rb") as f: bin_contents = f.read() if not encoding: if is_gib or is_ngf or b"AP[foxwq]" in bin_contents: encoding = "utf8" else: # sgf match = re.search(rb"CA\[(.*?)\]", bin_contents) if match: encoding = match[1].decode("ascii", errors="ignore") else: encoding = "ISO-8859-1" # default decoded = bin_contents.decode(encoding=encoding, errors="ignore") if is_ngf: return cls.parse_ngf(decoded) if is_gib: return cls.parse_gib(decoded) else: # sgf return cls.parse_sgf(decoded) def __init__(self, contents): self.contents = contents try: self.ix = self.contents.index("(") + 1 except ValueError: raise ParseError("Parse error: Expected '('") self.root = self._NODE_CLASS() self._parse_branch(self.root) def _parse_branch(self, current_move: SGFNode): while self.ix < len(self.contents): match = re.match(self.SGFPROP_PAT, self.contents[self.ix :]) if not match: break self.ix += len(match[0]) matched_item = match[0].strip() if matched_item == ")": return if matched_item == "(": self._parse_branch(self._NODE_CLASS(parent=current_move)) elif matched_item == ";": # ignore ;) for old SGF useless = self.ix < len(self.contents) and self.contents[self.ix] == ")" # ignore ; that generate empty nodes if not (current_move.empty or useless): current_move = self._NODE_CLASS(parent=current_move) else: property, value = match[1], match[2].strip()[1:-1] values = re.split(r"\]\s*\[", value) current_move.add_list_property(property, [SGFNode._unescape_value(v) for v in values]) if self.ix < len(self.contents): raise ParseError(f"Parse Error: unexpected character at {self.contents[self.ix:self.ix+25]}") raise ParseError("Parse Error: expected ')' at end of input.") # NGF parser adapted from https://github.com/fohristiwhirl/gofish/ @classmethod def parse_ngf(cls, ngf): ngf = ngf.strip() lines = ngf.split("\n") try: boardsize = int(lines[1]) handicap = int(lines[5]) pw = lines[2].split()[0] pb = lines[3].split()[0] rawdate = lines[8][0:8] komi = float(lines[7]) if handicap == 0 and int(komi) == komi: komi += 0.5 except (IndexError, ValueError): boardsize = 19 handicap = 0 pw = "" pb = "" rawdate = "" komi = 0 re = "" try: if "hite win" in lines[10]: re = "W+" elif "lack win" in lines[10]: re = "B+" except: pass if handicap < 0 or handicap > 9: raise ParseError(f"Handicap {handicap} out of range") root = cls._NODE_CLASS() node = root # Set root values... root.set_property("SZ", boardsize) if handicap >= 2: root.set_property("HA", handicap) root.place_handicap_stones(handicap, tygem=True) # While this isn't Tygem, it uses the same layout if komi: root.set_property("KM", komi) if len(rawdate) == 8: ok = True for n in range(8): if rawdate[n] not in "0123456789": ok = False if ok: date = rawdate[0:4] + "-" + rawdate[4:6] + "-" + rawdate[6:8] root.set_property("DT", date) if pw: root.set_property("PW", pw) if pb: root.set_property("PB", pb) if re: root.set_property("RE", re) # Main parser... for line in lines: line = line.strip().upper() if len(line) >= 7: if line[0:2] == "PM": if line[4] in ["B", "W"]: # move format is similar to SGF, but uppercase and out-by-1 key = line[4] raw_move = line[5:7].lower() value = chr(ord(raw_move[0]) - 1) + chr(ord(raw_move[1]) - 1) node = cls._NODE_CLASS(parent=node) node.set_property(key, value) if len(root.children) == 0: # We'll assume we failed in this case raise ParseError("Found no moves") return root # GIB parser adapted from https://github.com/fohristiwhirl/gofish/ @classmethod def parse_gib(cls, gib): def parse_player_name(raw): name = raw rank = "" foo = raw.split("(") if len(foo) == 2: if foo[1][-1] == ")": name = foo[0].strip() rank = foo[1][0:-1] return name, rank def gib_make_result(grlt, zipsu): easycases = {3: "B+R", 4: "W+R", 7: "B+T", 8: "W+T"} if grlt in easycases: return easycases[grlt] if grlt in [0, 1]: return "{}+{}".format("B" if grlt == 0 else "W", zipsu / 10) return "" def gib_get_result(line, grlt_regex, zipsu_regex): try: grlt = int(re.search(grlt_regex, line).group(1)) zipsu = int(re.search(zipsu_regex, line).group(1)) except: return "" return gib_make_result(grlt, zipsu) root = cls._NODE_CLASS() node = root lines = gib.split("\n") for line in lines: line = line.strip() if line.startswith("\\[GAMEBLACKNAME=") and line.endswith("\\]"): s = line[16:-2] name, rank = parse_player_name(s) if name: root.set_property("PB", name) if rank: root.set_property("BR", rank) if line.startswith("\\[GAMEWHITENAME=") and line.endswith("\\]"): s = line[16:-2] name, rank = parse_player_name(s) if name: root.set_property("PW", name) if rank: root.set_property("WR", rank) if line.startswith("\\[GAMEINFOMAIN="): result = gib_get_result(line, r"GRLT:(\d+),", r"ZIPSU:(\d+),") if result: root.set_property("RE", result) try: komi = int(re.search(r"GONGJE:(\d+),", line).group(1)) / 10 if komi: root.set_property("KM", komi) except: pass if line.startswith("\\[GAMETAG="): if "DT" not in root.properties: try: match = re.search(r"C(\d\d\d\d):(\d\d):(\d\d)", line) date = "{}-{}-{}".format(match.group(1), match.group(2), match.group(3)) root.set_property("DT", date) except: pass if "RE" not in root.properties: result = gib_get_result(line, r",W(\d+),", r",Z(\d+),") if result: root.set_property("RE", result) if "KM" not in root.properties: try: komi = int(re.search(r",G(\d+),", line).group(1)) / 10 if komi: root.set_property("KM", komi) except: pass if line[0:3] == "INI": if node is not root: raise ParseError("Node is not root") setup = line.split() try: handicap = int(setup[3]) except IndexError: continue if handicap < 0 or handicap > 9: raise ParseError(f"Handicap {handicap} out of range") if handicap >= 2: root.set_property("HA", handicap) root.place_handicap_stones(handicap, tygem=True) if line[0:3] == "STO": move = line.split() key = "B" if move[3] == "1" else "W" try: x = int(move[4]) y = 18 - int(move[5]) if not (0 <= x < 19 and 0 <= y < 19): raise ParseError(f"Coordinates for move ({x},{y}) out of range on line {line}") value = Move(coords=(x, y)).sgf(board_size=(19, 19)) except IndexError: continue node = cls._NODE_CLASS(parent=node) node.set_property(key, value) if len(root.children) == 0: # We'll assume we failed in this case raise ParseError("No valid nodes found") return root