diff --git a/.gitignore b/.gitignore index 031afa54..df2424a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +dist_sgf + # additions KataGoData katrain/KataGo/KataGoData diff --git a/CONTRIBUTIONS.md b/CONTRIBUTIONS.md index c3bcd763..5e48e29f 100644 --- a/CONTRIBUTIONS.md +++ b/CONTRIBUTIONS.md @@ -48,8 +48,9 @@ Many thanks to these additional authors: * "kaorahi" for bug fixes and SGF parser improvements. * "ajkenny84" for the red-green colourblind theme. * Lukasz Wierzbowski for the ability to paste urls for sgfs and helping fix alt-gr issues. -* Carton He for a fix to handling empty komi values in sgfs. -* "blamarche" for adding the board coordinates toggle +* Carton He for contributions to sgf parsing and handling. +* "blamarche" for adding the board coordinates toggle. +* "pdeblanc" for adding the ancient chinese scoring option. ## Translators @@ -62,6 +63,7 @@ Many thanks to the following contributors for translations. * Russian: Dmitry Ivankov and Alexander Kiselev * Simplified Chinese: Qing Mu with contributions from "Medwin" and Viktor Lin * Japanese: "kaorahi" +* Traditional Chinese: "Tony-Liou" ## Additional thanks to diff --git a/README.md b/README.md index 6b14c2f1..daf7b9c3 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ French Russian
-Chinese +Simplified Chinese +Traditional Chinese Korean Japanese @@ -208,13 +209,13 @@ In addition to shortcuts mentioned above and those shown in the main menu: ## Support / Contribute [![GitHub issues](http://img.shields.io/github/issues/sanderland/katrain)](http://github.com/sanderland/katrain/issues) -[![Contributors](http://img.shields.io/static/v1?label=contributors&message=24&color=dcb424)](CONTRIBUTIONS.md) +[![Contributors](http://img.shields.io/static/v1?label=contributors&message=26&color=dcb424)](CONTRIBUTIONS.md) [![Github sponsors](http://img.shields.io/static/v1?label=sponsor&message=%E2%9D%A4&logo=GitHub&color=dcb424&link=http://github.com/sponsors/sanderland/)](http://github.com/sponsors/sanderland) * Ideas, feedback, and contributions to code or translations are all very welcome. * For suggestions and planned improvements, see [open issues](http://github.com/sanderland/katrain/issues) on github to check if the functionality is already planned. * I am looking for contributors of more translations of both this manual and the program itself. The best way to help with this is to contact me on discord. -* You can contact me on [discord](http://discord.gg/AjTPFpN) (Sander#3278) or [Reddit](http://reddit.com/u/sanderbaduk) to get help, discuss improvements, or simply show your appreciation. +* You can contact me on the [Leela Zero & Friends Discord](http://discord.gg/AjTPFpN) (use the #gui channel) to get help, discuss improvements, or simply show your appreciation. * You can also donate to the project through [Github Sponsors](http://github.com/sponsors/sanderland). diff --git a/i18n.py b/i18n.py index 9adf47b8..76fe841c 100644 --- a/i18n.py +++ b/i18n.py @@ -70,7 +70,7 @@ po[lang].append(copied_entry) errors = True else: - print(f"MISSING IN DEFAULT AND {lang}", strings_to_langs[msgid]) + print(f"MISSING IN DEFAULT AND {lang}", msgid) errors = True for msgid, lang_entries in strings_to_langs.items(): diff --git a/katrain/KataGo/katago b/katrain/KataGo/katago index a85dce48..e1ad5246 100755 Binary files a/katrain/KataGo/katago and b/katrain/KataGo/katago differ diff --git a/katrain/KataGo/katago.exe b/katrain/KataGo/katago.exe index f65582aa..b6347645 100644 Binary files a/katrain/KataGo/katago.exe and b/katrain/KataGo/katago.exe differ diff --git a/katrain/__main__.py b/katrain/__main__.py index a49b68e8..1834d358 100644 --- a/katrain/__main__.py +++ b/katrain/__main__.py @@ -7,6 +7,7 @@ os.environ["KIVY_AUDIO"] = "sdl2" # some backends hard crash / this seems to be most stable import kivy + kivy.require("2.0.0") # next, icon @@ -42,6 +43,7 @@ from queue import Queue import urllib3 import webbrowser +import time from kivy.base import ExceptionHandler, ExceptionManager from kivy.app import App @@ -112,9 +114,8 @@ def __init__(self, **kwargs): self.idle_analysis = False self.message_queue = Queue() - self._keyboard = Window.request_keyboard(None, self, "") - self._keyboard.bind(on_key_down=self._on_keyboard_down) - Clock.schedule_interval(self.animate_pondering, 0.1) + self.last_key_down = None + self.last_focus_event = 0 def log(self, message, level=OUTPUT_INFO): super().log(message, level) @@ -156,7 +157,23 @@ def start(self): self.board_gui.trainer_config = self.config("trainer") self.engine = KataGoEngine(self, self.config("engine")) threading.Thread(target=self._message_loop_thread, daemon=True).start() - self._do_new_game() + sgf_args = [ + f + for f in sys.argv[1:] + if os.path.isfile(f) and any(f.lower().endswith(ext) for ext in ["sgf", "ngf", "gib"]) + ] + if sgf_args: + self.load_sgf_file(sgf_args[0], fast=True, rewind=True) + else: + self._do_new_game() + + Clock.schedule_interval(self.animate_pondering, 0.1) + Window.request_keyboard(None, self, "").bind(on_key_down=self._on_keyboard_down, on_key_up=self._on_keyboard_up) + + def set_focus_event(*args): + self.last_focus_event = time.time() + + MDApp.get_running_app().root_window.bind(focus=set_focus_event) def update_gui(self, cn, redraw_board=False): # Handle prisoners and next player display @@ -178,11 +195,11 @@ def update_gui(self, cn, redraw_board=False): # update engine status dot if not self.engine or not self.engine.katago_process or self.engine.katago_process.poll() is not None: self.board_controls.engine_status_col = Theme.ENGINE_DOWN_COLOR - elif len(self.engine.queries) == 0: + elif self.engine.is_idle(): self.board_controls.engine_status_col = Theme.ENGINE_READY_COLOR else: self.board_controls.engine_status_col = Theme.ENGINE_BUSY_COLOR - self.board_controls.queries_remaining = len(self.engine.queries) + self.board_controls.queries_remaining = self.engine.queries_remaining() # redraw board/stones if redraw_board: @@ -223,7 +240,7 @@ def _do_update_state( ): # cn mismatch stops this if undo fired. avoid message loop here or fires repeatedly. self._do_ai_move(cn) Clock.schedule_once(self.board_gui.play_stone_sound, 0.25) - if len(self.engine.queries) == 0 and self.idle_analysis: + if self.engine.is_idle() and self.idle_analysis: self("analyze-extra", "extra", continuous=True) Clock.schedule_once(lambda _dt: self.update_gui(cn, redraw_board=redraw_board), -1) # trigger? @@ -275,7 +292,13 @@ def _do_new_game(self, move_tree=None, analyze_fast=False, sgf_filename=None): self.play_mode.switch_ui_mode() # for new game, go to play, for loaded, analyze self.board_gui.animating_pv = None self.engine.on_new_game() # clear queries - self.game = Game(self, self.engine, move_tree=move_tree, analyze_fast=analyze_fast, sgf_filename=sgf_filename) + self.game = Game( + self, + self.engine, + move_tree=move_tree, + analyze_fast=analyze_fast or not move_tree, + sgf_filename=sgf_filename, + ) if move_tree: for bw, player_info in self.players_info.items(): player_info.player_type = PLAYER_HUMAN @@ -532,6 +555,7 @@ def popup_open(self) -> Popup: return first_child if isinstance(first_child, Popup) else None def _on_keyboard_down(self, _keyboard, keycode, _text, modifiers): + self.last_key_down = keycode ctrl_pressed = "ctrl" in modifiers if self.controls.note.focus: return # when making notes, don't allow keyboard shortcuts @@ -549,11 +573,7 @@ def _on_keyboard_down(self, _keyboard, keycode, _text, modifiers): return shift_pressed = "shift" in modifiers shortcuts = self.shortcuts - if keycode[1] == "tab": - self.play_mode.switch_ui_mode() - elif keycode[1] == "alt": - self.nav_drawer.set_state("toggle") - elif keycode[1] == "spacebar": + if keycode[1] == "spacebar": self.toggle_continuous_analysis() elif keycode[1] == "k": self.board_gui.toggle_coordinates() @@ -610,7 +630,23 @@ def _on_keyboard_down(self, _keyboard, keycode, _text, modifiers): filename = f"callgrind.{int(time.time())}.prof" stats.save(filename, type="callgrind") self.log(f"wrote profiling results to {filename}", OUTPUT_ERROR) - return True + + def _on_keyboard_up(self, _keyboard, keycode): + if keycode[1] in ["alt", "tab"]: + Clock.schedule_once(lambda *_args: self._single_key_action(keycode), 0.05) + + def _single_key_action(self, keycode): + if ( + self.controls.note.focus + or self.popup_open + or keycode != self.last_key_down + or time.time() - self.last_focus_event < 0.2 # this is here to prevent alt-tab from firing alt or tab + ): + return + if keycode[1] == "alt": + self.nav_drawer.set_state("toggle") + elif keycode[1] == "tab": + self.play_mode.switch_ui_mode() class KaTrainApp(MDApp): @@ -651,7 +687,6 @@ def build(self): Window.bind(on_request_close=self.on_request_close) Window.bind(on_dropfile=lambda win, file: self.gui.load_sgf_file(file.decode("utf8"))) - self.gui = KaTrainGui() Builder.load_file(popup_kv_file) return self.gui diff --git a/katrain/config.json b/katrain/config.json index 58337137..090e9799 100644 --- a/katrain/config.json +++ b/katrain/config.json @@ -6,7 +6,7 @@ "config": "katrain/KataGo/analysis_config.cfg", "threads": 12, "max_visits": 500, - "fast_visits": 50, + "fast_visits": 25, "max_time": 8.0, "wide_root_noise": 0.0, "_enable_ownership": true @@ -17,7 +17,7 @@ "anim_pv_time": 0.5, "debug_level": 0, "lang": "en", - "version": "1.7.0" + "version": "1.7.1" }, "timer": { "byo_length": 30, diff --git a/katrain/core/base_katrain.py b/katrain/core/base_katrain.py index c5e2e491..d562fcf4 100644 --- a/katrain/core/base_katrain.py +++ b/katrain/core/base_katrain.py @@ -141,7 +141,7 @@ def config(self, setting, default=None): cat, key = setting.split("/") return self._config.get(cat, {}).get(key, default) else: - return self._config[setting] + return self._config.get(setting, default) except KeyError: self.log(f"Missing configuration option {setting}", OUTPUT_ERROR) diff --git a/katrain/core/constants.py b/katrain/core/constants.py index a7d2a99f..c955d03a 100644 --- a/katrain/core/constants.py +++ b/katrain/core/constants.py @@ -1,5 +1,5 @@ PROGRAM_NAME = "KaTrain" -VERSION = "1.7.0" +VERSION = "1.7.1" HOMEPAGE = "https://github.com/sanderland/katrain" CONFIG_MIN_VERSION = "1.7.0" # keep config files from this version ANALYSIS_FORMAT_VERSION = "1.0" diff --git a/katrain/core/engine.py b/katrain/core/engine.py index 56c7ea99..6db94124 100644 --- a/katrain/core/engine.py +++ b/katrain/core/engine.py @@ -1,6 +1,7 @@ import copy import json import os +import queue import shlex import subprocess import threading @@ -14,7 +15,7 @@ from katrain.core.game_node import GameNode from katrain.core.lang import i18n from katrain.core.sgf_parser import Move -from katrain.core.utils import find_package_resource +from katrain.core.utils import find_package_resource, json_truncate_arrays class EngineDiedException(Exception): @@ -25,7 +26,13 @@ class KataGoEngine: """Starts and communicates with the KataGO analysis engine""" # TODO: we don't support suicide in game.py, so no "tt": "tromp-taylor", "nz": "new-zealand" - RULESETS_ABBR = [("jp", "japanese"), ("cn", "chinese"), ("ko", "korean"), ("aga", "aga")] + RULESETS_ABBR = [ + ("jp", "japanese"), + ("cn", "chinese"), + ("ko", "korean"), + ("aga", "aga"), + ("stone_scoring", "stone_scoring"), + ] RULESETS = {fromkey: name for abbr, name in RULESETS_ABBR for fromkey in [abbr, name]} @staticmethod @@ -40,10 +47,11 @@ def __init__(self, katrain, config): self.katago_process = None self.base_priority = 0 self.override_settings = {"reportAnalysisWinratesAs": "BLACK"} # force these settings - self._lock = threading.Lock() self.analysis_thread = None self.stderr_thread = None + self.write_stdin_thread = None self.shell = False + self.write_queue = queue.Queue() exe = config.get("katago", "").strip() if config.get("altcommand", ""): @@ -84,6 +92,7 @@ def __init__(self, katrain, config): self.start() def start(self): + self.write_queue = queue.Queue() try: self.katrain.log(f"Starting KataGo with {self.command}", OUTPUT_DEBUG) startupinfo = None @@ -100,13 +109,16 @@ def start(self): ) except (FileNotFoundError, PermissionError, OSError) as e: self.katrain.log( - i18n._("Starting Kata failed").format(command=self.command, error=e), OUTPUT_ERROR, + i18n._("Starting Kata failed").format(command=self.command, error=e), + OUTPUT_ERROR, ) return # don't start self.analysis_thread = threading.Thread(target=self._analysis_read_thread, daemon=True) self.stderr_thread = threading.Thread(target=self._read_stderr_thread, daemon=True) + self.write_stdin_thread = threading.Thread(target=self._write_stdin_thread, daemon=True) self.analysis_thread.start() self.stderr_thread.start() + self.write_stdin_thread.start() def on_new_game(self): self.base_priority += 1 @@ -129,7 +141,8 @@ def check_alive(self, os_error="", exception_if_dead=False): else: os_error += f"status {code}" died_msg = i18n._("Engine died unexpectedly").format(error=os_error) - self.katrain.log(died_msg, OUTPUT_ERROR) + if code != 1: # deliberate exit, already showed message? + self.katrain.log(died_msg, OUTPUT_ERROR) self.katago_process = None else: died_msg = i18n._("Engine died unexpectedly").format(error=os_error) @@ -147,13 +160,15 @@ def shutdown(self, finish=False): if process: self.katago_process = None process.terminate() - if self.stderr_thread: - self.stderr_thread.join() - if self.analysis_thread: - self.analysis_thread.join() + for t in [self.stderr_thread, self.analysis_thread, self.write_stdin_thread]: + if t: + t.join() def is_idle(self): - return not self.queries + return not self.queries and self.write_queue.empty() + + def queries_remaining(self): + return len(self.queries) + int(not self.write_queue.empty()) def _read_stderr_thread(self): while self.katago_process is not None: @@ -215,7 +230,7 @@ def _analysis_read_thread(self): f"[{time_taken:.1f}][{query_id}][{'....' if partial_result else 'done'}] KataGo analysis received: {len(analysis.get('moveInfos',[]))} candidate moves, {analysis['rootInfo']['visits'] if results_exist else 'n/a'} visits", OUTPUT_DEBUG, ) - self.katrain.log(line, OUTPUT_EXTRA_DEBUG) + self.katrain.log(json_truncate_arrays(analysis), OUTPUT_EXTRA_DEBUG) try: if callback and results_exist: callback(analysis, partial_result) @@ -227,19 +242,25 @@ def _analysis_read_thread(self): self.katrain.log(f"Unexpected exception {e} while processing KataGo output {line}", OUTPUT_ERROR) traceback.print_exc() - def send_query(self, query, callback, error_callback, next_move=None): - with self._lock: - self.query_counter += 1 + def _write_stdin_thread(self): # flush only in a thread since it returns only when the other program reads + while self.katago_process is not None: + try: + query, callback, error_callback, next_move = self.write_queue.get(block=True, timeout=0.1) + except queue.Empty: + continue if "id" not in query: + self.query_counter += 1 query["id"] = f"QUERY:{str(self.query_counter)}" self.queries[query["id"]] = (callback, error_callback, time.time(), next_move) - if self.katago_process: - self.katrain.log(f"Sending query {query['id']}: {json.dumps(query)}", OUTPUT_DEBUG) - try: - self.katago_process.stdin.write((json.dumps(query) + "\n").encode()) - self.katago_process.stdin.flush() - except OSError as e: - self.check_alive(os_error=str(e), exception_if_dead=True) + self.katrain.log(f"Sending query {query['id']}: {json.dumps(query)}", OUTPUT_DEBUG) + try: + self.katago_process.stdin.write((json.dumps(query) + "\n").encode()) + self.katago_process.stdin.flush() + except OSError as e: + self.check_alive(os_error=str(e), exception_if_dead=False) + + def send_query(self, query, callback, error_callback, next_move=None): + self.write_queue.put((query, callback, error_callback, next_move)) def terminate_query(self, query_id): if query_id is not None: @@ -319,7 +340,7 @@ def request_analysis( "includeMovesOwnership": ownership and not next_move, "includePolicy": not next_move, "initialStones": [[m.player, m.gtp()] for m in initial_stones], - "initialPlayer": analysis_node.root.next_player, + "initialPlayer": analysis_node.initial_player, "moves": [[m.player, m.gtp()] for m in moves], "overrideSettings": {**settings, **(extra_settings or {})}, } diff --git a/katrain/core/game.py b/katrain/core/game.py index dcf8401d..603d78d9 100644 --- a/katrain/core/game.py +++ b/katrain/core/game.py @@ -67,11 +67,21 @@ def __init__( 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.get_property("HA", 0)) + 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 (not self.root.move_with_placements and self.root.children and self.root.children[0].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: @@ -109,7 +119,8 @@ def __init__( def analyze_all_nodes(self, priority=0, analyze_fast=False, even_if_present=True): for node in self.root.nodes_in_tree: - if even_if_present or not node.analysis_loaded: + # 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) @@ -396,7 +407,7 @@ def __repr__(self): + f"\ncaptures: {self.prisoner_count}" ) - def generate_filename(self): + 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 @@ -413,23 +424,23 @@ def player_name(player_info): x_properties[bw + "R"] = rank_label(player_info.calculated_rank) if "+" in str(self.end_result): x_properties["RE"] = self.end_result - x_properties["KTV"] = ANALYSIS_FORMAT_VERSION 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 = None, trainer_config: Optional[Dict] = None, - ): + 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" } @@ -570,7 +581,7 @@ def set_analysis(result, _partial): analyze_and_play_policy(new_node) self.engines[node.next_player].request_analysis( - new_node, callback=set_analysis, priority=-1000, analyze_fast=True, + new_node, callback=set_analysis, priority=-1000, analyze_fast=True ) analyze_and_play_policy(cn) diff --git a/katrain/core/game_node.py b/katrain/core/game_node.py index 148b3375..249fc055 100644 --- a/katrain/core/game_node.py +++ b/katrain/core/game_node.py @@ -8,10 +8,10 @@ from katrain.core.constants import ( ANALYSIS_FORMAT_VERSION, PROGRAM_NAME, + REPORT_DT, SGF_INTERNAL_COMMENTS_MARKER, SGF_SEPARATOR_MARKER, VERSION, - REPORT_DT, ) from katrain.core.lang import i18n from katrain.core.sgf_parser import Move, SGFNode @@ -33,19 +33,6 @@ def analysis_dumps(analysis): ] -def analysis_loads(property_array, board_squares, version): - if version > ANALYSIS_FORMAT_VERSION: - raise ValueError(f"Can not decode analysis data with version {version}, please update {PROGRAM_NAME}") - ownership_data, policy_data, main_data, *_ = [ - gzip.decompress(base64.standard_b64decode(data)) for data in property_array - ] - return { - **json.loads(main_data), - "policy": unpack_floats(policy_data, board_squares + 1), - "ownership": unpack_floats(ownership_data, board_squares), - } - - class GameNode(SGFNode): """Represents a single game node, with one or more moves and placements.""" @@ -60,7 +47,7 @@ def __init__(self, parent=None, properties=None, move=None): self.end_state = None self.shortcuts_to = [] self.shortcut_from = None - self.analysis_loaded = False + self.analysis_from_sgf = None self.clear_analysis() def add_shortcut(self, to_node): # collapses the branch between them @@ -78,15 +65,31 @@ def remove_shortcut(self): from_node.shortcuts_to = [(m, v) for m, v in from_node.shortcuts_to if m != self] self.shortcut_from = None + def load_analysis(self): + if not self.analysis_from_sgf: + return False + try: + szx, szy = self.root.board_size + board_squares = szx * szy + version = self.root.get_property("KTV", ANALYSIS_FORMAT_VERSION) + if version > ANALYSIS_FORMAT_VERSION: + raise ValueError(f"Can not decode analysis data with version {version}, please update {PROGRAM_NAME}") + ownership_data, policy_data, main_data, *_ = [ + gzip.decompress(base64.standard_b64decode(data)) for data in self.analysis_from_sgf + ] + self.analysis = { + **json.loads(main_data), + "policy": unpack_floats(policy_data, board_squares + 1), + "ownership": unpack_floats(ownership_data, board_squares), + } + return True + except Exception as e: + print(f"Error in loading analysis: {e}") + return False + def add_list_property(self, property: str, values: List): if property == "KT": - try: - szx, szy = self.root.board_size - version = self.root.get_property("KTV", "") - self.analysis = analysis_loads(values, szx * szy, version) - self.analysis_loaded = True - except Exception as e: - print(f"Error in loading analysis: {e}") + self.analysis_from_sgf = values elif property == "C": comments = [ # strip out all previously auto generated comments c @@ -126,7 +129,9 @@ def sgf_properties( candidate_moves = self.parent.candidate_moves top_x = Move.from_gtp(candidate_moves[0]["move"]).sgf(self.board_size) best_sq = [ - Move.from_gtp(d["move"]).sgf(self.board_size) for d in candidate_moves[1:] if d["pointsLost"] <= 0.5 + Move.from_gtp(d["move"]).sgf(self.board_size) + for d in candidate_moves + if d["pointsLost"] <= 0.5 and d["move"] != "pass" and d["order"] != 0 ] if best_sq and "SQ" not in properties: properties["SQ"] = best_sq @@ -141,6 +146,7 @@ def sgf_properties( ] properties["CA"] = ["UTF-8"] properties["AP"] = [f"{PROGRAM_NAME}:{VERSION}"] + properties["KTV"] = [ANALYSIS_FORMAT_VERSION] if self.shortcut_from: properties["KTSF"] = [id(self.shortcut_from)] elif "KTSF" in properties: @@ -176,11 +182,10 @@ def analyze( region_of_interest=None, report_every=REPORT_DT, ): - additional_moves = bool(find_alternatives or region_of_interest) engine.request_analysis( self, callback=lambda result, partial_result: self.set_analysis( - result, refine_move, additional_moves, partial_result + result, refine_move, find_alternatives, region_of_interest, partial_result ), priority=priority, visits=visits, @@ -210,6 +215,7 @@ def set_analysis( analysis_json: Dict, refine_move: Optional[Move] = None, additional_moves: bool = False, + region_of_interest=None, partial_result: bool = False, ): if refine_move: @@ -218,25 +224,25 @@ def set_analysis( {"pv": [refine_move.gtp()] + pvtail, **analysis_json["rootInfo"]}, refine_move.gtp() ) else: - if additional_moves: + if additional_moves: # additional moves: old order matters, ignore new order for m in analysis_json["moveInfos"]: - del m["order"] # avoid changing order - if refine_move is None and not additional_moves: + del m["order"] + elif refine_move is None: # normal update: old moves to end, new order matters. also for region? for move_dict in self.analysis["moves"].values(): move_dict["order"] = 999 # old moves to end for move_analysis in analysis_json["moveInfos"]: self.update_move_analysis(move_analysis, move_analysis["move"]) self.analysis["ownership"] = analysis_json.get("ownership") self.analysis["policy"] = analysis_json.get("policy") - if not additional_moves: + if not additional_moves and not region_of_interest: self.analysis["root"] = analysis_json["rootInfo"] - if self.parent and self.move: - analysis_json["rootInfo"]["pv"] = [self.move.gtp()] + ( - analysis_json["moveInfos"][0]["pv"] if analysis_json["moveInfos"] else [] - ) - self.parent.update_move_analysis( - analysis_json["rootInfo"], self.move.gtp() - ) # update analysis in parent for consistency + if self.parent and self.move: + analysis_json["rootInfo"]["pv"] = [self.move.gtp()] + ( + analysis_json["moveInfos"][0]["pv"] if analysis_json["moveInfos"] else [] + ) + self.parent.update_move_analysis( + analysis_json["rootInfo"], self.move.gtp() + ) # update analysis in parent for consistency is_normal_query = refine_move is None and not additional_moves self.analysis["completed"] = self.analysis["completed"] or (is_normal_query and not partial_result) @@ -313,7 +319,7 @@ def comment(self, sgf=False, teach=False, details=False, interactive=True): text += i18n._("Info:point loss").format(points_lost=points_lost) + "\n" top_move = previous_top_move["move"] score = self.format_score(previous_top_move["scoreLead"]) - text += i18n._("Info:top move").format(top_move=top_move, score=score,) + "\n" + text += i18n._("Info:top move").format(top_move=top_move, score=score) + "\n" else: text += i18n._("Info:best move") + "\n" if previous_top_move.get("pv") and (sgf or details): diff --git a/katrain/core/sgf_parser.py b/katrain/core/sgf_parser.py index 158d6a83..441780f3 100644 --- a/katrain/core/sgf_parser.py +++ b/katrain/core/sgf_parser.py @@ -1,4 +1,5 @@ import copy +import chardet import math import re from collections import defaultdict @@ -29,7 +30,9 @@ def from_gtp(cls, gtp_coords, player="B"): @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 + if sgf_coords == "" or ( + sgf_coords == "tt" and board_size[0] <= 19 and board_size[1] <= 19 + ): # [tt] can be used as "pass" for <= 19x19 board 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), @@ -210,6 +213,13 @@ def komi(self) -> float: return km + @property + def handicap(self) -> int: + try: + return int(self.root.get_property("HA", 0)) + except ValueError: + return 0 + @property def ruleset(self) -> str: """Retrieves the root's RU property, or 'japanese' if missing""" @@ -291,17 +301,33 @@ def play(self, move) -> "SGFNode": return self.__class__(parent=self, move=move) @property - def next_player(self): - """Returns player to move""" - if "PL" in self.properties: # explicit + def initial_player(self): # player for first node + root = self.root + if "PL" in root.properties: # explicit return "B" if self.get_property("PL").upper().strip() == "B" else "W" - elif "B" in self.properties or ( - "AB" in self.properties and "W" not in self.properties and "AW" not in self.properties - ): # b move or setup with only black moves like root handicap + elif root.children: # child exist, use it if not placement + for child in root.children: + for color in "BW": + if color in child.properties: + return color + # b move or setup with only black moves like handicap + if "AB" in self.properties and "AW" not in self.properties: return "W" else: return "B" + @property + def next_player(self): + """Returns player to move""" + if self.is_root: + return self.initial_player + elif "B" in self.properties: + return "W" + elif "W" in self.properties: + return "B" + else: # only placements, find a parent node with a real move. TODO: better placement support + return self.parent.next_player + @property def player(self): """Returns player that moved last. nb root is considered white played if no handicap stones are placed""" @@ -343,7 +369,7 @@ def place_handicap_stones(self, n_handicaps, tygem=False): class SGF: - DEFAULT_ENCODING = "ISO-8859-1" # as specified by the standard + DEFAULT_ENCODING = "UTF-8" _NODE_CLASS = SGFNode # Class used for SGF Nodes, can change this to something that inherits from SGFNode # https://xkcd.com/1171/ @@ -383,11 +409,14 @@ def parse_file(cls, filename, encoding=None) -> SGFNode: if match: encoding = match[1].decode("ascii", errors="ignore") else: - encoding = cls.DEFAULT_ENCODING - try: - decoded = bin_contents.decode(encoding=encoding, errors="ignore") - except LookupError: - decoded = bin_contents.decode(encoding=cls.DEFAULT_ENCODING, errors="ignore") + encoding = chardet.detect(bin_contents[:300])["encoding"] + # workaround for some compatibility issues for Windows-1252 and GB2312 encodings + if encoding == "Windows-1252" or encoding == "GB2312": + encoding = "GBK" + try: + decoded = bin_contents.decode(encoding=encoding, errors="ignore") + except LookupError: + decoded = bin_contents.decode(encoding=cls.DEFAULT_ENCODING, errors="ignore") if is_ngf: return cls.parse_ngf(decoded) if is_gib: diff --git a/katrain/core/utils.py b/katrain/core/utils.py index 9bd57386..7d01a2ea 100644 --- a/katrain/core/utils.py +++ b/katrain/core/utils.py @@ -57,10 +57,14 @@ def find_package_resource(path, silent_errors=False): def pack_floats(float_list): + if float_list is None: + return b"" return struct.pack(f"{len(float_list)}e", *float_list) def unpack_floats(str, num): + if not str: + return None return struct.unpack(f"{num}e", str) @@ -72,3 +76,16 @@ def format_visits(n): if n < 1e6: return f"{n/1000:.0f}k" return f"{n/1e6:.0f}k" + + +def json_truncate_arrays(data, lim=20): + if isinstance(data, list): + if data and isinstance(data[0], dict): + return [json_truncate_arrays(d) for d in data] + if len(data) > lim: + data = [f"{len(data)} x {type(data[0]).__name__}"] + return data + elif isinstance(data, dict): + return {k: json_truncate_arrays(v) for k, v in data.items()} + else: + return data diff --git a/katrain/gui.kv b/katrain/gui.kv index 78a86975..c2fb0022 100644 --- a/katrain/gui.kv +++ b/katrain/gui.kv @@ -271,6 +271,7 @@ outline_color: Theme.BOX_BACKGROUND_COLOR outline_width: 2 padding: [2*CP_PADDING,CP_PADDING,CP_PADDING,CP_PADDING] + subtype_label: subtype_label CircleWithText: size_hint: 0.32, 1 player: root.player @@ -289,7 +290,8 @@ halign: 'center' valign: 'middle' Label: # todo: proper scaling/shortening label instead of this jank - text: (root.name if root.name and root.player_type==PLAYER_HUMAN and root.player_subtype == PLAYING_NORMAL else i18n._(root.player_subtype) ) + (" ({})".format(root.rank) if root.rank and root.player_subtype != PLAYING_TEACHING else "") + id: subtype_label + text: i18n._(root.player_subtype) size_hint: 1,0.4 font_size: self.height * 0.7 if root.player_type==PLAYER_HUMAN else self.height * 0.7 * min(1,18/len(self.text)) text_size: self.size if root.player_type==PLAYER_HUMAN else (None,self.height) @@ -942,7 +944,6 @@ icon: 'New-Game.png' shortcut: 'Ctrl-N' on_action: root.katrain("new-game-popup") - height: root.item_height MainMenuItem: text: i18n._('menu:save') icon: 'Save-Game.png' @@ -1002,6 +1003,9 @@ LangButton: icon: 'flags/flag-cn.png' on_press: app.language = 'cn' + LangButton: + icon: 'flags/flag-tw.png' + on_press: app.language = 'tw' LangButton: icon: 'flags/flag-ko.png' on_press: app.language = 'ko' @@ -1071,18 +1075,18 @@ AnalysisControls: id: analysis_controls size_hint_y: None - height: 1 if root.zen > 0 else max(sp(50),min(sp(75),self.width / 13.5)) + height: 1 if root.zen > 0 else max(sp(Theme.CONTROLS_PANEL_MIN_HEIGHT),min(sp(Theme.CONTROLS_PANEL_MAX_HEIGHT),self.width / Theme.CONTROLS_PANEL_ASPECT_RATIO)) opacity: 0 if root.zen > 0 else 1 BadukPanWidget: id: board_gui BadukPanControls: id: board_controls size_hint_y: None - height: 1 if root.zen > 0 else max(sp(50),min(sp(75),self.width / 13.5)) + height: 1 if root.zen > 0 else max(sp(Theme.CONTROLS_PANEL_MIN_HEIGHT),min(sp(Theme.CONTROLS_PANEL_MAX_HEIGHT),self.width / Theme.CONTROLS_PANEL_ASPECT_RATIO)) opacity: 0 if root.zen > 0 else 1 MDBoxLayout: size_hint_x: None - width: 1 if root.zen == 2 else self.height * 0.4 + width: 1 if root.zen == 2 else self.height * Theme.RIGHT_PANEL_ASPECT_RATIO opacity: 0 if root.zen == 2 else 1 orientation: 'vertical' PlayAnalyzeSelect: diff --git a/katrain/gui/badukpan.py b/katrain/gui/badukpan.py index eacf143a..3d3047d4 100644 --- a/katrain/gui/badukpan.py +++ b/katrain/gui/badukpan.py @@ -31,7 +31,7 @@ ) from katrain.core.game import Move from katrain.core.lang import i18n -from katrain.core.utils import evaluation_class, format_visits, var_to_grid +from katrain.core.utils import evaluation_class, format_visits, var_to_grid, json_truncate_arrays from katrain.gui.kivyutils import draw_circle, draw_text, cached_texture from katrain.gui.popups import I18NPopup, ReAnalyzeGamePopup from katrain.gui.theme import Theme @@ -186,9 +186,16 @@ def on_touch_up(self, touch): katrain.game.set_current_node(nodes_here[-1].parent) katrain.update_state() else: # load comments & pv - katrain.log(f"\nAnalysis:\n{nodes_here[-1].analysis}", OUTPUT_EXTRA_DEBUG) - katrain.log(f"\nParent Analysis:\n{nodes_here[-1].parent.analysis}", OUTPUT_EXTRA_DEBUG) - katrain.log(f"\nRoot Stats:\n{nodes_here[-1].analysis['root']}", OUTPUT_DEBUG) + katrain.log( + f"\nAnalysis:\n{json_truncate_arrays(nodes_here[-1].analysis,lim=5)}", OUTPUT_EXTRA_DEBUG + ) + katrain.log( + f"\nParent Analysis:\n{json_truncate_arrays(nodes_here[-1].parent.analysis,lim=5)}", + OUTPUT_EXTRA_DEBUG, + ) + katrain.log( + f"\nRoot Stats:\n{json_truncate_arrays(nodes_here[-1].analysis['root'],lim=5)}", OUTPUT_DEBUG + ) katrain.controls.info.text = nodes_here[-1].comment(sgf=True) katrain.controls.active_comment_node = nodes_here[-1] if nodes_here[-1].parent.analysis_exists: @@ -466,13 +473,12 @@ def draw_board_contents(self, *_args): pos=(self.gridpos_x[x], self.gridpos_y[y]), text=f"{100 * move_policy :.2f}"[:4] + "%", font_name="Roboto", + font_size=self.grid_size / 4, halign="center", ) if move_policy == best_move_policy: Color(*Theme.TOP_MOVE_BORDER_COLOR[:3], Theme.POLICY_ALPHA) - Line( - circle=(self.gridpos_x[x], self.gridpos_y[y], self.stone_size - dp(1.2),), width=dp(2), - ) + Line(circle=(self.gridpos_x[x], self.gridpos_y[y], self.stone_size - dp(1.2)), width=dp(2)) with pass_btn.canvas.after: move_policy = policy[-1] diff --git a/katrain/gui/controlspanel.py b/katrain/gui/controlspanel.py index 5fa5e6f5..ef4f7324 100644 --- a/katrain/gui/controlspanel.py +++ b/katrain/gui/controlspanel.py @@ -24,9 +24,9 @@ def save_ui_state(self): self.katrain._config["ui_state"] = self.katrain._config.get("ui_state", {}) self.katrain._config["ui_state"][self.mode] = { "analysis_controls": { - id: checkbox.active - for id, checkbox in self.katrain.analysis_controls.ids.items() - if isinstance(checkbox, AnalysisToggle) + id: toggle.active if not toggle.checkbox.slashed else None # troolean ftw + for id, toggle in self.katrain.analysis_controls.ids.items() + if isinstance(toggle, AnalysisToggle) }, "panels": { id: (panel.state, panel.option_state) @@ -39,7 +39,10 @@ def save_ui_state(self): def load_ui_state(self, _dt=None): state = self.katrain.config(f"ui_state/{self.mode}", {}) for id, active in state.get("analysis_controls", {}).items(): - self.katrain.analysis_controls.ids[id].checkbox.active = active + cb = self.katrain.analysis_controls.ids[id].checkbox + cb.active = bool(active) + if cb.tri_state: + cb.slashed = active is None for id, (panel_state, button_state) in state.get("panels", {}).items(): self.katrain.controls.ids[id].set_option_state(button_state) self.katrain.controls.ids[id].state = panel_state diff --git a/katrain/gui/kivyutils.py b/katrain/gui/kivyutils.py index fbf6a819..ead23747 100644 --- a/katrain/gui/kivyutils.py +++ b/katrain/gui/kivyutils.py @@ -28,7 +28,15 @@ from kivymd.uix.selectioncontrol import MDCheckbox from kivymd.uix.textfield import MDTextField -from katrain.core.constants import AI_STRATEGIES_RECOMMENDED_ORDER, GAME_TYPES, MODE_PLAY, PLAYER_AI +from katrain.core.constants import ( + AI_STRATEGIES_RECOMMENDED_ORDER, + GAME_TYPES, + MODE_PLAY, + PLAYER_AI, + PLAYER_HUMAN, + PLAYING_NORMAL, + PLAYING_TEACHING, +) from katrain.core.lang import i18n from katrain.gui.theme import Theme @@ -372,6 +380,26 @@ class PlayerInfo(MDBoxLayout, BackgroundMixin): rank = StringProperty("", allownone=True) active = BooleanProperty(True) + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.bind(player_type=self.set_label, player_subtype=self.set_label, name=self.set_label, rank=self.set_label) + + def set_label(self, *args): + if not self.subtype_label: # building + return + show_player_name = self.name and self.player_type == PLAYER_HUMAN and self.player_subtype == PLAYING_NORMAL + if show_player_name: + text = self.name + else: + text = i18n._(self.player_subtype) + if ( + self.rank + and self.player_subtype != PLAYING_TEACHING + and (show_player_name or self.player_type == PLAYER_AI) + ): + text += " ({})".format(self.rank) + self.subtype_label.text = text + class TimerOrMoveTree(MDBoxLayout): mode = StringProperty(MODE_PLAY) @@ -385,6 +413,11 @@ class Timer(BGBoxLayout): class TriStateMDCheckbox(MDCheckbox): tri_state = BooleanProperty(False) slashed = BooleanProperty(False) + checkbox_icon_slashed = StringProperty("checkbox-blank-off-outline") + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.bind(slashed=self.update_icon) def _do_press(self): if not self.tri_state: @@ -392,15 +425,21 @@ def _do_press(self): if self.slashed: self.state = "normal" self.slashed = False - self.icon = "checkbox-blank-outline" elif self.state == "down": self.state = "normal" self.slashed = True - self.icon = "checkbox-blank-off-outline" else: self.state = "down" self.slashed = False - self.icon = "checkbox-marked-outline" + self.update_icon() + + def update_icon(self, *args): + if self.tri_state and self.slashed: + self.icon = self.checkbox_icon_slashed + elif self.state == "down": + self.icon = self.checkbox_icon_down + else: + self.icon = self.checkbox_icon_normal class AnalysisToggle(MDBoxLayout): @@ -624,9 +663,7 @@ def cached_text_texture(text, font_name, markup, _cache={}, **kwargs): def draw_text(pos, text, font_name=None, markup=False, **kwargs): texture = cached_text_texture(text, font_name, markup, **kwargs) - Rectangle( - texture=texture, pos=(pos[0] - texture.size[0] / 2, pos[1] - texture.size[1] / 2), size=texture.size, - ) + Rectangle(texture=texture, pos=(pos[0] - texture.size[0] / 2, pos[1] - texture.size[1] / 2), size=texture.size) def draw_circle(pos, r, col): diff --git a/katrain/gui/popups.py b/katrain/gui/popups.py index b2436e5f..ae286354 100644 --- a/katrain/gui/popups.py +++ b/katrain/gui/popups.py @@ -1,10 +1,12 @@ import glob +import json import os import re import stat from typing import Any, Dict, List, Tuple, Union from zipfile import ZipFile +import urllib3 from kivy.clock import Clock from kivy.metrics import dp from kivy.properties import BooleanProperty, ListProperty, NumericProperty, ObjectProperty, StringProperty @@ -25,11 +27,12 @@ AI_KEY_PROPERTIES, AI_OPTION_VALUES, AI_STRATEGIES_RECOMMENDED_ORDER, + DATA_FOLDER, OUTPUT_DEBUG, OUTPUT_ERROR, OUTPUT_INFO, + SGF_INTERNAL_COMMENTS_MARKER, STATUS_INFO, - DATA_FOLDER, ) from katrain.core.engine import KataGoEngine from katrain.core.lang import i18n, rank_label @@ -43,8 +46,10 @@ class I18NPopup(Popup): title_key = StringProperty("") font_name = StringProperty(Theme.DEFAULT_FONT) - def __init__(self, **kwargs): - super().__init__(**kwargs) + def __init__(self, size=None, **kwargs): + if size: # do not exceed window height + size[1] = min(MDApp.get_running_app().gui.height, size[1]) + super().__init__(size=size, **kwargs) self.bind(on_dismiss=Clock.schedule_once(lambda _dt: MDApp.get_running_app().gui.update_state(), 1)) @@ -62,13 +67,15 @@ def raw_input_value(self): class LabelledPathInput(LabelledTextInput): + check_path = BooleanProperty(True) + def __init__(self, **kwargs): super().__init__(**kwargs) Clock.schedule_once(self.check_error, 0) def check_error(self, _dt=None): file = find_package_resource(self.input_value, silent_errors=True) - self.error = not (file and os.path.exists(file)) + self.error = self.check_path and not (file and os.path.exists(file)) def on_text(self, widget, text): self.check_error() @@ -267,7 +274,7 @@ def update_playerinfo(self, *args): def update_from_current_game(self, *args): for bw in "BW": name = self.katrain.game.root.get_property("P" + bw, None) - if name: + if name and SGF_INTERNAL_COMMENTS_MARKER not in name: self.player_name[bw].text = name rules = self.normalized_rules() self.km.text = str(self.katrain.game.root.komi) @@ -301,7 +308,7 @@ def update_game(self): self.update_playerinfo() if changed: self.katrain.engine.on_new_game() - self.katrain.game.analyze_all_nodes() + self.katrain.game.analyze_all_nodes(analyze_fast=True) self.popup.dismiss() @@ -419,6 +426,36 @@ def update_config(self, save_to_file=True): class ConfigPopup(QuickConfigGui): + MODEL_ENDPOINTS = {"Latest distributed model": "https://katagotraining.org/api/networks/newest_training/"} + MODELS = { + "20 block model": "https://github.com/lightvector/KataGo/releases/download/v1.4.5/g170e-b20c256x2-s5303129600-d1228401921.bin.gz", + "30 block model": "https://github.com/lightvector/KataGo/releases/download/v1.4.5/g170-b30c320x2-s4824661760-d1229536699.bin.gz", + "40 block model": "https://github.com/lightvector/KataGo/releases/download/v1.4.5/g170-b40c256x2-s5095420928-d1229425124.bin.gz", + } + MODEL_DESC = { + "Fat 40 block model": "https://d3dndmfyhecmj0.cloudfront.net/g170/neuralnets/g170e-b40c384x2-s2348692992-d1229892979.zip", + "15 block model": "https://d3dndmfyhecmj0.cloudfront.net/g170/neuralnets/g170e-b15c192-s1672170752-d466197061.bin.gz", + } + + KATAGOS = { + "win": { + "OpenCL v1.8.0": "https://github.com/lightvector/KataGo/releases/download/v1.8.0/katago-v1.8.0-opencl-windows-x64.zip", + "Eigen AVX2 (Modern CPUs) v1.8.0": "https://github.com/lightvector/KataGo/releases/download/v1.8.0/katago-v1.8.0-eigenavx2-windows-x64.zip", + "Eigen (CPU, Non-optimized) v1.8.0": "https://github.com/lightvector/KataGo/releases/download/v1.8.0/katago-v1.8.0-eigen-windows-x64.zip", + "OpenCL v1.6.1 (bigger boards)": "https://github.com/lightvector/KataGo/releases/download/v1.6.1%2Bbs29/katago-v1.6.1+bs29-gpu-opencl-windows-x64.zip", + }, + "linux": { + "OpenCL v1.8.0": "https://github.com/lightvector/KataGo/releases/download/v1.8.0/katago-v1.8.0-opencl-linux-x64.zip", + "Eigen AVX2 (Modern CPUs) v1.8.0": "https://github.com/lightvector/KataGo/releases/download/v1.8.0/katago-v1.8.0-eigenavx2-linux-x64.zip", + "Eigen (CPU, Non-optimized) v1.8.0": "https://github.com/lightvector/KataGo/releases/download/v1.8.0/katago-v1.8.0-eigen-linux-x64.zip", + "OpenCL v1.6.1 (bigger boards)": "https://github.com/lightvector/KataGo/releases/download/v1.6.1%2Bbs29/katago-v1.6.1+bs29-gpu-opencl-linux-x64.zip", + }, + "just-descriptions": { + "CUDA v1.8.0 (Windows)": "https://github.com/lightvector/KataGo/releases/download/v1.8.0/katago-v1.8.0-gpu-cuda10.2-windows-x64.zip", + "CUDA v1.8.0 (Linux)": "https://github.com/lightvector/KataGo/releases/download/v1.8.0/katago-v1.8.0-gpu-cuda10.2-linux-x64.zip", + }, + } + def __init__(self, katrain): super().__init__(katrain) self.paths = [self.katrain.config("engine/model"), "katrain/models", DATA_FOLDER] @@ -431,13 +468,17 @@ def build_and_set_properties(self, *_args): super().build_and_set_properties() def check_models(self, *args): + all_models = [self.MODELS, self.MODEL_DESC, self.katrain.config("dist_models", {})] + + def extract_model_file(model): + try: + return re.match(r".*/([^/]+)", model)[1].replace(".zip", ".bin.gz") + except (TypeError, IndexError): + return None + def find_description(path): file = os.path.split(path)[1] - file_to_desc = { - re.match(r".*/([^/]+)", model)[1].replace(".zip", ".bin.gz"): desc - for mods in [self.MODELS, self.MODEL_DESC] - for desc, model in mods.items() - } + file_to_desc = {extract_model_file(model): desc for mods in all_models for desc, model in mods.items()} if file in file_to_desc: return f"{file_to_desc[file]} - {path}" else: @@ -515,35 +556,6 @@ def find_description(path): self.katago_files.value_keys = ["", ""] + [path for path, desc in kata_files] self.katago_files.text = katas_available_msg - MODELS = { - "Latest 20 block model": "https://github.com/lightvector/KataGo/releases/download/v1.4.5/g170e-b20c256x2-s5303129600-d1228401921.bin.gz", - "Latest 30 block model": "https://github.com/lightvector/KataGo/releases/download/v1.4.5/g170-b30c320x2-s4824661760-d1229536699.bin.gz", - "Latest 40 block model": "https://github.com/lightvector/KataGo/releases/download/v1.4.5/g170-b40c256x2-s5095420928-d1229425124.bin.gz", - } - MODEL_DESC = { - "Fat 40 block model": "https://d3dndmfyhecmj0.cloudfront.net/g170/neuralnets/g170e-b40c384x2-s2348692992-d1229892979.zip", - "Latest 15 block model": "https://d3dndmfyhecmj0.cloudfront.net/g170/neuralnets/g170e-b15c192-s1672170752-d466197061.bin.gz", - } - - KATAGOS = { - "win": { - "OpenCL v1.7.0": "https://github.com/lightvector/KataGo/releases/download/v1.7.0/katago-v1.7.0-gpu-opencl-windows-x64.zip", - "Eigen AVX2 (Modern CPUs) v1.7.0": "https://github.com/lightvector/KataGo/releases/download/v1.7.0/katago-v1.7.0-cpu-eigen-avx2-windows-x64.zip", - "Eigen (CPU, Non-optimized) v1.7.0": "https://github.com/lightvector/KataGo/releases/download/v1.7.0/katago-v1.7.0-cpu-eigen-windows-x64.zip", - "OpenCL v1.6.1 (bigger boards)": "https://github.com/lightvector/KataGo/releases/download/v1.6.1%2Bbs29/katago-v1.6.1+bs29-gpu-opencl-windows-x64.zip", - }, - "linux": { - "OpenCL v1.7.0": "https://github.com/lightvector/KataGo/releases/download/v1.7.0/katago-v1.7.0-gpu-opencl-linux-x64.zip", - "Eigen AVX2 (Modern CPUs) v1.7.0": "https://github.com/lightvector/KataGo/releases/download/v1.7.0/katago-v1.7.0-cpu-eigen-avx2-linux-x64.zip", - "Eigen (CPU, Non-optimized) v1.7.0": "https://github.com/lightvector/KataGo/releases/download/v1.7.0/katago-v1.7.0-cpu-eigen-linux-x64.zip", - "OpenCL v1.6.1 (bigger boards)": "https://github.com/lightvector/KataGo/releases/download/v1.6.1%2Bbs29/katago-v1.6.1+bs29-gpu-opencl-linux-x64.zip", - }, - "just-descriptions": { - "CUDA v1.7.0 (Windows)": "https://github.com/lightvector/KataGo/releases/download/v1.7.0/katago-v1.7.0-gpu-cuda10.2-windows-x64.zip", - "CUDA v1.7.0 (Linux)": "https://github.com/lightvector/KataGo/releases/download/v1.7.0/katago-v1.7.0-gpu-cuda10.2-linux-x64.zip", - }, - } - def download_models(self, *_largs): def download_complete(req, tmp_path, path, model): try: @@ -558,7 +570,21 @@ def download_complete(req, tmp_path, path, model): c.request.cancel() self.download_progress_box.clear_widgets() downloading = False - for name, url in self.MODELS.items(): + + dist_models = {k: v for k, v in self.katrain.config("dist_models", {}).items() if k in self.MODEL_ENDPOINTS} + + for name, url in self.MODEL_ENDPOINTS.items(): + try: + http = urllib3.PoolManager() + response = http.request("GET", url) + dist_models[name] = json.loads(response.data.decode("utf-8"))["model_file"] + except Exception as e: + self.katrain.log(f"Failed to retrieve info for model: {e}", OUTPUT_INFO) + + self.katrain._config["dist_models"] = dist_models + self.katrain.save_config(key="dist_models") + + for name, url in {**self.MODELS, **dist_models}.items(): filename = os.path.split(url)[1] if not any(os.path.split(f)[1] == filename for f in self.model_files.values): savepath = os.path.expanduser(os.path.join(DATA_FOLDER, filename)) @@ -607,8 +633,11 @@ def download_complete(req, tmp_path, path, binary): os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR | stat.S_IXGRP) for f in zipObj.namelist(): if f.lower().endswith("dll"): - with open(os.path.join(os.path.split(path)[0], f), "wb") as fout: - fout.write(zipObj.read(f)) + try: + with open(os.path.join(os.path.split(path)[0], f), "wb") as fout: + fout.write(zipObj.read(f)) + except: # already there? no problem + pass os.remove(tmp_path) else: os.rename(tmp_path, path) @@ -662,7 +691,7 @@ def update_config(self, save_to_file=True): updated = super().update_config(save_to_file=save_to_file) self.katrain.debug_level = self.katrain.config("general/debug_level", OUTPUT_INFO) - ignore = {"max_visits", "max_time", "enable_ownership", "wide_root_noise"} + ignore = {"max_visits", "fast_visits", "max_time", "enable_ownership", "wide_root_noise"} detected_restart = [key for key in updated if "engine" in key and not any(ig in key for ig in ignore)] if detected_restart: @@ -678,7 +707,9 @@ def restart_engine(_dt): new_engine = KataGoEngine(self.katrain, self.katrain.config("engine")) self.katrain.engine = new_engine self.katrain.game.engines = {"B": new_engine, "W": new_engine} - self.katrain.game.analyze_all_nodes() # old engine was possibly broken, so make sure we redo any failures + self.katrain.game.analyze_all_nodes( + analyze_fast=True + ) # old engine was possibly broken, so make sure we redo any failures self.katrain.update_state() Clock.schedule_once(restart_engine, 0) @@ -695,6 +726,9 @@ def __init__(self, **kwargs): self.filesel.path = os.path.abspath(os.path.expanduser(app.gui.config("general/sgf_load"))) self.filesel.select_string = i18n._("Load File") + def on_submit(self): + self.filesel.button_clicked() + class SaveSGFPopup(BoxLayout): def __init__(self, suggested_filename, **kwargs): @@ -715,7 +749,7 @@ def set_suggested(_widget, path): self.filesel.select_string = i18n._("Save File") def on_submit(self): - self.filesel.dispatch("on_success") + self.filesel.button_clicked() class ReAnalyzeGamePopup(BoxLayout): diff --git a/katrain/gui/theme.py b/katrain/gui/theme.py index 3b1296c4..00580918 100644 --- a/katrain/gui/theme.py +++ b/katrain/gui/theme.py @@ -49,6 +49,10 @@ class Theme: MISTAKE_BUTTON_COLOR = [0.79, 0.06, 0.06, 1] # gui spacing + RIGHT_PANEL_ASPECT_RATIO = 0.4 # W/H + CONTROLS_PANEL_ASPECT_RATIO = 13.5 # W/H + CONTROLS_PANEL_MIN_HEIGHT = 50 + CONTROLS_PANEL_MAX_HEIGHT = 75 # dp CP_SPACING = 6 CP_SMALL_SPACING = 3 CP_PADDING = 6 diff --git a/katrain/gui/widgets/filebrowser.py b/katrain/gui/widgets/filebrowser.py index 9c679ebf..467f4a76 100644 --- a/katrain/gui/widgets/filebrowser.py +++ b/katrain/gui/widgets/filebrowser.py @@ -40,7 +40,7 @@ def _fbrowser_submit(self, instance): import string from functools import partial from os import walk -from os.path import dirname, expanduser, getmtime, isdir, join, sep +from os.path import dirname, expanduser, getmtime, isdir, isfile, join, sep from kivy import Config from kivy.clock import Clock @@ -172,6 +172,7 @@ class I18NFileChooserListLayout(FileChooserListLayout): spacing: 5 padding: [6, 6, 6, 6] select_state: select_button.state + file_text: file_text filename: file_text.text browser: list_view on_favorites: link_tree.reload_favs(self.favorites) @@ -207,7 +208,7 @@ class I18NFileChooserListLayout(FileChooserListLayout): id: list_view path: root.path sort_func: root.sort_func - filters: root.filters + filters: root.filters + [f.upper() for f in root.filters] filter_dirs: root.filter_dirs show_hidden: root.show_hidden multiselect: root.multiselect @@ -232,7 +233,7 @@ class I18NFileChooserListLayout(FileChooserListLayout): height: '40dp' size_hint_x: None text: root.select_string - on_release: root.dispatch('on_success') + on_release: root.button_clicked() """ ) @@ -314,11 +315,12 @@ def trigger_populate(self, node): class I18NFileBrowser(BoxLayout): - """I18NFileBrowser class, see module documentation for more information. - """ + """I18NFileBrowser class, see module documentation for more information.""" __events__ = ("on_success", "on_submit") + file_must_exist = BooleanProperty(False) # whether new file paths can be pointed at + select_state = OptionProperty("normal", options=("normal", "down")) """State of the 'select' button, must be one of 'normal' or 'down'. The state is 'down' only when the button is currently touched/clicked, @@ -464,3 +466,9 @@ def _shorten_filenames(self, filenames): def _attr_callback(self, attr, obj, value): setattr(self, attr, getattr(obj, attr)) + + def button_clicked(self): + if isdir(self.file_text.text): + self.path = self.file_text.text + elif not self.file_must_exist or isfile(self.file_text.text): + self.dispatch("on_success") diff --git a/katrain/gui/widgets/movetree.py b/katrain/gui/widgets/movetree.py index a8481088..a83a0417 100644 --- a/katrain/gui/widgets/movetree.py +++ b/katrain/gui/widgets/movetree.py @@ -109,7 +109,7 @@ def toggle_selected_node_collapse(self): def switch_branch(self, direction=1): pos = self.move_pos.get(self.scroll_view_widget.current_node) - if not self.scroll_view_widget: + if not self.scroll_view_widget or not pos: return same_x_moves = sorted([(y, n) for n, (x, y) in self.move_pos.items() if x == pos[0]]) new_index = next((i for i, (y, n) in enumerate(same_x_moves) if y == pos[1]), 0) + direction @@ -284,7 +284,7 @@ def on_scroll_start(self, touch, check_children=True): MoveTreeDropdownItem: text: i18n._("Delete Node") icon: 'delete.png' - shortcut: 'Ctrl+Del' + shortcut: 'Ctr+Del' on_action: root.katrain.controls.move_tree.delete_selected_node() -background_color: Theme.LIGHTER_BACKGROUND_COLOR -height: dp(45) diff --git a/katrain/gui/widgets/progress_loader.py b/katrain/gui/widgets/progress_loader.py index d2c3aae9..c7f4b14b 100644 --- a/katrain/gui/widgets/progress_loader.py +++ b/katrain/gui/widgets/progress_loader.py @@ -71,7 +71,7 @@ def start(self, root_instance): Clock.schedule_once(self.animation_show, 1) def animation_show(self, _dt): - animation = Animation(opacity=1, d=0.2, t="out_quad",) + animation = Animation(opacity=1, d=0.2, t="out_quad") animation.start(self) def request_download_file(self, url, path): diff --git a/katrain/i18n/locales/cn/LC_MESSAGES/katrain.mo b/katrain/i18n/locales/cn/LC_MESSAGES/katrain.mo index 469ca4a4..72b89fef 100644 Binary files a/katrain/i18n/locales/cn/LC_MESSAGES/katrain.mo and b/katrain/i18n/locales/cn/LC_MESSAGES/katrain.mo differ diff --git a/katrain/i18n/locales/cn/LC_MESSAGES/katrain.po b/katrain/i18n/locales/cn/LC_MESSAGES/katrain.po index e6e997a4..81bb71a3 100644 --- a/katrain/i18n/locales/cn/LC_MESSAGES/katrain.po +++ b/katrain/i18n/locales/cn/LC_MESSAGES/katrain.po @@ -276,6 +276,10 @@ msgstr "韩国规则" msgid "aga" msgstr "AGA规则" +#. TODO check +msgid "stone_scoring" +msgstr "古代中国规则" + msgid "clear cache" msgstr "清除缓存" @@ -322,11 +326,6 @@ msgstr "" "AI的\n" "显示选点/SGF评价" -msgid "lock ai when playing" -msgstr "" -"对局模式中\n" -"不允许最佳选点" - #. submit button msgid "update teacher" msgstr "更新指导棋设置" @@ -383,12 +382,6 @@ msgstr "" msgid "engine:time:hint" msgstr "时间单位是秒" -msgid "general:sgf_load" -msgstr "加载SGF文件路径" - -msgid "general:sgf_save" -msgstr "保存SGF文件路径" - msgid "general:anim_pv_time" msgstr "动画中两手棋时间间隔" diff --git a/katrain/i18n/locales/de/LC_MESSAGES/katrain.mo b/katrain/i18n/locales/de/LC_MESSAGES/katrain.mo index 240981d4..3a49e67a 100644 Binary files a/katrain/i18n/locales/de/LC_MESSAGES/katrain.mo and b/katrain/i18n/locales/de/LC_MESSAGES/katrain.mo differ diff --git a/katrain/i18n/locales/de/LC_MESSAGES/katrain.po b/katrain/i18n/locales/de/LC_MESSAGES/katrain.po index cd7890cb..76d22f4a 100644 --- a/katrain/i18n/locales/de/LC_MESSAGES/katrain.po +++ b/katrain/i18n/locales/de/LC_MESSAGES/katrain.po @@ -283,6 +283,10 @@ msgstr "Koreanisch" msgid "aga" msgstr "AGA" +#. TODO check +msgid "stone_scoring" +msgstr "Alte Chinesisch" + msgid "clear cache" msgstr "Cache leeren" @@ -329,11 +333,6 @@ msgstr "" "Dots/SGF-Kommentare \n" " für AI Spieler zeigen" -msgid "lock ai when playing" -msgstr "" -"Beste Züge während \n" -" Spiel deaktivieren" - #. submit button msgid "update teacher" msgstr "Feedbackeinstellungen aktualisieren" @@ -393,12 +392,6 @@ msgstr "" msgid "engine:time:hint" msgstr "Zeit in Sekunden" -msgid "general:sgf_load" -msgstr "Pfad um SGF-Dateien zu laden" - -msgid "general:sgf_save" -msgstr "Pfad um SGF-Dateien zu speichern" - msgid "general:anim_pv_time" msgstr "Zeit zwischen Zügen, wenn Sequenz animiert wird" diff --git a/katrain/i18n/locales/en/LC_MESSAGES/katrain.mo b/katrain/i18n/locales/en/LC_MESSAGES/katrain.mo index c0677f09..2e29a936 100644 Binary files a/katrain/i18n/locales/en/LC_MESSAGES/katrain.mo and b/katrain/i18n/locales/en/LC_MESSAGES/katrain.mo differ diff --git a/katrain/i18n/locales/en/LC_MESSAGES/katrain.po b/katrain/i18n/locales/en/LC_MESSAGES/katrain.po index 20f1d6e9..14178a88 100644 --- a/katrain/i18n/locales/en/LC_MESSAGES/katrain.po +++ b/katrain/i18n/locales/en/LC_MESSAGES/katrain.po @@ -394,6 +394,9 @@ msgstr "Korean" msgid "aga" msgstr "AGA" +msgid "stone_scoring" +msgstr "Ancient Chinese" + msgid "clear cache" msgstr "Clear cache" @@ -450,12 +453,6 @@ msgstr "" msgid "cache analysis to sgf" msgstr "Cache analysis in SGF" -#. removed as an option / can ignore -msgid "lock ai when playing" -msgstr "" -"Disable top moves\n" -" while in play mode" - #. submit button msgid "update teacher" msgstr "Update Feedback Settings" @@ -523,13 +520,6 @@ msgstr "" msgid "engine:time:hint" msgstr "Time in seconds" -#. no longer used, can ignore -msgid "general:sgf_load" -msgstr "Path for loading SGF files" - -msgid "general:sgf_save" -msgstr "Path for saving SGF files" - msgid "general:anim_pv_time" msgstr "Time between moves when animating a sequence" diff --git a/katrain/i18n/locales/es/LC_MESSAGES/katrain.mo b/katrain/i18n/locales/es/LC_MESSAGES/katrain.mo index 5baaa1ea..b92c9362 100644 Binary files a/katrain/i18n/locales/es/LC_MESSAGES/katrain.mo and b/katrain/i18n/locales/es/LC_MESSAGES/katrain.mo differ diff --git a/katrain/i18n/locales/es/LC_MESSAGES/katrain.po b/katrain/i18n/locales/es/LC_MESSAGES/katrain.po index cff7acec..cb88853d 100644 --- a/katrain/i18n/locales/es/LC_MESSAGES/katrain.po +++ b/katrain/i18n/locales/es/LC_MESSAGES/katrain.po @@ -287,6 +287,10 @@ msgstr "Koreanas" msgid "aga" msgstr "AGA" +#. TODO check +msgid "stone_scoring" +msgstr "Antiguas Chinas" + msgid "clear cache" msgstr "Vaciar Cache" @@ -333,11 +337,6 @@ msgstr "" "Mostrar puntos/comentarios\n" " para motores de IA" -msgid "lock ai when playing" -msgstr "" -"Deshabilitar jugadas superiores top moves\n" -" en modo juego" - #. submit button msgid "update teacher" msgstr "Actualizar conf" @@ -398,12 +397,6 @@ msgstr "" msgid "engine:time:hint" msgstr "Tiempo en segundos" -msgid "general:sgf_load" -msgstr "Ruta para cargar los archivos SGF" - -msgid "general:sgf_save" -msgstr "Ruta para guardar los archivos SGF" - msgid "general:anim_pv_time" msgstr "Tiempo entre juagadas animando una secuencia" diff --git a/katrain/i18n/locales/fr/LC_MESSAGES/katrain.mo b/katrain/i18n/locales/fr/LC_MESSAGES/katrain.mo index a9301513..0f0aa329 100644 Binary files a/katrain/i18n/locales/fr/LC_MESSAGES/katrain.mo and b/katrain/i18n/locales/fr/LC_MESSAGES/katrain.mo differ diff --git a/katrain/i18n/locales/fr/LC_MESSAGES/katrain.po b/katrain/i18n/locales/fr/LC_MESSAGES/katrain.po index 81694f1d..f1d74975 100644 --- a/katrain/i18n/locales/fr/LC_MESSAGES/katrain.po +++ b/katrain/i18n/locales/fr/LC_MESSAGES/katrain.po @@ -302,6 +302,10 @@ msgstr "Coréennes" msgid "aga" msgstr "AGA" +#. TODO check +msgid "stone_scoring" +msgstr "Chinoises anciennes" + msgid "clear cache" msgstr "Vider le cache" @@ -344,11 +348,6 @@ msgstr "Dernier(s) coup(s) à marquer :" msgid "show ai dots" msgstr "Étudier les coups du joueur artificiel" -msgid "lock ai when playing" -msgstr "" -"Ne pas recommander de coups \n" -"en mode “Partie”" - #. submit button msgid "update teacher" msgstr "Mettre à jour" @@ -428,16 +427,6 @@ msgstr "" msgid "engine:time:hint" msgstr "En secondes" -msgid "general:sgf_load" -msgstr "" -"Chemin d'accès du \n" -"fichier SGF à analyser" - -msgid "general:sgf_save" -msgstr "" -"Chemin d'accès du \n" -"fichier SGF à enregistrer" - msgid "general:anim_pv_time" msgstr "" "Durée entre chaque coup \n" diff --git a/katrain/i18n/locales/jp/LC_MESSAGES/katrain.mo b/katrain/i18n/locales/jp/LC_MESSAGES/katrain.mo index 928ba4ba..3511d380 100644 Binary files a/katrain/i18n/locales/jp/LC_MESSAGES/katrain.mo and b/katrain/i18n/locales/jp/LC_MESSAGES/katrain.mo differ diff --git a/katrain/i18n/locales/jp/LC_MESSAGES/katrain.po b/katrain/i18n/locales/jp/LC_MESSAGES/katrain.po index 4ea0d5c7..fcf96b7f 100644 --- a/katrain/i18n/locales/jp/LC_MESSAGES/katrain.po +++ b/katrain/i18n/locales/jp/LC_MESSAGES/katrain.po @@ -308,6 +308,9 @@ msgstr "韓国" msgid "aga" msgstr "AGA" +msgid "stone_scoring" +msgstr "古代中国" + msgid "clear cache" msgstr "思考記録をクリア" @@ -354,12 +357,6 @@ msgstr "" "AIの手にもドットや\n" "SGFコメントをつける" -#. removed as an option / can ignore -msgid "lock ai when playing" -msgstr "" -"Disable top moves\n" -" while in play mode" - #. submit button msgid "update teacher" msgstr "指導設定を更新" @@ -419,13 +416,6 @@ msgstr "" msgid "engine:time:hint" msgstr "秒数" -#. no longer used, can ignore -msgid "general:sgf_load" -msgstr "Path for loading SGF files" - -msgid "general:sgf_save" -msgstr "SGFファイルの保存場所" - msgid "general:anim_pv_time" msgstr "手順アニメーションの一手毎の時間" diff --git a/katrain/i18n/locales/ko/LC_MESSAGES/katrain.mo b/katrain/i18n/locales/ko/LC_MESSAGES/katrain.mo index 0ea1b770..5290e244 100644 Binary files a/katrain/i18n/locales/ko/LC_MESSAGES/katrain.mo and b/katrain/i18n/locales/ko/LC_MESSAGES/katrain.mo differ diff --git a/katrain/i18n/locales/ko/LC_MESSAGES/katrain.po b/katrain/i18n/locales/ko/LC_MESSAGES/katrain.po index 7ea4495b..c44af394 100644 --- a/katrain/i18n/locales/ko/LC_MESSAGES/katrain.po +++ b/katrain/i18n/locales/ko/LC_MESSAGES/katrain.po @@ -280,6 +280,10 @@ msgstr "한국" msgid "aga" msgstr "AGA" +#. TODO check +msgid "stone_scoring" +msgstr "고대 중국" + msgid "clear cache" msgstr "캐시 지우기" @@ -326,11 +330,6 @@ msgstr "" "인공지능 대국자에 대해서도\n" "평가를 색으로 표시" -msgid "lock ai when playing" -msgstr "" -"대국 중에\n" -"후보수 분석하지 않기" - #. submit button msgid "update teacher" msgstr "피드백 설정 갱신" @@ -392,12 +391,6 @@ msgstr "" msgid "engine:time:hint" msgstr "시간(초)" -msgid "general:sgf_load" -msgstr "SGF 파일을 불러올 경로" - -msgid "general:sgf_save" -msgstr "SGF 파일을 저장할 경로" - msgid "general:anim_pv_time" msgstr "수순을 보여줄 때 수 사이의 시간" diff --git a/katrain/i18n/locales/ru/LC_MESSAGES/katrain.mo b/katrain/i18n/locales/ru/LC_MESSAGES/katrain.mo index b583b4ca..49f48cca 100644 Binary files a/katrain/i18n/locales/ru/LC_MESSAGES/katrain.mo and b/katrain/i18n/locales/ru/LC_MESSAGES/katrain.mo differ diff --git a/katrain/i18n/locales/ru/LC_MESSAGES/katrain.po b/katrain/i18n/locales/ru/LC_MESSAGES/katrain.po index 2a3e83f4..489a683e 100644 --- a/katrain/i18n/locales/ru/LC_MESSAGES/katrain.po +++ b/katrain/i18n/locales/ru/LC_MESSAGES/katrain.po @@ -306,6 +306,10 @@ msgstr "Корейские" msgid "aga" msgstr "AGA" +#. TODO check +msgid "stone_scoring" +msgstr "Древние китайские" + msgid "clear cache" msgstr "Очистить кэш" @@ -352,12 +356,6 @@ msgstr "" "Показать точки и SGF комментарии\n" " для ИИ игроков" -#. removed as an option / can ignore -msgid "lock ai when playing" -msgstr "" -"Отключить лучшие ходы\n" -" во время игры" - #. submit button msgid "update teacher" msgstr "Сохранить настройки анализа" @@ -417,13 +415,6 @@ msgstr "" msgid "engine:time:hint" msgstr "Время в секундах" -#. no longer used, can ignore -msgid "general:sgf_load" -msgstr "Путь для загрузки файлов SGF" - -msgid "general:sgf_save" -msgstr "Путь для сохранения файлов SGF" - msgid "general:anim_pv_time" msgstr "Задержка между ходами в анимации" diff --git a/katrain/i18n/locales/tw/LC_MESSAGES/katrain.mo b/katrain/i18n/locales/tw/LC_MESSAGES/katrain.mo new file mode 100644 index 00000000..bde68db5 Binary files /dev/null and b/katrain/i18n/locales/tw/LC_MESSAGES/katrain.mo differ diff --git a/katrain/i18n/locales/tw/LC_MESSAGES/katrain.po b/katrain/i18n/locales/tw/LC_MESSAGES/katrain.po new file mode 100644 index 00000000..9e8b9224 --- /dev/null +++ b/katrain/i18n/locales/tw/LC_MESSAGES/katrain.po @@ -0,0 +1,785 @@ +# KaTrain localization file +msgid "" +msgstr "" +"Language: Traditional Chinese\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#. main hamburger menu +msgid "menu:playersetup" +msgstr "棋手設定" + +msgid "menu:newgame" +msgstr "新對局/更改規則" + +msgid "menu:save" +msgstr "儲存對局" + +msgid "menu:save-as" +msgstr "另存對局為..." + +msgid "menu:load" +msgstr "載入對局" + +msgid "menu:settings" +msgstr "一般和引擎設定" + +msgid "menu:clocksettings" +msgstr "用時設定" + +msgid "menu:teachsettings" +msgstr "指導/分析設定" + +msgid "menu:aisettings" +msgstr "AI設定" + +msgid "menu:lang" +msgstr "語言" + +msgid "menu:support" +msgstr "幫助" + +msgid "menu:manual" +msgstr "使用手册" + +#. player options in the setup and on the right +msgid "player:type" +msgstr "棋手" + +msgid "player:human" +msgstr "人類" + +msgid "player:ai" +msgstr "AI" + +msgid "aistrategy" +msgstr "AI策略" + +msgid "gametype" +msgstr "對局類型" + +msgid "game:normal" +msgstr "分先對局" + +msgid "game:teach" +msgstr "指導棋" + +#. buttons at the top - should all include a line break +msgid "analysis:nextmoves" +msgstr "" +"下\n" +"一手" + +msgid "analysis:dots" +msgstr "" +"顯示\n" +"選點" + +msgid "analysis:topmoves" +msgstr "" +"最佳\n" +"選點" + +msgid "analysis:policy" +msgstr "" +"自定義下\n" +"最佳選點" + +msgid "analysis:territory" +msgstr "" +"形勢\n" +"判断" + +#. analysis menu items +msgid "analysis:extra" +msgstr "深度分析" + +msgid "analysis:equalize" +msgstr "選點平均" + +msgid "analysis:sweep" +msgstr "快速全盤分析" + +#. Analyze every node in the game more deeply +msgid "analysis:game" +msgstr "深度全盤分析" + +#. Analyze again but exclude all currently considered moves +msgid "analysis:alternatives" +msgstr "尋找其他選點" + +#. Restrict analysis to a specific region +msgid "analysis:region" +msgstr "設定欲分析區域" + +#. Status message +msgid "analysis:region:start" +msgstr "拖移以選取區域。選擇其他點來取消已選取的區域。" + +#. Menu option +msgid "analysis:playtoend" +msgstr "快速下完" + +#. Keep improving current position analysis when there is nothing else to do +msgid "analysis:continuous" +msgstr "切換持續分析" + +msgid "analysis:aimove" +msgstr "要求AI產生下一手" + +#. Option in dropdown to insert some moves between the current and next +msgid "analysis:insert" +msgstr "插入棋子" + +#. status message +msgid "extra analysis" +msgstr "執行 {visits} 步額外分析" + +#. status message +msgid "sweep analysis" +msgstr "使用 {visits} 走訪/步來分析整盤棋" + +#. status message +msgid "alternative analysis" +msgstr "搜尋其他棋步中" + +#. status message +msgid "local analysis" +msgstr "搜尋局部下法中" + +#. status message +msgid "equalizing analysis" +msgstr "對所有選點進行 {visits} 步分析" + +#. status message +msgid "game re-analysis" +msgstr "使用 {visits} 步來重新分析整盤棋" + +#. right panel buttons and below board +msgid "pass-button-text" +msgstr "虛手" + +#. resign the game +msgid "resign" +msgstr "認輸" + +#. Shown when a move is undone in teaching mode. +msgid "teaching undo message" +msgstr "" +"復原第 {move} 手損失了 {points_lost:.1f} " +"目。請再試一次。若要查看提示,將游標停於該手以查看預期的反制手段;或查看預估的領地來看可能損失目數的棋盤區域。" + +msgid "ai-ponder" +msgstr "AI引擎狀態指示燈,從綠色(閒置)變為橘色(處理中)。當引擎崩潰或無法啟動時,會變為紅色。" + +#. undo button on the right +msgid "undo-button-text" +msgstr "復原" + +#. tabs and labels on the right panel +msgid "tab:score" +msgstr "分數" + +msgid "tab:winrate" +msgstr "勝率" + +#. shown on a graph tab +msgid "tab:rank_est" +msgstr "棋力預估" + +msgid "tab:points" +msgstr "目數損失" + +msgid "tab:info" +msgstr "資訊" + +msgid "tab:info-details" +msgstr "詳細資訊" + +msgid "tab:notes" +msgstr "註記" + +msgid "closedlabel:scoregraph" +msgstr "分數圖" + +#. on the graph +msgid "Jigo" +msgstr "和棋" + +msgid "closedlabel:movestats" +msgstr "每步統計" + +msgid "closedlabel:info¬es" +msgstr "每步資訊和註記" + +#. on the board after passing once/twice +msgid "board-pass" +msgstr "虛手" + +msgid "board-game-end" +msgstr "" +"對局\n" +"結束" + +#. Select mode +msgid "btn:Play" +msgstr "下棋" + +msgid "btn:Analysis" +msgstr "分析" + +msgid "btn:Analyze" +msgstr "分析選項" + +msgid "stats:winrate" +msgstr "勝率" + +msgid "stats:score" +msgstr "預估分數" + +msgid "stats:pointslost" +msgstr "目數損失" + +#. better than expected best move +msgid "stats:pointsgained" +msgstr "目數增加" + +#. SGF and move comment messages +msgid "SGF start message" +msgstr " 'X' 是KataGo的最佳選點,正方形為損失少於0.5目的選點" + +msgid "Info:score" +msgstr "分數: {score}" + +msgid "Info:winrate" +msgstr "勝率: {winrate}" + +msgid "Info:point loss" +msgstr "目數損失: {points_lost:.1f}" + +#. as in 'Move 125: B5' +msgid "move" +msgstr "手數 {number}" + +msgid "Info:top move" +msgstr "預測最佳選點為 {top_move} ({score})。" + +msgid "Info:best move" +msgstr "該手為最佳選點" + +msgid "Info:PV" +msgstr "PV: {pv}" + +msgid "Info:policy rank" +msgstr "直覺選點下該手棋是 #{rank} ({probability:.2%})." + +msgid "Info:policy best" +msgstr "最佳直覺選點是 {move} ({probability:.1%})." + +msgid "Info:teaching undo" +msgstr "指導棋模式下該手棋自動悔棋" + +msgid "Info:undo predicted PV" +msgstr "建議下一手: {pv}" + +msgid "Info:AI thoughts" +msgstr "AI思考過程: {thoughts}" + +msgid "No analysis available" +msgstr "沒有可用分析" + +msgid "Analyzing move..." +msgstr "分析中..." + +msgid "SGF Notes Hint" +msgstr "SGF註記" + +# sgf load popup +msgid "load sgf title" +msgstr "載入SGF文件" + +# sgf load button +msgid "Load File" +msgstr "載入檔案" + +# sgf save as button +msgid "Save File" +msgstr "儲存檔案" + +# sgf save popup +msgid "save sgf title" +msgstr "儲存SGF檔案" + +msgid "load sgf fast analysis" +msgstr "快速分析" + +msgid "load sgf rewind" +msgstr "回到第一步" + +# timer settings from here +msgid "timer settings" +msgstr "用時設定" + +msgid "byoyomi length" +msgstr "讀秒時長" + +msgid "byoyomi periods" +msgstr "讀秒次數" + +msgid "count down sound" +msgstr "開啟讀秒聲音" + +#. in minutes +msgid "main time" +msgstr "保留時間(分鐘)" + +#. ignore moves if timer is on and less than this is used +msgid "minimal time use" +msgstr "讀秒最短時間" + +msgid "move too fast" +msgstr "請最少思考 {num} 秒再落子。" + +#. submit button +msgid "update timer" +msgstr "更新用時設定" + +# new game settings from here +msgid "New Game title" +msgstr "新對局設定" + +#. in new game options +msgid "player names" +msgstr "棋手名稱" + +#. hint on player name input +msgid "black player name hint" +msgstr "黑棋棋手名稱" + +#. hint on player name input +msgid "white player name hint" +msgstr "白棋棋手名稱" + +msgid "board size" +msgstr "棋盤大小" + +msgid "handicap" +msgstr "讓子" + +msgid "komi" +msgstr "貼目" + +msgid "ruleset" +msgstr "規則設定" + +#. ruleset names +msgid "japanese" +msgstr "日本規則" + +msgid "chinese" +msgstr "中國規則" + +msgid "korean" +msgstr "韓國規則" + +msgid "aga" +msgstr "AGA規則" + +msgid "clear cache" +msgstr "清除快取" + +#. clear cache help +msgid "avoids replaying" +msgstr "" +"避免重複\n" +"相同對局" + +#. hints on new game +msgid "non square board hint" +msgstr "使用 x:y 來設定非正方形棋盤(例如19:9)" + +#. submit new game +msgid "new game" +msgstr "開始新對局" + +#. change komi etc in current game +msgid "change current game" +msgstr "" +"在進行中對局\n" +"更新貼目/規則" + +# teacher settings from here +msgid "teacher settings" +msgstr "指導棋設定" + +msgid "point loss threshold" +msgstr "目數損失下限" + +msgid "num undos" +msgstr "悔棋次數" + +msgid "dot color" +msgstr "選點顏色顯示" + +msgid "show dots" +msgstr "顯示選點" + +msgid "save dots" +msgstr "存入SGF" + +msgid "show last n dots" +msgstr "" +"顯示最後\n" +" 手棋的選點" + +msgid "show ai dots" +msgstr "" +"顯示AI的\n" +"選點/SGF評論" + +#. analysis option +msgid "cache analysis to sgf" +msgstr "儲存分析到SGF中" + +#. submit button +msgid "update teacher" +msgstr "更新指導棋設定" + +# main config settings +msgid "restarting engine" +msgstr "改變設定後,重新啟動引擎" + +# title +msgid "general settings title" +msgstr "一般和引擎設定" + +msgid "file not found" +msgstr "路徑不存在" + +msgid "general settings" +msgstr "一般設定" + +msgid "engine settings" +msgstr "KataGo設定" + +msgid "config file path" +msgstr "所有設定儲存在:" + +msgid "engine:katago" +msgstr "KataGo執行檔路徑" + +msgid "engine:katago:hint" +msgstr "留白以使用其他執行檔案" + +msgid "engine:model" +msgstr "KataGo模型檔路徑" + +msgid "engine:config" +msgstr "KataGo設定檔路徑" + +msgid "engine:altcommand" +msgstr "覆寫引擎指令" + +msgid "engine:altcommand:hint" +msgstr "啟動引擎的完整指令。其他所有模型的設定將會被忽略。" + +msgid "engine:max_visits" +msgstr "分析模式中,每手棋最大運算步數" + +msgid "engine:fast_visits" +msgstr "快速分析模式中,每手棋最大運算步數" + +msgid "engine:max_time" +msgstr "分析時間上限" + +msgid "engine:wide_root_noise" +msgstr "寬根雜訊 (增加了納入考慮的選點多樣性)" + +msgid "engine:wide_root_noise:hint" +msgstr "" +"使用 0.02-0.1 以顯示\n" +"更多可能選點" + +msgid "engine:time:hint" +msgstr "時間單位是秒" + +msgid "general:anim_pv_time" +msgstr "動畫中,兩手棋的時間間隔" + +msgid "general:debug_level:hint" +msgstr "設為1時,回報錯誤" + +msgid "general:debug_level" +msgstr "Console中的偵錯層級" + +msgid "katago settings" +msgstr "KataGo設定" + +msgid "update settings" +msgstr "更新設定" + +# ai settings from here +msgid "ai settings" +msgstr "進階AI設定" + +msgid "Select AI" +msgstr "選擇AI策略" + +msgid "update ai settings" +msgstr "更新AI設定" + +#. misc errors and info messages +msgid "sgf written" +msgstr "將包含分析的SGF寫入 {file_name}" + +msgid "wait-before-extra-analysis" +msgstr "優化前,等待初始分析完成" + +msgid "Copied SGF to clipboard." +msgstr "複製SGF到剪貼簿" + +msgid "Failed to import from clipboard" +msgstr "" +"從剪貼簿匯入對局失敗: {error}\n" +"剪貼簿内容: {contents}..." + +msgid "Failed to load SGF" +msgstr "載入SGF失敗: {error}" + +msgid "Kata exe not found" +msgstr "KataGo執行檔 {exe} 不存在,請檢查一般設定。" + +msgid "Kata exe not found in path" +msgstr "" +"在 PATH 中找不到KataGo執行檔 `{exe}`,請檢查環境變數或在一般設定中使用完整路徑代替。若是在 MacOS " +"上,請先閱讀使用手冊中的如何使用brew安裝KataGo。" + +msgid "Kata model not found" +msgstr "KataGo模型 {model} 不存在,請檢查一般設定。" + +msgid "Kata config not found" +msgstr "KataGo組態檔 {config} 不存在,請檢查一般設定。" + +msgid "Starting Kata failed" +msgstr "使用指令 '{command}' 啟動KataGo發生錯誤 {error}。" + +msgid "Engine died unexpectedly" +msgstr "引擎意外崩潰沒有輸出,可能為記憶體不足導致: {error}" + +msgid "Engine missing DLL" +msgstr "遺失DLL因此無法啟動KataGo。請參考KataGo文件中的所需事項,或嘗試單獨啟動KataGo來取得詳細資訊。" + +#. AI names, help etc +msgid "strength:kyu" +msgstr "級" + +msgid "strength:dan" +msgstr "段" + +msgid "ai:default" +msgstr "KataGo" + +msgid "aihelp:default" +msgstr "" +"最強的KataGo AI。其棋力由在一般設定中 `引擎` 區塊中的 `最大走訪數` 和 `模型` 以及引擎組態文件決定。此處沒有可供調整的選項。" + +#. ai which handles handicap games better +msgid "ai:handicap" +msgstr "Kata讓子棋" + +msgid "aihelp:handicap" +msgstr "" +"KataGo 對讓子棋做了最佳化的對局版本。以黑棋的觀點來看,KataGo 中的 `pda` 設定對應到的是 " +"`playoutDoublingAdvantage`。當被設定為 `自動` 時,KaTrain 將會找自動找到適合的值。" + +msgid "ai:simple" +msgstr "單純模式" + +msgid "aihelp:simple" +msgstr "" +"下法會導致盤面狀態簡單化。每步在損失最多 '最大目數損失' 目和最少 '最少走訪數'中,較傾向於損失較少目數、安定自身領地(搭配 importance " +"settled_weight)、突破對方領地(importance settled_weight * " +"opponent_fac)、避免脫先(5路以上,importance tenuki_penalty)和避免碰(importance " +"attachment_penalty)。權重可以是負數使對局複雜化,而寬根雜訊(在一般設定中)的使用,可以以棋力強度作為代價,加強風格。" + +msgid "ai:jigo" +msgstr "Kata控目" + +msgid "aihelp:jigo" +msgstr "在沒有更多限制的情況下,嘗試按指定目數(預設0.5)赢棋。" + +msgid "ai:scoreloss" +msgstr "分數損失" + +msgid "aihelp:scoreloss" +msgstr "傾向選擇低機率損失較多目數的落點。在較高計算深度次數下,棋力可能更不穩定/更弱。" + +msgid "ai:policy" +msgstr "直覺" + +msgid "aihelp:policy" +msgstr "按最佳直覺行棋,沒有任何計算。棋力主要由引擎設定中的 `模型` 決定。布局落點設定影響布局中隨機落點的數量。" + +msgid "ai:p:weighted" +msgstr "直覺權重" + +msgid "aihelp:p:weighted" +msgstr "" +"按直覺機率行棋, `weaken_fac` 影響較弱選點的多寡。當機率極低時,它的下棋風格變得很像直覺AI。 `pick_override` " +"決定在選擇最佳選點時的無隨機性;而 `lower_bound` 決定了允許的直覺值的下限。" + +msgid "ai:p:pick" +msgstr "盲選直覺" + +msgid "aihelp:p:pick" +msgstr "" +"隨機選擇 `pick_n + pick_frac * ` 並採用最佳的一手。 當 " +"`pick_frac=1` 時,下棋風格像是直覺AI。 如果直覺值高於 `pick_override`,則採用最佳的一手以避免明顯錯誤。" + +msgid "ai:p:local" +msgstr "局部模式" + +msgid "aihelp:p:local" +msgstr "" +"使加權 `pick_n + pick_frac * ` 接近最後一手並選出最好的一手。如果直覺值大於 " +"`pick_override`則採用最佳的一手以避免明顯錯誤。" + +msgid "ai:p:tenuki" +msgstr "脫先模式" + +msgid "aihelp:p:tenuki" +msgstr "" +"使加權 `pick_n + pick_frac * ` 遠離最後一手並選出最好的一手。如果直覺值大於 " +"`pick_override` 則採用最佳的一手以避免明顯錯誤。更高的 `stddev` 使其偏離更多。" + +msgid "ai:p:influence" +msgstr "取勢模式" + +msgid "aihelp:p:influence" +msgstr "" +"在偏移值高於 `threshold` 線的點中選出最好的一手。增加 `line_weight` 以加重對靠近邊緣選點的懲罰。在 '終局' " +"部分結束後停止。" + +msgid "ai:p:territory" +msgstr "取地模式" + +msgid "aihelp:p:territory" +msgstr "" +"在偏移值低於 `threshold` 線的點中選出最好的一手。增加 `line_weight` 以加重對靠近中央選點的懲罰。在 '終局' " +"部分結束後停止。" + +msgid "ai:p:rank" +msgstr "可變棋力" + +msgid "aihelp:p:rank" +msgstr "隨機選擇有限數量的合法選點,並下出最佳的一手。愈強的設定有愈多的選擇來選出最佳的一手。因為沒有0級/段,因此 3段 = -2級。" + +#. in AI settings +msgid "estimated strength" +msgstr "預估棋力" + +#. button in general settings for downloading models +msgid "download models button" +msgstr "下載模型" + +#. button in general settings for downloading katago executables +msgid "download katago button" +msgstr "下載多種 KataGo 版本" + +#. shown on on the model select dropdown +msgid "models available" +msgstr "找到 {num} 個可用模型" + +#. shown on on the katago binary select dropdown +msgid "default katago option" +msgstr "偵測平台並使用所提供OpenCL KataGo執行檔" + +#. shown on on the binaries select dropdown +msgid "katago binaries available" +msgstr "找到 {num} 個可用的KataGo版本" + +#. error message on trying to download models when already done +msgid "All models downloaded" +msgstr "已下載所有可用模型" + +#. error message on trying to download models when already done +msgid "All binaries downloaded" +msgstr "已下載所有可用的執行檔" + +#. label in little popup for analyzing entire game +msgid "reanalyze max visits" +msgstr "每步棋的計算深度" + +#. on move tree editing +msgid "Delete Node" +msgstr "刪除模式" + +#. on move tree editing +msgid "Toggle Collapse Branch" +msgstr "擴展/摺疊" + +#. on move tree editing +msgid "Make Main Branch" +msgstr "設為主要變化" + +#. theme label +msgid "theme" +msgstr "顏色主題" + +#. theme name +msgid "theme:normal" +msgstr "預設主題" + +#. theme name +msgid "theme:red-green-colourblind" +msgstr "紅綠色盲友好" + +#. setting: what to show on top move (e.g. point loss, visits, ...) +msgid "stats on top move" +msgstr "最佳選點統計" + +#. Dropdown menu option +msgid "top_move_delta_score" +msgstr "分數波動" + +#. Dropdown menu option +msgid "top_move_score" +msgstr "分數" + +#. Dropdown menu option +msgid "top_move_delta_winrate" +msgstr "勝率波動" + +#. Dropdown menu option +msgid "top_move_winrate" +msgstr "勝率" + +#. Dropdown menu option +msgid "top_move_visits" +msgstr "計算深度" + +#. Dropdown menu option +msgid "top_move_nothing" +msgstr "(無)" + +#. top move stats show threshold description +msgid "show stats if" +msgstr "計算深度...以上時顯示統計" + +#. status message on starting insert mode +msgid "starting insert mode" +msgstr "插入模式,按 i 結束插入。" + +#. status message on ending insert mode and copying the moves +msgid "ending insert mode" +msgstr "插入完畢,複製 {num_copied} 手棋。" + +#. when a user tries to go to a different move/node while in insert +#. mode +msgid "finish inserting before navigating" +msgstr "此功能在插入模式無法使用,按 i 結束插入。" + +#. TODO +msgid "stone_scoring" +msgstr "Ancient Chinese" diff --git a/katrain/img/flags/flag-tw.png b/katrain/img/flags/flag-tw.png new file mode 100644 index 00000000..970e3e30 Binary files /dev/null and b/katrain/img/flags/flag-tw.png differ diff --git a/katrain/popups.kv b/katrain/popups.kv index 14fdd3cc..8d5e420f 100644 --- a/katrain/popups.kv +++ b/katrain/popups.kv @@ -160,6 +160,7 @@ size_hint: 0.33, 1 AnchorLayout: LabelledPathInput: + check_path: False input_property: "engine/altcommand" hint_text: i18n._("engine:altcommand:hint") BoxLayout: @@ -298,6 +299,7 @@ Label: size_hint: 0.1, 1 text: '&' + font_size: sp(Theme.DESC_FONT_SIZE) * 0.8 LabelledSpinner: id: top_moves_show_secondary value_refs: TOP_MOVE_OPTIONS @@ -470,7 +472,7 @@ text: i18n._("ruleset") AnchorLayout: LabelledSpinner: - size_hint: 0.8,0.8 + size_hint: 0.95,0.8 input_property: 'game/rules' font_size: sp(Theme.DESC_FONT_SIZE) id: rules_spinner @@ -608,6 +610,7 @@ I18NFileBrowser: id: filesel multiselect: False + file_must_exist: True filters: ["*.sgf","*.gib","*.ngf"] path: "." size_hint: 1,7 @@ -618,6 +621,7 @@ I18NFileBrowser: id: filesel multiselect: False + file_must_exist: False filters: ["*.sgf","*.gib","*.ngf"] path: "." size_hint: 1,7 \ No newline at end of file diff --git a/setup.py b/setup.py index a0bc74aa..bcdd9efa 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ def include_data_files(directory): "urllib3", "pygame;platform_system=='Darwin'", # some mac versions need this for kivy "screeninfo;platform_system!='Darwin'", # for screen resolution, has problems on macos + "chardet", # for automatic encoding detection ], dependency_links=["https://kivy.org/downloads/simple/"], python_requires=">=3.6, <4", diff --git a/tests/test_board.py b/tests/test_board.py index 7014a085..496c1d14 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -1,7 +1,7 @@ import pytest from katrain.core.base_katrain import KaTrainBase -from katrain.core.game import Game, IllegalMoveException, Move +from katrain.core.game import Game, IllegalMoveException, Move, KaTrainSGF from katrain.core.game_node import GameNode @@ -105,3 +105,16 @@ def test_ko(self, new_game): b.play(Move(coords=None, player="B")) b.play(Move.from_gtp("A1", player="W")) assert 3 == len(b.prisoners) + + def test_handicap_load(self): + input_sgf = ( + "(;GM[1]FF[4]CA[UTF-8]AP[CGoban:3]ST[2]RU[Chinese]SZ[19]HA[2]KM[0.50]TM[600]OT[5x30 byo-yomi]PW[kneh]PB[ayabot003]WR[4k]BR[6k]DT[2021-01-04]PC[The KGS Go Server at http://www.gokgs.com/]C[ayabot003 [6k\\" + "]: GTP Engine for ayabot003 (black): Aya version 7.85x]RE[W+Resign];B[pd]BL[599.647];B[dp]BL[599.477];W[pp]WL[597.432];B[cd]BL[598.896];W[ed]WL[595.78];B[ec]BL[598.558])" + ) + root = KaTrainSGF.parse_sgf(input_sgf) + game = Game(MockKaTrain(force_package_config=True), MockEngine(), move_tree=root) + assert 0 == len(game.root.placements) + + root2 = KaTrainSGF.parse_sgf("(;GM[1]FF[4]SZ[19]HA[2];)") + game2 = Game(MockKaTrain(force_package_config=True), MockEngine(), move_tree=root2) + assert 2 == len(game2.root.placements) diff --git a/tests/test_parser.py b/tests/test_parser.py index cdbfcd44..83a18e57 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -157,11 +157,22 @@ def test_foxwq(): def test_next_player(): input_sgf = "(;GM[1]FF[4]AB[aa]AW[bb])" assert "B" == SGF.parse_sgf(input_sgf).next_player + assert "B" == SGF.parse_sgf(input_sgf).initial_player input_sgf = "(;GM[1]FF[4]AB[aa]AW[bb]PL[B])" assert "B" == SGF.parse_sgf(input_sgf).next_player + assert "B" == SGF.parse_sgf(input_sgf).initial_player input_sgf = "(;GM[1]FF[4]AB[aa]AW[bb]PL[W])" assert "W" == SGF.parse_sgf(input_sgf).next_player + assert "W" == SGF.parse_sgf(input_sgf).initial_player input_sgf = "(;GM[1]FF[4]AB[aa])" assert "W" == SGF.parse_sgf(input_sgf).next_player + assert "W" == SGF.parse_sgf(input_sgf).initial_player input_sgf = "(;GM[1]FF[4]AB[aa]PL[B])" assert "B" == SGF.parse_sgf(input_sgf).next_player + assert "B" == SGF.parse_sgf(input_sgf).initial_player + input_sgf = "(;GM[1]FF[4]AB[aa];B[dd])" # branch exists + assert "B" == SGF.parse_sgf(input_sgf).next_player + assert "B" == SGF.parse_sgf(input_sgf).initial_player + input_sgf = "(;GM[1]FF[4]AB[aa];W[dd])" # branch exists + assert "W" == SGF.parse_sgf(input_sgf).next_player + assert "W" == SGF.parse_sgf(input_sgf).initial_player