diff --git a/KataGo/OpenCL.dll b/KataGo/OpenCL.dll index 2c8d65d..ec4db0f 100644 Binary files a/KataGo/OpenCL.dll and b/KataGo/OpenCL.dll differ diff --git a/KataGo/analysis_config.cfg b/KataGo/analysis_config.cfg index 6cebae5..40523e4 100644 --- a/KataGo/analysis_config.cfg +++ b/KataGo/analysis_config.cfg @@ -55,14 +55,14 @@ maxVisits = 500 # number of visits, and thread contention will reduce efficiency, so cross-position parallelization is preferable # to numSearchThreads, but numSearchThreads is preferable if you want to reduce latency, and have individual # searches complete faster by doing fewer of them at a time. -numSearchThreads = 2 +numSearchThreads = 6 # GPU Settings------------------------------------------------------------------------------- # Maximum number of positions to send to GPU at once. -nnMaxBatchSize = 128 +nnMaxBatchSize = 64 # Cache up to 2 ** this many neural net evaluations in case of transpositions in the tree. -nnCacheSizePowerOfTwo = 23 +nnCacheSizePowerOfTwo = 21 # Size of mutex pool for nnCache is 2 ** this nnMutexPoolSizePowerOfTwo = 17 # Randomize board orientation when running neural net evals? diff --git a/KataGo/boost_filesystem-vc142-mt-x64-1_72.dll b/KataGo/boost_filesystem-vc142-mt-x64-1_72.dll new file mode 100644 index 0000000..2d6950d Binary files /dev/null and b/KataGo/boost_filesystem-vc142-mt-x64-1_72.dll differ diff --git a/KataGo/katago b/KataGo/katago new file mode 100755 index 0000000..cf204b4 Binary files /dev/null and b/KataGo/katago differ diff --git a/KataGo/katago-bs b/KataGo/katago-bs deleted file mode 100755 index d461551..0000000 Binary files a/KataGo/katago-bs and /dev/null differ diff --git a/KataGo/katago-bs.exe b/KataGo/katago-bs.exe deleted file mode 100644 index 47557ad..0000000 Binary files a/KataGo/katago-bs.exe and /dev/null differ diff --git a/KataGo/katago-bs52.exe b/KataGo/katago-bs52.exe new file mode 100644 index 0000000..3ffc4b2 Binary files /dev/null and b/KataGo/katago-bs52.exe differ diff --git a/KataGo/katago.exe b/KataGo/katago.exe index decdb4e..834a12b 100644 Binary files a/KataGo/katago.exe and b/KataGo/katago.exe differ diff --git a/KataGo/libz.dll b/KataGo/libz.dll deleted file mode 100644 index 831fa88..0000000 Binary files a/KataGo/libz.dll and /dev/null differ diff --git a/KataGo/libzip.dll b/KataGo/libzip.dll deleted file mode 100644 index c8bf586..0000000 Binary files a/KataGo/libzip.dll and /dev/null differ diff --git a/KataGo/vcruntime140_1.dll b/KataGo/vcruntime140_1.dll new file mode 100644 index 0000000..f564204 Binary files /dev/null and b/KataGo/vcruntime140_1.dll differ diff --git a/KataGo/zlib1.dll b/KataGo/zlib1.dll new file mode 100644 index 0000000..be705d8 Binary files /dev/null and b/KataGo/zlib1.dll differ diff --git a/README.md b/README.md index 43eeec2..a056203 100644 --- a/README.md +++ b/README.md @@ -39,17 +39,25 @@ but has since grown to include a wide range of features, including: * Execute the command `pip install kivy_deps.glew kivy_deps.sdl2 kivy_deps.gstreamer kivy` * Start the app by running `python katrain.py` in the directory where you downloaded the scripts. Note that the program can be slow to initialize the first time, due to kata's gpu tuning. -### Installation for Linux/Mac users +### Installation for Linux users * This assumed you have a working Python 3.6/3.7 installation as a default. If your default is python 2, use pip3/python3. Kivy currently does not have a release for Python 3.8. * Git clone or download the repository. -* `pip install kivy` -* A binary for KataGo is included, but if you have compiled your own, point the 'engine/katago' setting to the relevant KataGo v1.3.5+ binary. +* Run the command `pip install kivy` in the terminal. +* A binary for KataGo is included, but if you have compiled your own, point the 'engine/katago' setting to the relevant KataGo v1.3.5-bs29+ binary. * Start the app by running `python katrain.py`. Note that the program can be slow to initialize the first time, due to KataGo's GPU tuning. - + +### Installation for MacOS users + +* Git clone or download the repository. +* Run the command `pip install kivy` in the terminal. +* Follow instructions [here](https://github.com/lightvector/KataGo) to compile KataGo yourself -- note that the version required is currently too new for the 'brew' method. +* Start the app by running `python katrain.py`, change the path of the 'katago' setting to the path where you compiled it, and click 'Apply and Save'. + ## Manual ### Play + Under the 'play' tab you can select who is playing black and white. * Human is simple play with potential feedback, but without auto-undo. * Teach will give you instant feedback, and auto-undo bad moves to give you a second chance. @@ -72,6 +80,7 @@ In short, if you are a weaker player you should mostly on large dots that are re while stronger players can pay more attention to smaller mistakes. #### AIs + Available AIs, with strength indicating an estimate for the default settings, are: * **[9p+]** **Default** is full KataGo, above professional level. @@ -107,7 +116,6 @@ Keyboard shortcuts are shown with **[key]**. * **[s]**: Equalize: Re-evaluate all currently shown next moves with the same visits as the current top move. Useful to increase confidence in the suggestions with high uncertainty. * **[d]**: Sweep: Evaluate all possible next moves. This can take a bit of time even though 'fast_visits' is used, but the result is nothing if not colourful. - ## Keyboard and mouse shortcuts In addition to shortcuts mentioned above, there are: @@ -128,7 +136,6 @@ In addition to shortcuts mentioned above, there are: * **[Ctrl-n]**: Load SGF from clipboard * **[space]**: Pass - ## Configuration Configuration is stored in `config.json`. Most settings are now available to edit in the program, but some advanced options are not. @@ -165,11 +172,12 @@ If you ever need to reset to the original settings, simply re-download the `conf * The first startup of KataGo can be slow due to GPU tuning, after that it should be much faster. * The program is running too slowly. How can I speed it up? * Adjust the number of visits or maximum time allowed in the settings. -* The engine crashes with out of memory errors - * Try setting `nnMaxBatchSize` in `KataGo/analysis_config.cfg` to something lower. +* KataGo crashes with out of memory errors, how can I prevent this? + * Try using a lower number for `nnMaxBatchSize` in `KataGo/analysis_config.cfg`, and avoid using versions compiled with large board sizes. ## Contributing -* Feedback and pull requests are both very welcome. +* Feedback and pull requests are both very welcome. I would also be happy to host translations of this manual into languages where English fluency is typically lower. * For suggestions and planned improvements, see the 'issues' tab on github. -* You can also contact me on discord (Sander#3278) or [reddit](http://reddit.com/u/sanderbaduk) to give feedback, or simply show your appreciation. +* You can also contact me on discord (Sander#3278), [KakaoTalk](https://open.kakao.com/o/gTsMJCac) or [Reddit](http://reddit.com/u/sanderbaduk) to give feedback, or simply show your appreciation. +* Some people have also asked me how to donate. Something go-related such as a book or teaching time is highly appreciated. diff --git a/bots/ai2gtp.py b/bots/ai2gtp.py index 602acf7..297f2e2 100644 --- a/bots/ai2gtp.py +++ b/bots/ai2gtp.py @@ -75,7 +75,7 @@ def malkovich_analysis(cn): logger.log(f"dscore {dscore} = {cn.analysis['root']['scoreLead']} {cn.parent.analysis['root']['scoreLead']} at {move}...", OUTPUT_ERROR) if abs(dscore) > REPORT_SCORE_THRESHOLD and (cn.player == "B" and dscore < 0 or cn.player == "W" and dscore > 0): # relevant mistakes favpl = "B" if dscore > 0 else "W" - msg = f"MALKOVICH:{cn.player} {cn.single_move.gtp()} caused a significant score change ({favpl} gained {abs(dscore):.1f} points)" + msg = f"MALKOVICH:{cn.player} {cn.move.gtp()} caused a significant score change ({favpl} gained {abs(dscore):.1f} points)" if cn.ai_thoughts: msg += f" -> Win Rate {cn.format_win_rate()} Score {cn.format_score()} AI Thoughts: {cn.ai_thoughts}" else: @@ -139,7 +139,7 @@ while True: if not pol: pol = ["??"] print(f"DISCUSSION:OK, since you passed {MAX_PASS} times after the {bx+by}th move, I will pass as well [policy {pol[-1]:.3%}].", file=sys.stderr) - move = game.play(Move(None, player=game.next_player)).single_move + move = game.play(Move(None, player=game.next_player)).move else: move, node = ai_move(game, ai_strategy, ai_settings) if node is None: diff --git a/bots/engine_server.py b/bots/engine_server.py index 300fa8d..b71d0cf 100644 --- a/bots/engine_server.py +++ b/bots/engine_server.py @@ -18,7 +18,7 @@ ENGINE_SETTINGS = { "max_visits": 50, "max_time": 1.0, "enable_ownership": False, - "threads": 8, + "threads": 32, } diff --git a/bots/selfplay.py b/bots/selfplay.py index 309cdb5..315d9b1 100644 --- a/bots/selfplay.py +++ b/bots/selfplay.py @@ -41,7 +41,7 @@ class AI: "max_time": 300.0, "_enable_ownership": False, } - NUM_THREADS = 8 + NUM_THREADS = 32 IGNORE_SETTINGS_IN_TAG = {"threads", "_enable_ownership", "katago"} # katago for switching from/to bs version ENGINES = [] LOCK = threading.Lock() diff --git a/bots/settings.py b/bots/settings.py index 016a74f..0db6b04 100644 --- a/bots/settings.py +++ b/bots/settings.py @@ -1,5 +1,6 @@ bot_strategy_names = { "dev": "P:Noise", + "dev-beta": "P:Weighted", "strong": "Policy", "influence": "P:Influence", "territory": "P:Territory", @@ -11,7 +12,8 @@ bot_strategy_names = { greetings = { - "dev": "Experimental!", + "dev": "Policy+Dirichlet noise.", + "dev-beta": "Play a policy-weighted move.", "strong": "Play top policy move.", "influence": "Play an influential style.", "territory": "Play a territorial style.", diff --git a/bots/start_bot.py b/bots/start_bot.py index 35e4df0..b4f147e 100755 --- a/bots/start_bot.py +++ b/bots/start_bot.py @@ -15,7 +15,10 @@ if True or bot in ["dev", "local"]: else: GTP2OGS = "node ../stable-gtp2ogs" BOT_SETTINGS = f" --maxconnectedgames {MAXGAMES} --maxhandicapunranked 25 --maxhandicapranked 1 --boardsizesranked 19 --boardsizesunranked all --komisranked automatic,5.5,6.5,7.5 --komisunranked all" - +if "beta" in bot: + BOT_SETTINGS += " --beta" +else: + BOT_SETTINGS += "" # --rankedonly" username = f"katrain-{bot}" @@ -40,6 +43,6 @@ if settings: GREETING += f" Settings: {settings_dump}." BYEMSG = "Thank you for playing. If you have any feedback, please message my admin! Play with these bots at any time by downloading KaTrain at github.com/sanderland/katrain" -cmd = f'{GTP2OGS} --debug --apikey {APIKEY} --username {username} --greeting "{GREETING}" --farewell "{BYEMSG}" {BOT_SETTINGS} --farewellscore --aichat --noclock --nopause --speeds blitz,live --persist --minrank 25k -- python bots/ai2gtp.py {bot} {port}' +cmd = f'{GTP2OGS} --debug --apikey {APIKEY} --rejectnewfile ~/shutdown_bots --username {username} --greeting "{GREETING}" --farewell "{BYEMSG}" {BOT_SETTINGS} --farewellscore --aichat --noclock --nopause --speeds blitz,live --persist --minrank 25k -- python bots/ai2gtp.py {bot} {port}' print(f"starting bot {username} using server port {port} --> {cmd}") os.system(cmd) diff --git a/config.json b/config.json index 9da299c..0d4a0dd 100644 --- a/config.json +++ b/config.json @@ -1,9 +1,9 @@ { "engine": { - "katago": "KataGo/katago-bs", + "katago": "KataGo/katago", "model": "KataGo/models/b15-1.3.2.txt.gz", "config": "KataGo/analysis_config.cfg", - "threads": 8, + "threads": 16, "max_visits": 500, "fast_visits": 50, "max_time": 3.0, @@ -43,7 +43,8 @@ 0 ], "eval_off_show_last": 3, - "eval_show_ai": true + "eval_show_ai": true, + "lock_ai": false }, "ai": { "Default": { @@ -98,7 +99,7 @@ "_help_right": "Lower `stddev` makes it prefer closer moves." }, "P:Tenuki": { - "pick_override": 0.9, + "pick_override": 0.85, "stddev": 7.5, "pick_n": 5, "pick_frac": 0.7, diff --git a/core/ai.py b/core/ai.py index 225bc88..87c4d1c 100644 --- a/core/ai.py +++ b/core/ai.py @@ -99,11 +99,11 @@ def ai_move(game: Game, ai_mode: str, ai_settings: Dict) -> Tuple[Move, GameNode ai_thoughts += f"Generated weights for {ai_mode} according to weight factor {ai_settings['line_weight']} and distance from {thr_line+1}th line. " elif "local" in ai_mode or "tenuki" in ai_mode: var = ai_settings["stddev"] ** 2 - if not cn.single_move or cn.single_move.coords is None: + if not cn.move or cn.move.coords is None: weighted_coords = [(1, 1, *top_policy_move.coords)] # if "pick" in ai_mode -> even ai_thoughts += f"No previous non-pass move, faking weights to play top policy move. " else: - mx, my = cn.single_move.coords + mx, my = cn.move.coords weighted_coords = [ (policy_grid[y][x], math.exp(-0.5 * ((x - mx) ** 2 + (y - my) ** 2) / var), x, y) for x in range(size[0]) for y in range(size[1]) if policy_grid[y][x] > 0 ] diff --git a/core/engine.py b/core/engine.py index 5fb6915..4579f77 100644 --- a/core/engine.py +++ b/core/engine.py @@ -43,9 +43,9 @@ class KataGoEngine: try: self.katrain.log(f"Starting KataGo with {self.command}", OUTPUT_DEBUG) self.katago_process = subprocess.Popen(self.command, stdin=subprocess.PIPE, stdout=subprocess.PIPE) - except FileNotFoundError: + except FileNotFoundError as e: self.katrain.log( - f"Starting kata with command '{self.command}' failed. If you are on Mac or Linux, please change the settings or configuration file (config.json) to point to the correct KataGo executable.", + f"Starting kata with command '{self.command}' failed with error {e}. Please make sure the 'katago' value under 'engine' in settings points to the correct KataGo executable.", OUTPUT_ERROR, ) diff --git a/core/game.py b/core/game.py index 6692ce5..d2eb1ea 100644 --- a/core/game.py +++ b/core/game.py @@ -265,12 +265,15 @@ class Game: elif mode == "sweep": board_size_x, board_size_y = self.board_size 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 = [ - 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 - ] + 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]], + ) visits = engine.config["fast_visits"] self.katrain.controls.set_status(f"Refining analysis of entire board to {visits} visits") priority = -1_000_000_000 @@ -283,7 +286,7 @@ class Game: 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): - move = node.single_move + 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 @@ -306,5 +309,5 @@ class Game: node.auto_undo = undo if undo: self.undo(1) - self.katrain.controls.set_status(f"Undid move {move.gtp()} as it lost {points_lost:.1f} points{xmsg}") + self.katrain.controls.set_status(f"Undid move {move.gtp()} as it lost {points_lost:.1f} points{xmsg}. Hover over the move to see expected refutation.") self.katrain.update_state() diff --git a/core/game_node.py b/core/game_node.py index 9ee21b7..beebe4d 100644 --- a/core/game_node.py +++ b/core/game_node.py @@ -86,8 +86,14 @@ class GameNode(SGFNode): if win_rate is not None: return f"{'B' if win_rate > 0.5 else 'W'} {max(win_rate,1-win_rate):.1%}" - def comment(self, sgf=False, teach=False, hints=False): - single_move = self.single_move + def make_pv(self, player, pv, interactive): + pvtext = f"{player}{' '.join(pv)}" + # if interactive: + # pvtext = f"[u][ref={pvtext}][color=#334466]{pvtext}[/color][/ref][/u]" + return pvtext + + def comment(self, sgf=False, teach=False, hints=False, interactive=False): + single_move = self.move if not self.parent or not single_move: # root return "" @@ -109,7 +115,7 @@ class GameNode(SGFNode): text += f"Move was predicted best move.\n" if sgf: if previous_top_move.get("pv") and (sgf or hints): - text += f"PV: {single_move.player}{' '.join(previous_top_move['pv'])}\n" + text += f"PV: {self.make_pv(single_move.player,previous_top_move['pv'],interactive)}\n" if sgf or hints or teach: policy_ranking = self.parent.policy_ranking @@ -131,7 +137,7 @@ class GameNode(SGFNode): @property def points_lost(self) -> Optional[float]: - single_move = self.single_move + single_move = self.move if single_move and self.parent and self.analysis_ready and self.parent.analysis_ready: parent_score = self.parent.score score = self.score @@ -139,7 +145,7 @@ class GameNode(SGFNode): @property def parent_realized_points_lost(self) -> Optional[float]: - single_move = self.single_move + single_move = self.move if single_move and self.parent and self.parent.parent and self.analysis_ready and self.parent.parent.analysis_ready: parent_parent_score = self.parent.parent.score score = self.score diff --git a/core/sgf_parser.py b/core/sgf_parser.py index c3e59b9..9e1a994 100644 --- a/core/sgf_parser.py +++ b/core/sgf_parser.py @@ -5,16 +5,19 @@ 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"] # kata board size 29 support + 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_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) @@ -22,11 +25,13 @@ class Move: @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) + 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 @@ -37,21 +42,25 @@ class Move: 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" @@ -114,6 +123,7 @@ class SGFNode: @property def parent(self) -> Optional["SGFNode"]: + """Returns the parent node""" return self._parent @parent.setter @@ -123,13 +133,15 @@ class SGFNode: self._depth = None @property - def root(self) -> "SGFNode": # cached root 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: # cached depth 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 @@ -137,9 +149,9 @@ class SGFNode: self._depth = self.parent.depth + 1 return self._depth - # some root properties are available on any node @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(":")) @@ -150,15 +162,17 @@ class SGFNode: @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: - return self.root.get_property("RU") + """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.""" + """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 @@ -172,30 +186,35 @@ class SGFNode: return self.placements + self.moves @property - def single_move(self) -> Optional[Move]: + 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: # TODO: and not placements? + 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: - return not self.placements and self.single_move and self.single_move.is_pass + """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""" return [self] + sum([c.nodes_in_tree for c in self.children], []) @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: @@ -206,43 +225,49 @@ class SGFNode: 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.single_move == move: + if c.move == move: return c return self.__class__(parent=self, move=move) @property def next_player(self): - if "B" in self.properties or "AB" in self.properties: # root or black moved + """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" # nb root is considered white played if no handicap stones are placed + return "W" class SGF: + """Class used for SGF Nodes, can change this to something that inherits from SGFNode""" + _NODE_CLASS = SGFNode @classmethod def parse(cls, input_str) -> SGFNode: + """Parse a string as SGF.""" return cls(input_str).root @classmethod def parse_file(cls, filename, encoding=None) -> SGFNode: + """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: match = re.search(rb"CA\[(.*?)\]", bin_contents) if match: - encoding = match[1].decode("ascii") + encoding = match[1].decode("ascii", errors="ignore") else: encoding = "ISO-8859-1" # default - decoded = bin_contents.decode(encoding=encoding) + decoded = bin_contents.decode(encoding=encoding, errors="ignore") return cls.parse(decoded) def __init__(self, contents): diff --git a/gui/badukpan.py b/gui/badukpan.py index 7eaacc3..7957e86 100644 --- a/gui/badukpan.py +++ b/gui/badukpan.py @@ -25,7 +25,7 @@ class BadukPanWidget(Widget): self.grid_size = 0 self.stone_size = 0 self.last_eval = 0 - self.active_hints = [] + self.active_pv_moves = [] self.show_pv_for = None self.redraw_board_contents_trigger = Clock.create_trigger(self.draw_board_contents) Window.bind(mouse_pos=self.on_mouse_pos) @@ -60,10 +60,10 @@ class BadukPanWidget(Widget): pos = args[1] rel_pos = self.to_widget(*pos) # compensate for relative layout inside = self.collide_point(*rel_pos) - if inside and self.active_hints: + if inside and self.active_pv_moves: near_move = [ move - for move in self.active_hints + for move in self.active_pv_moves if abs(rel_pos[0] - self.gridpos_x[move[0]]) < self.grid_size / 2 and abs(rel_pos[1] - self.gridpos_y[move[1]]) < self.grid_size / 2 ] if near_move: @@ -81,7 +81,7 @@ class BadukPanWidget(Widget): xd, xp = self._find_closest(touch.x, self.gridpos_x) yd, yp = self._find_closest(touch.y, self.gridpos_y) - nodes_here = [node for node in katrain.game.current_node.nodes_from_root if node.single_move and node.single_move.coords == (xp, yp)] + nodes_here = [node for node in katrain.game.current_node.nodes_from_root if node.move and node.move.coords == (xp, yp)] if nodes_here and max(yd, xd) < self.grid_size / 2: # load old comment if touch.is_double_tap: # navigate to move katrain.game.set_current_node(nodes_here[-1]) @@ -89,8 +89,7 @@ class BadukPanWidget(Widget): else: # load comments katrain.log(f"\nAnalysis:\n{nodes_here[-1].analysis}", OUTPUT_DEBUG) katrain.log(f"\nParent Analysis:\n{nodes_here[-1].parent.analysis}", OUTPUT_DEBUG) - katrain.controls.info.text = nodes_here[-1].comment(sgf=True) - katrain.controls.info.text = nodes_here[-1].comment(sgf=True) + katrain.controls.info.text = nodes_here[-1].comment(sgf=True, interactive=True) self.ghost_stone = None self.draw_hover_contents() # remove ghost @@ -176,6 +175,8 @@ class BadukPanWidget(Widget): outline_color = self.ui_config["outline"] katrain = self.katrain board_size_x, board_size_y = katrain.game.board_size + lock_ai = self.trainer_config.get("lock_ai", False) and katrain.controls.teaching_mode_enabled() + show_n_eval = self.trainer_config["eval_off_show_last"] self.canvas.clear() with self.canvas: @@ -188,7 +189,6 @@ class BadukPanWidget(Widget): for m in katrain.game.stones: has_stone[m.coords] = m.player - show_n_eval = self.trainer_config["eval_off_show_last"] show_dots_for = {p: self.trainer_config["eval_show_ai"] or "ai" not in katrain.controls.player_mode(p) for p in Move.PLAYERS} nodes = katrain.game.current_node.nodes_from_root realized_points_lost = None @@ -239,7 +239,7 @@ class BadukPanWidget(Widget): pass_btn = katrain.board_controls.pass_btn pass_btn.canvas.after.clear() - if katrain.controls.policy.active and policy: + if katrain.controls.policy.active and policy and not lock_ai: policy_grid = var_to_grid(policy, (board_size_x, board_size_y)) best_move_policy = max(*policy) for y in range(board_size_y - 1, -1, -1): @@ -255,23 +255,6 @@ class BadukPanWidget(Widget): with pass_btn.canvas.after: draw_circle((pass_btn.pos[0] + pass_btn.width / 2, pass_btn.pos[1] + pass_btn.height / 2), polsize * pass_btn.height / 2, self.ui_config["policy_color"]) - # children of current moves in undo / review - undo_coords = set() - if katrain.controls.show_children.active: - alpha = self.ui_config["ghost_alpha"] - for child_node in current_node.children: - points_lost = child_node.points_lost - m = child_node.single_move - if m and m.coords is not None: - undo_coords.add(m.coords) - if points_lost is None: - evalcol = None - else: - evalcol = copy.copy(self.eval_color(points_lost)) - evalcol[3] = alpha - scale = self.ui_config["child_scale"] - self.draw_stone(m.coords[0], m.coords[1], (*stone_color[m.player][:3], alpha), None, None, evalcol, evalscale=scale, scale=scale) - # pass circle passed = len(nodes) > 1 and current_node.is_pass if passed: @@ -295,49 +278,73 @@ class BadukPanWidget(Widget): current_node = katrain.game.current_node player, next_player = current_node.player, current_node.next_player stone_color = self.ui_config["stones"] + teaching = katrain.controls.teaching_mode_enabled() + lock_ai = self.trainer_config.get("lock_ai", False) and teaching self.canvas.after.clear() with self.canvas.after: + self.active_pv_moves = [] + + # children of current moves in undo / review + if katrain.controls.show_children.active: + alpha = self.ui_config["ghost_alpha"] + for child_node in current_node.children: + points_lost = child_node.points_lost + move = child_node.move + if move and move.coords is not None: + if points_lost is None: + evalcol = None + else: + evalcol = copy.copy(self.eval_color(points_lost)) + evalcol[3] = alpha + self.active_pv_moves.append(move.coords) + if teaching and move.coords == self.show_pv_for and child_node.analysis_ready: + self.draw_pv(katrain, [move.gtp()] + child_node.candidate_moves[0].get("pv", []), [next_player, player]) + else: + scale = self.ui_config["child_scale"] + self.draw_stone(move.coords[0], move.coords[1], (*stone_color[move.player][:3], alpha), None, None, evalcol, evalscale=scale, scale=scale) # hints or PV - self.active_hints = [] - if katrain.controls.hints.active and not game_ended: + if katrain.controls.hints.active and not game_ended and not lock_ai: hint_moves = current_node.candidate_moves - for i, d in enumerate(hint_moves): - move = Move.from_gtp(d["move"]) - if move.coords is not None: # and move.coords not in undo_coords: + for i, move_dict in enumerate(hint_moves): + move = Move.from_gtp(move_dict["move"]) + if move.coords is not None: alpha, scale = self.ui_config["ghost_alpha"], 1.0 if i == 0: alpha += self.ui_config["top_move_x_alpha"] - elif d["visits"] < self.ui_config["visit_frac_small"] * hint_moves[0]["visits"]: + elif move_dict["visits"] < self.ui_config["visit_frac_small"] * hint_moves[0]["visits"]: scale = 0.8 - self.active_hints.append(move.coords) - - if move.coords == self.show_pv_for: # TODO: overlapping moves - pv = d.get("pv", [move.gtp()]) # if empty, show current move at least - for i, gtpmove in enumerate(pv): - move_player = [next_player, player][i % 2] - opp_player = [next_player, player][1 - i % 2] - coords = Move.from_gtp(gtpmove).coords - if coords is None: # tee-hee - sizefac = katrain.board_controls.pass_btn.size[1] / 2 / self.stone_size - board_coords = [ - katrain.board_controls.pass_btn.pos[0] + katrain.board_controls.pass_btn.size[0] + self.stone_size * sizefac, - katrain.board_controls.pass_btn.pos[1] + katrain.board_controls.pass_btn.size[1] / 2, - ] - else: - board_coords = (self.gridpos_x[coords[0]], self.gridpos_y[coords[1]]) - sizefac = 0.95 - draw_circle(board_coords, self.stone_size * sizefac, stone_color[move_player]) - Color(*stone_color[opp_player]) - draw_text(pos=board_coords, text=str(i + 1), font_size=sizefac * self.grid_size / 1.45) + self.active_pv_moves.append(move.coords) + if move.coords == self.show_pv_for: + self.draw_pv(katrain, move_dict.get("pv", [move.gtp()]), [next_player, player]) # if empty, show current move at least elif not self.show_pv_for: - self.draw_stone(move.coords[0], move.coords[1], [*self.eval_color(d["pointsLost"])[:3], alpha], scale=scale) + self.draw_stone(move.coords[0], move.coords[1], [*self.eval_color(move_dict["pointsLost"])[:3], alpha], scale=scale) # hover next move ghost stone if self.ghost_stone: self.draw_stone(*self.ghost_stone, (*stone_color[next_player], ghost_alpha)) + def draw_pv(self, katrain, pv, next_last_player): + # TODO: overlapping moves + stone_color = self.ui_config["stones"] + for i, gtpmove in enumerate(pv): + move_player = next_last_player[i % 2] + opp_player = next_last_player[1 - i % 2] + coords = Move.from_gtp(gtpmove).coords + if coords is None: # tee-hee + sizefac = katrain.board_controls.pass_btn.size[1] / 2 / self.stone_size + board_coords = [ + katrain.board_controls.pass_btn.pos[0] + katrain.board_controls.pass_btn.size[0] + self.stone_size * sizefac, + katrain.board_controls.pass_btn.pos[1] + katrain.board_controls.pass_btn.size[1] / 2, + ] + else: + board_coords = (self.gridpos_x[coords[0]], self.gridpos_y[coords[1]]) + sizefac = 0.95 + draw_circle(board_coords, self.stone_size * sizefac, stone_color[move_player]) + Color(*stone_color[opp_player]) + draw_text(pos=board_coords, text=str(i + 1), font_size=sizefac * self.grid_size / 1.45) + class BadukPanControls(BoxLayout): pass diff --git a/gui/controls.py b/gui/controls.py index e968b2c..1fd2847 100644 --- a/gui/controls.py +++ b/gui/controls.py @@ -36,6 +36,9 @@ class Controls(BoxLayout): def ai_mode(self, player): return self.ai_mode_groups[player].text + def teaching_mode_enabled(self): + return "undo" in self.player_mode("B") or "undo" in self.player_mode("W") + def on_size(self, *args): self.update_evaluation() @@ -51,7 +54,7 @@ class Controls(BoxLayout): info = "" if current_node: - move = current_node.single_move + move = current_node.move both_players_are_robots = "ai" in self.player_mode(current_node.player) and "ai" in self.player_mode(current_node.next_player) next_player_is_human_or_both_robots = current_node.player and ("ai" not in self.player_mode(current_node.player) or both_players_are_robots) current_player_is_ai_playing_human = current_node.player and "ai" in self.player_mode(current_node.player) and "ai" not in self.player_mode(current_node.next_player) @@ -70,10 +73,10 @@ class Controls(BoxLayout): elif not current_player_is_ai_playing_human: self.score_change.label = f"Points lost" self.score_change.text = "" - elif current_player_is_ai_playing_human and current_node.parent and current_node.parent.single_move: + elif current_player_is_ai_playing_human and current_node.parent and current_node.parent.move: points_lost = current_node.parent.points_lost self.score_change.label = f"Points lost" if points_lost and points_lost > 0 else f"Points gained" - self.score_change.text = f"{current_node.parent.single_move.player}: {abs(points_lost):.1f}" if points_lost else "..." + self.score_change.text = f"{current_node.parent.move.player}: {abs(points_lost):.1f}" if points_lost else "..." elif both_players_are_robots and current_node.parent and current_node.parent.analysis_ready: self.score.text = current_node.parent.format_score() self.win_rate.text = current_node.parent.format_win_rate() diff --git a/gui/kivyutils.py b/gui/kivyutils.py index 3b683f3..85ddfda 100644 --- a/gui/kivyutils.py +++ b/gui/kivyutils.py @@ -80,9 +80,14 @@ class BackgroundLabel(Label, BackgroundColor): class ScrollableLabel(ScrollView): + __events__ = ["on_ref_press"] text = StringProperty("") + markup = BooleanProperty(False) border_color = ListProperty([0, 0, 0, 1]) + def on_ref_press(self, ref): + pass + class StyledButton(Button, ToolTipBehavior): button_color = ListProperty([]) diff --git a/gui/popups.py b/gui/popups.py index 888d5d3..498f8aa 100644 --- a/gui/popups.py +++ b/gui/popups.py @@ -179,6 +179,7 @@ class ConfigPopup(QuickConfigGui): if not old_proc: self.katrain.game.analyze_all_nodes() # old engine was broken, so make sure we redo any failures self.katrain.update_state() + Clock.schedule_once(restart_engine, 0) self.katrain.debug_level = self.config["debug"]["level"] @@ -275,6 +276,10 @@ class ConfigTeacherPopup(QuickConfigGui): xsettings.add_widget(ScaledLightLabel(text="Show dots/SGF comments for AI players")) xsettings.add_widget(LabelledCheckBox(size_hint=(0.5, 1), text=str(self.settings["eval_show_ai"]), input_property="eval_show_ai")) self.add_widget(xsettings) + xsettings = BoxLayout(size_hint=(1, 0.15), spacing=2) + xsettings.add_widget(ScaledLightLabel(text="Disable analysis while in teach mode")) + xsettings.add_widget(LabelledCheckBox(size_hint=(0.5, 1), text=str(self.settings["lock_ai"]), input_property="lock_ai")) + self.add_widget(xsettings) bl = BoxLayout(size_hint=(1, 0.15), spacing=2) bl.add_widget(StyledButton(text=f"Apply", on_press=lambda _: self.update_config(False))) diff --git a/katrain.kv b/katrain.kv index 4ef8fb1..27dbe11 100644 --- a/katrain.kv +++ b/katrain.kv @@ -402,6 +402,7 @@ width:1 Label: id: label + markup: root.markup padding: 5, 5 font_size: dp(18) color: BLACK @@ -409,6 +410,7 @@ height: self.texture_size[1] text_size: self.width, None text: root.text + on_ref_press: root.dispatch('on_ref_press',args[1]) : orientation: 'vertical' @@ -626,6 +628,8 @@ height: 0.00001 if not self.text else min(0.66*self.parent.height,self.label.texture_size[1]) ScrollableLabel: id: info +# markup: True +# on_ref_press: root.katrain.board_gui.show_pv_from_label(args[1]) size_hint: 1, None height: self.parent.height - status_label.height - 1 BoxLayout: diff --git a/spec/KaTrain.spec b/spec/KaTrain.spec index 00706fd..ff42991 100644 --- a/spec/KaTrain.spec +++ b/spec/KaTrain.spec @@ -33,7 +33,7 @@ exe = EXE(pyz, upx=True, console=True) # , icon='..\\icon.png' -EXCLUDE_SUFFIX = ['katago-bs','b10-1.3.txt.gz','katago.exe','screenshot_analyze.png','screenshot_play.png'] +EXCLUDE_SUFFIX = ['katago','b10-1.3.txt.gz','screenshot_analyze.png','screenshot_play.png'] EXCLUDE = ['KataGoData'] a.datas = [(ff,ft,tp) for ff,ft,tp in a.datas if not any(ff.endswith(suffix) for suffix in EXCLUDE_SUFFIX) and not any(kw in ff for kw in EXCLUDE)] coll = COLLECT(exe,