def test_collide(self, new_game): b = Game(MockKaTrain(force_package_config=True), MockEngine(), move_tree=new_game) b.play(Move.from_gtp("B9", player="B")) with pytest.raises(IllegalMoveException): b.play(Move.from_gtp("B9", player="W")) assert 1 == len(self.nonempty_chains(b)) assert 1 == len(b.stones) assert 0 == len(b.prisoners)
def test_collide(self): b = Game(MockKaTrain(), MockEngine()) b.play(Move.from_gtp("B9", player="B")) with pytest.raises(IllegalMoveException): b.play(Move.from_gtp("B9", player="W")) assert 1 == len(self.nonempty_chains(b)) assert 1 == len(b.stones) assert 0 == len(b.prisoners)
def test_merge(self, new_game): b = Game(MockKaTrain(force_package_config=True), MockEngine(), move_tree=new_game) b.play(Move.from_gtp("B9", player="B")) b.play(Move.from_gtp("A3", player="B")) b.play(Move.from_gtp("A9", player="B")) assert 2 == len(self.nonempty_chains(b)) assert 3 == len(b.stones) assert 0 == len(b.prisoners)
def test_merge(self): b = Game(MockKaTrain(), MockEngine()) b.play(Move.from_gtp("B9", player="B")) b.play(Move.from_gtp("A3", player="B")) b.play(Move.from_gtp("A9", player="B")) assert 2 == len(self.nonempty_chains(b)) assert 3 == len(b.stones) assert 0 == len(b.prisoners)
def test_capture(self, new_game): b = Game(MockKaTrain(force_package_config=True), MockEngine(), move_tree=new_game) b.play(Move.from_gtp("A2", player="B")) b.play(Move.from_gtp("B1", player="W")) b.play(Move.from_gtp("A1", player="W")) b.play(Move.from_gtp("C1", player="B")) assert 3 == len(self.nonempty_chains(b)) assert 4 == len(b.stones) assert 0 == len(b.prisoners) b.play(Move.from_gtp("B2", player="B")) assert 2 == len(self.nonempty_chains(b)) assert 3 == len(b.stones) assert 2 == len(b.prisoners) b.play(Move.from_gtp("B1", player="B")) with pytest.raises(IllegalMoveException, match="Single stone suicide"): b.play(Move.from_gtp("A1", player="W")) assert 1 == len(self.nonempty_chains(b)) assert 4 == len(b.stones) assert 2 == len(b.prisoners)
def test_capture(self): b = Game(MockKaTrain(), MockEngine()) b.play(Move.from_gtp("A2", player="B")) b.play(Move.from_gtp("B1", player="W")) b.play(Move.from_gtp("A1", player="W")) b.play(Move.from_gtp("C1", player="B")) assert 3 == len(self.nonempty_chains(b)) assert 4 == len(b.stones) assert 0 == len(b.prisoners) b.play(Move.from_gtp("B2", player="B")) assert 2 == len(self.nonempty_chains(b)) assert 3 == len(b.stones) assert 2 == len(b.prisoners) b.play(Move.from_gtp("B1", player="B")) with pytest.raises(IllegalMoveException) as exc: b.play(Move.from_gtp("A1", player="W")) assert "Suicide" in str(exc.value) assert 1 == len(self.nonempty_chains(b)) assert 4 == len(b.stones) assert 2 == len(b.prisoners)
def test_snapback(self, new_game): b = Game(MockKaTrain(force_package_config=True), MockEngine(), move_tree=new_game) for move in ["C1", "D1", "E1", "C2", "D3", "E4", "F2", "F3", "F4"]: b.play(Move.from_gtp(move, player="B")) for move in ["D2", "E2", "C3", "D4", "C4"]: b.play(Move.from_gtp(move, player="W")) assert 5 == len(self.nonempty_chains(b)) assert 14 == len(b.stones) assert 0 == len(b.prisoners) b.play(Move.from_gtp("E3", player="W")) assert 4 == len(self.nonempty_chains(b)) assert 14 == len(b.stones) assert 1 == len(b.prisoners) b.play(Move.from_gtp("D3", player="B")) assert 4 == len(self.nonempty_chains(b)) assert 12 == len(b.stones) assert 4 == len(b.prisoners)
def test_suicide(self): rulesets_to_test = BaseEngine.RULESETS_ABBR + [('{"suicide":true}', ""), ('{"suicide":false}', "")] for shortrule, _ in rulesets_to_test: new_game = GameNode(properties={"SZ": 19, "RU": shortrule}) b = Game(MockKaTrain(force_package_config=True), MockEngine(), move_tree=new_game) b.play(Move.from_gtp("A18", player="B")) b.play(Move.from_gtp("B18", player="B")) b.play(Move.from_gtp("C19", player="B")) b.play(Move.from_gtp("A19", player="W")) assert 4 == len(b.stones) assert 0 == len(b.prisoners) if shortrule in ["tt", "nz", '{"suicide":true}']: b.play(Move.from_gtp("B19", player="W")) assert 3 == len(b.stones) assert 2 == len(b.prisoners) else: with pytest.raises(IllegalMoveException, match="Suicide"): b.play(Move.from_gtp("B19", player="W")) assert 4 == len(b.stones) assert 0 == len(b.prisoners)
class KaTrainGui(Screen, KaTrainBase): """Top level class responsible for tying everything together""" zen = NumericProperty(0) controls = ObjectProperty(None) def __init__(self, **kwargs): super().__init__(**kwargs) self.engine = None self.new_game_popup = None self.fileselect_popup = None self.config_popup = None self.ai_settings_popup = None self.teacher_settings_popup = None self.timer_settings_popup = None 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) def log(self, message, level=OUTPUT_INFO): super().log(message, level) if level == OUTPUT_KATAGO_STDERR and "ERROR" not in self.controls.status.text: if "starting" in message.lower(): self.controls.set_status(f"KataGo engine starting...", STATUS_INFO) if message.startswith("Tuning"): self.controls.set_status( f"KataGo is tuning settings for first startup, please wait." + message, STATUS_INFO ) return if "ready" in message.lower(): self.controls.set_status(f"KataGo engine ready.", STATUS_INFO) if ( level == OUTPUT_ERROR or (level == OUTPUT_KATAGO_STDERR and "error" in message.lower() and "tuning" not in message.lower()) ) and getattr(self, "controls", None): self.controls.set_status(f"ERROR: {message}", STATUS_ERROR) def animate_pondering(self, *_args): if not self.idle_analysis: self.board_controls.engine_status_pondering = -1 else: self.board_controls.engine_status_pondering += 5 @property def play_analyze_mode(self): return self.play_mode.mode def toggle_continuous_analysis(self): self.idle_analysis = not self.idle_analysis if not self.idle_analysis: self.controls.set_status("", STATUS_INFO) self.update_state() def start(self): if self.engine: return 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() def update_gui(self, cn, redraw_board=False): # Handle prisoners and next player display prisoners = self.game.prisoner_count top, bot = [w.__self__ for w in self.board_controls.circles] # no weakref if self.next_player_info.player == "W": top, bot = bot, top self.controls.players["W"].active = True self.controls.players["B"].active = False else: self.controls.players["W"].active = False self.controls.players["B"].active = True self.board_controls.mid_circles_container.clear_widgets() self.board_controls.mid_circles_container.add_widget(bot) self.board_controls.mid_circles_container.add_widget(top) self.board_controls.branch.disabled = not cn.parent or len(cn.parent.children) <= 1 self.controls.players["W"].captures = prisoners["W"] self.controls.players["B"].captures = prisoners["B"] # 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 = ENGINE_DOWN_COL elif len(self.engine.queries) == 0: self.board_controls.engine_status_col = ENGINE_READY_COL else: self.board_controls.engine_status_col = ENGINE_BUSY_COL # redraw board/stones if redraw_board: self.board_gui.draw_board() self.board_gui.redraw_board_contents_trigger() self.controls.update_evaluation() self.controls.update_timer(1) # update move tree self.controls.move_tree.current_node = self.game.current_node def update_state(self, redraw_board=False): # redirect to message queue thread self("update_state", redraw_board=redraw_board) def _do_update_state( self, redraw_board=False ): # is called after every message and on receiving analyses and config changes # AI and Trainer/auto-undo handlers if not self.game or not self.game.current_node: return cn = self.game.current_node last_player, next_player = self.players_info[cn.player], self.players_info[cn.next_player] if self.play_analyze_mode == MODE_PLAY and self.nav_drawer.state != "open" and self.popup_open is None: teaching_undo = cn.player and last_player.being_taught and cn.parent if ( teaching_undo and cn.analysis_ready and cn.parent.analysis_ready and not cn.children and not self.game.end_result ): self.game.analyze_undo(cn) # not via message loop if ( cn.analysis_ready and next_player.ai and not cn.children and not self.game.end_result and not (teaching_undo and cn.auto_undo is None) ): # 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) if len(self.engine.queries) == 0 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? def update_player(self, bw, **kwargs): super().update_player(bw, **kwargs) if self.controls: self.controls.update_players() self.update_state() for player_setup_block in PlayerSetupBlock.INSTANCES: player_setup_block.update_players(bw, self.players_info[bw]) def set_note(self, note): self.game.current_node.note = note def _message_loop_thread(self): while True: game, msg, args, kwargs = self.message_queue.get() try: self.log(f"Message Loop Received {msg}: {args} for Game {game}", OUTPUT_EXTRA_DEBUG) if game != self.game.game_id: self.log( f"Message skipped as it is outdated (current game is {self.game.game_id}", OUTPUT_EXTRA_DEBUG ) continue fn = getattr(self, f"_do_{msg.replace('-','_')}") fn(*args, **kwargs) if msg != "update_state": self._do_update_state() except Exception as exc: self.log(f"Exception in processing message {msg} {args}: {exc}", OUTPUT_ERROR) traceback.print_exc() def __call__(self, message, *args, **kwargs): if self.game: if message.endswith("popup"): # gui code needs to run in main kivy thread. fn = getattr(self, f"_do_{message.replace('-', '_')}") Clock.schedule_once(lambda _dt: fn(*args, **kwargs), -1) else: # game related actions self.message_queue.put([self.game.game_id, message, args, kwargs]) def _do_new_game(self, move_tree=None, analyze_fast=False): self.idle_analysis = False mode = self.play_analyze_mode if (move_tree is not None and mode == MODE_PLAY) or (move_tree is None and mode == MODE_ANALYZE): 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) if move_tree: for bw, player_info in self.players_info.items(): player_info.player_type = PLAYER_HUMAN player_info.player_subtype = PLAYING_NORMAL player_info.sgf_rank = move_tree.root.get_property(bw + "R") player_info.calculated_rank = None player_info.name = move_tree.root.get_property("P" + bw) self.update_player(bw) self.controls.graph.initialize_from_game(self.game.root) # self.controls.rank_graph.initialize_from_game(self.game.root) self.update_state(redraw_board=True) def _do_ai_move(self, node=None): if node is None or self.game.current_node == node: mode = self.next_player_info.strategy settings = self.config(f"ai/{mode}") if settings is not None: generate_ai_move(self.game, mode, settings) else: self.log(f"AI Mode {mode} not found!", OUTPUT_ERROR) def _do_undo(self, n_times=1): if n_times == "smart": n_times = 1 if self.play_analyze_mode == MODE_PLAY and self.last_player_info.ai and self.next_player_info.human: n_times = 2 self.board_gui.animating_pv = None self.game.undo(n_times) def _do_resign(self): self.game.current_node.end_state = f"{self.game.current_node.player}+R" def _do_redo(self, n_times=1): self.board_gui.animating_pv = None self.game.redo(n_times) def _do_cycle_children(self, *args): self.board_gui.animating_pv = None self.game.cycle_children(*args) def _do_switch_branch(self, *args): self.board_gui.animating_pv = None self.controls.move_tree.switch_branch(*args) def _do_play(self, coords): self.board_gui.animating_pv = None try: self.game.play(Move(coords, player=self.next_player_info.player)) except IllegalMoveException as e: self.controls.set_status(f"Illegal Move: {str(e)}", STATUS_ERROR) def _do_analyze_extra(self, mode, **kwargs): self.game.analyze_extra(mode, **kwargs) def _do_new_game_popup(self): self.controls.timer.paused = True if not self.new_game_popup: self.new_game_popup = I18NPopup( title_key="New Game title", size=[dp(800), dp(800)], content=NewGamePopup(self) ).__self__ self.new_game_popup.content.popup = self.new_game_popup self.new_game_popup.open() def _do_timer_popup(self): self.controls.timer.paused = True if not self.timer_settings_popup: self.timer_settings_popup = I18NPopup( title_key="timer settings", size=[dp(350), dp(400)], content=ConfigTimerPopup(self) ).__self__ self.timer_settings_popup.content.popup = self.timer_settings_popup self.timer_settings_popup.open() def _do_teacher_popup(self): self.controls.timer.paused = True if not self.teacher_settings_popup: self.teacher_settings_popup = I18NPopup( title_key="teacher settings", size=[dp(800), dp(750)], content=ConfigTeacherPopup(self) ).__self__ self.teacher_settings_popup.content.popup = self.teacher_settings_popup self.teacher_settings_popup.open() def _do_config_popup(self): self.controls.timer.paused = True if not self.config_popup: self.config_popup = I18NPopup( title_key="general settings title", size=[dp(1200), dp(950)], content=ConfigPopup(self) ).__self__ self.config_popup.content.popup = self.config_popup self.config_popup.open() def _do_ai_popup(self): self.controls.timer.paused = True if not self.ai_settings_popup: self.ai_settings_popup = I18NPopup( title_key="ai settings", size=[dp(600), dp(650)], content=ConfigAIPopup(self) ).__self__ self.ai_settings_popup.content.popup = self.ai_settings_popup self.ai_settings_popup.open() def load_sgf_file(self, file, fast=False, rewind=False): try: move_tree = KaTrainSGF.parse_file(file) except ParseError as e: self.log(i18n._("Failed to load SGF").format(error=e), OUTPUT_ERROR) return self._do_new_game(move_tree=move_tree, analyze_fast=fast) if not rewind: self.game.redo(999) def _do_analyze_sgf_popup(self): if not self.fileselect_popup: popup_contents = LoadSGFPopup() popup_contents.filesel.path = os.path.abspath(os.path.expanduser(self.config("general/sgf_load", "."))) self.fileselect_popup = I18NPopup( title_key="load sgf title", size=[dp(1200), dp(800)], content=popup_contents ).__self__ def readfile(*_args): filename = popup_contents.filesel.filename self.fileselect_popup.dismiss() path, file = os.path.split(filename) settings_path = self.config("general/sgf_load") if path != settings_path: self.log(f"Updating sgf load path default to {path}", OUTPUT_INFO) self._config["general"]["sgf_load"] = path self.save_config("general") self.load_sgf_file(filename, popup_contents.fast.active, popup_contents.rewind.active) popup_contents.filesel.on_success = readfile popup_contents.filesel.on_submit = readfile self.fileselect_popup.open() def _do_output_sgf(self): msg = self.game.write_sgf(self.config("general/sgf_save")) self.log(msg, OUTPUT_INFO) self.controls.set_status(msg, STATUS_INFO) def load_sgf_from_clipboard(self): clipboard = Clipboard.paste() if not clipboard: self.controls.set_status(f"Ctrl-V pressed but clipboard is empty.", STATUS_INFO) return try: move_tree = KaTrainSGF.parse_sgf(clipboard) except Exception as exc: self.controls.set_status( i18n._("Failed to import from clipboard").format(error=exc, contents=clipboard[:50]), STATUS_INFO ) return move_tree.nodes_in_tree[-1].analyze( self.engine, analyze_fast=False ) # speed up result for looking at end of game self._do_new_game(move_tree=move_tree, analyze_fast=True) self("redo", 999) self.log("Imported game from clipboard.", OUTPUT_INFO) def on_touch_up(self, touch): if ( self.board_gui.collide_point(*touch.pos) or self.board_controls.collide_point(*touch.pos) or self.controls.move_tree.collide_point(*touch.pos) ): if touch.is_mouse_scrolling: if touch.button == "scrollup": self("redo") elif touch.button == "scrolldown": self("undo") return super().on_touch_up(touch) @property def shortcuts(self): return { "q": self.analysis_controls.show_children, "w": self.analysis_controls.eval, "e": self.analysis_controls.hints, "t": self.analysis_controls.ownership, "r": self.analysis_controls.policy, "enter": ("ai-move",), "numpadenter": ("ai-move",), "a": ("analyze-extra", "extra"), "s": ("analyze-extra", "equalize"), "d": ("analyze-extra", "sweep"), "f": ("analyze-extra", "alternative"), "p": ("play", None), "down": ("switch-branch", 1), "up": ("switch-branch", -1), "f5": ("timer-popup",), "f6": ("teacher-popup",), "f7": ("ai-popup",), "f8": ("config-popup",), } @property def popup_open(self) -> Popup: app = App.get_running_app() first_child = app.root_window.children[0] return first_child if isinstance(first_child, Popup) else None def _on_keyboard_down(self, _keyboard, keycode, _text, modifiers): if self.controls.note.focus: return # when making notes, don't allow keyboard shortcuts popup = self.popup_open if popup: if keycode[1] in ["f5", "f6", "f7", "f8"]: # switch between popups popup.dismiss() return else: return shortcuts = self.shortcuts if keycode[1] == "tab": self.play_mode.switch_ui_mode() elif keycode[1] == "shift": self.nav_drawer.set_state("toggle") elif keycode[1] == "spacebar": self.toggle_continuous_analysis() elif keycode[1] == "b" and "ctrl" not in modifiers: self.controls.timer.paused = not self.controls.timer.paused elif keycode[1] in ["`", "~", "m"] and "ctrl" not in modifiers: self.zen = (self.zen + 1) % 3 elif keycode[1] in ["left", "z"]: self("undo", 1 + ("alt" in modifiers) * 9 + ("ctrl" in modifiers) * 999) elif keycode[1] in ["right", "x"]: self("redo", 1 + ("alt" in modifiers) * 9 + ("ctrl" in modifiers) * 999) elif keycode[1] == "n" and "ctrl" in modifiers: self("new-game-popup") elif keycode[1] == "l" and "ctrl" in modifiers: self("analyze-sgf-popup") elif keycode[1] == "s" and "ctrl" in modifiers: self("output-sgf") elif keycode[1] == "c" and "ctrl" in modifiers: Clipboard.copy(self.game.root.sgf()) self.controls.set_status(i18n._("Copied SGF to clipboard."), STATUS_INFO) elif keycode[1] == "v" and "ctrl" in modifiers: self.load_sgf_from_clipboard() elif keycode[1] in shortcuts.keys() and "ctrl" not in modifiers: shortcut = shortcuts[keycode[1]] if isinstance(shortcut, Widget): shortcut.trigger_action(duration=0) else: self(*shortcut) return True
def test_ko(self, new_game): b = Game(MockKaTrain(force_package_config=True), MockEngine(), move_tree=new_game) for move in ["A2", "B1"]: b.play(Move.from_gtp(move, player="B")) for move in ["B2", "C1"]: b.play(Move.from_gtp(move, player="W")) b.play(Move.from_gtp("A1", player="W")) assert 4 == len(self.nonempty_chains(b)) assert 4 == len(b.stones) assert 1 == len(b.prisoners) with pytest.raises(IllegalMoveException) as exc: b.play(Move.from_gtp("B1", player="B")) assert "Ko" in str(exc.value) b.play(Move.from_gtp("B1", player="B"), ignore_ko=True) assert 2 == len(b.prisoners) with pytest.raises(IllegalMoveException) as exc: b.play(Move.from_gtp("A1", player="W")) b.play(Move.from_gtp("F1", player="W")) b.play(Move(coords=None, player="B")) b.play(Move.from_gtp("A1", player="W")) assert 3 == len(b.prisoners)
def generate_ai_move(game: Game, ai_mode: str, ai_settings: Dict) -> Tuple[Move, GameNode]: cn = game.current_node while not cn.analysis_ready: time.sleep(0.01) game.engines[cn.next_player].check_alive(exception_if_dead=True) ai_thoughts = "" if (ai_mode in AI_STRATEGIES_POLICY) and cn.policy: # pure policy based move policy_moves = cn.policy_ranking pass_policy = cn.policy[-1] # dont make it jump around for the last few sensible non pass moves top_5_pass = any([polmove[1].is_pass for polmove in policy_moves[:5]]) size = game.board_size policy_grid = var_to_grid(cn.policy, size) # type: List[List[float]] top_policy_move = policy_moves[0][1] ai_thoughts += f"Using policy based strategy, base top 5 moves are {fmt_moves(policy_moves[:5])}. " if (ai_mode == AI_POLICY and cn.depth <= ai_settings["opening_moves"] ) or (ai_mode in [AI_LOCAL, AI_TENUKI] and not (cn.move and cn.move.coords)): ai_mode = AI_WEIGHTED ai_thoughts += f"Strategy override, using policy-weighted strategy instead. " ai_settings = { "pick_override": 0.9, "weaken_fac": 1, "lower_bound": 0.02 } if top_5_pass: aimove = top_policy_move ai_thoughts += "Playing top one because one of them is pass." elif ai_mode == AI_POLICY: aimove = top_policy_move ai_thoughts += f"Playing top policy move {aimove.gtp()}." else: # weighted or pick-based legal_policy_moves = [(pol, mv) for pol, mv in policy_moves if not mv.is_pass and pol > 0] board_squares = size[0] * size[1] if ai_mode == AI_RANK: # calibrated, override from 0.8 at start to ~0.4 at full board override = 0.8 * ( 1 - 0.5 * (board_squares - len(legal_policy_moves)) / board_squares) else: override = ai_settings["pick_override"] if policy_moves[0][0] > override: aimove = top_policy_move ai_thoughts += f"Top policy move has weight > {override:.1%}, so overriding other strategies." elif ai_mode == AI_WEIGHTED: aimove, ai_thoughts = policy_weighted_move( policy_moves, ai_settings["lower_bound"], ai_settings["weaken_fac"]) elif ai_mode in AI_STRATEGIES_PICK: if ai_mode != AI_RANK: n_moves = int(ai_settings["pick_frac"] * len(legal_policy_moves) + ai_settings["pick_n"]) else: n_moves = int( round( board_squares / 361 * 10**(-0.05737 * ai_settings["kyu_rank"] + 1.9482))) if ai_mode in [ AI_INFLUENCE, AI_TERRITORY, AI_LOCAL, AI_TENUKI ]: if cn.depth > ai_settings["endgame"] * board_squares: weighted_coords = [(pol, 1, *mv.coords) for pol, mv in legal_policy_moves] x_ai_thoughts = ( f"Generated equal weights as move number >= {ai_settings['endgame'] * size[0] * size[1]}. " ) n_moves = int( max(n_moves, 0.5 * len(legal_policy_moves))) elif ai_mode in [AI_INFLUENCE, AI_TERRITORY]: weighted_coords, x_ai_thoughts = generate_influence_territory_weights( ai_mode, ai_settings, policy_grid, size) else: # ai_mode in [AI_LOCAL, AI_TENUKI] weighted_coords, x_ai_thoughts = generate_local_tenuki_weights( ai_mode, ai_settings, policy_grid, cn, size) ai_thoughts += x_ai_thoughts else: # ai_mode in [AI_PICK, AI_RANK]: weighted_coords = [(policy_grid[y][x], 1, x, y) for x in range(size[0]) for y in range(size[1]) if policy_grid[y][x] > 0] pick_moves = weighted_selection_without_replacement( weighted_coords, n_moves) ai_thoughts += f"Picked {min(n_moves,len(weighted_coords))} random moves according to weights. " if pick_moves: new_top = [ (p, Move((x, y), player=cn.next_player)) for p, wt, x, y in heapq.nlargest(5, pick_moves) ] aimove = new_top[0][1] ai_thoughts += f"Top 5 among these were {fmt_moves(new_top)} and picked top {aimove.gtp()}. " if new_top[0][0] < pass_policy: ai_thoughts += f"But found pass ({pass_policy:.2%} to be higher rated than {aimove.gtp()} ({new_top[0][0]:.2%}) so will play top policy move instead." aimove = top_policy_move else: aimove = top_policy_move ai_thoughts += f"Pick policy strategy {ai_mode} failed to find legal moves, so is playing top policy move {aimove.gtp()}." else: raise ValueError(f"Unknown Policy-based AI mode {ai_mode}") else: # Engine based move candidate_ai_moves = cn.candidate_moves top_cand = Move.from_gtp(candidate_ai_moves[0]["move"], player=cn.next_player) if top_cand.is_pass: # don't play suicidal to balance score - pass when it's best aimove = top_cand ai_thoughts += f"Top move is pass, so passing regardless of strategy." else: if ai_mode == AI_JIGO: sign = cn.player_sign(cn.next_player) jigo_move = min( candidate_ai_moves, key=lambda move: abs(sign * move["scoreLead"] - ai_settings["target_score"])) aimove = Move.from_gtp(jigo_move["move"], player=cn.next_player) ai_thoughts += f"Jigo strategy found {len(candidate_ai_moves)} candidate moves (best {top_cand.gtp()}) and chose {aimove.gtp()} as closest to 0.5 point win" elif ai_mode == AI_SCORELOSS: c = ai_settings["strength"] moves = [( d["pointsLost"], math.exp(min(200, -c * max(0, d["pointsLost"]))), Move.from_gtp(d["move"], player=cn.next_player), ) for d in candidate_ai_moves] topmove = weighted_selection_without_replacement(moves, 1)[0] aimove = topmove[2] ai_thoughts += f"ScoreLoss strategy found {len(candidate_ai_moves)} candidate moves (best {top_cand.gtp()}) and chose {aimove.gtp()} (weight {topmove[1]:.3f}, point loss {topmove[0]:.1f}) based on score weights." else: if ai_mode != AI_DEFAULT: game.katrain.log( f"Unknown AI mode {ai_mode} or policy missing, using default.", OUTPUT_INFO) ai_thoughts += f"Strategy {ai_mode} not found or unexpected fallback." aimove = top_cand ai_thoughts += f"Default strategy found {len(candidate_ai_moves)} moves returned from the engine and chose {aimove.gtp()} as top move" game.katrain.log(f"AI thoughts: {ai_thoughts}", OUTPUT_DEBUG) played_node = game.play(aimove) played_node.ai_thoughts = ai_thoughts return aimove, played_node
def generate_ai_move(game: Game, ai_mode: str, ai_settings: Dict) -> Tuple[Move, GameNode]: cn = game.current_node if ai_mode == AI_HANDICAP: pda = ai_settings["pda"] if ai_settings["automatic"]: n_handicaps = len(game.root.get_list_property("AB", [])) MOVE_VALUE = 14 # could be rules dependent b_stones_advantage = max( n_handicaps - 1, 0) - (cn.komi - MOVE_VALUE / 2) / MOVE_VALUE pda = min(3, max( -3, -b_stones_advantage * (3 / 8))) # max PDA at 8 stone adv, normal 9 stone game is 8.46 handicap_analysis = request_ai_analysis( game, cn, { "playoutDoublingAdvantage": pda, "playoutDoublingAdvantagePla": "BLACK" }) if not handicap_analysis: game.katrain.log(f"Error getting handicap-based move", OUTPUT_ERROR) ai_mode = AI_DEFAULT while not cn.analysis_ready: time.sleep(0.01) game.engines[cn.next_player].check_alive(exception_if_dead=True) ai_thoughts = "" if (ai_mode in AI_STRATEGIES_POLICY) and cn.policy: # pure policy based move policy_moves = cn.policy_ranking pass_policy = cn.policy[-1] # dont make it jump around for the last few sensible non pass moves top_5_pass = any([polmove[1].is_pass for polmove in policy_moves[:5]]) size = game.board_size policy_grid = var_to_grid(cn.policy, size) # type: List[List[float]] top_policy_move = policy_moves[0][1] ai_thoughts += f"Using policy based strategy, base top 5 moves are {fmt_moves(policy_moves[:5])}. " if (ai_mode == AI_POLICY and cn.depth <= ai_settings["opening_moves"] ) or (ai_mode in [AI_LOCAL, AI_TENUKI] and not (cn.move and cn.move.coords)): ai_mode = AI_WEIGHTED ai_thoughts += f"Strategy override, using policy-weighted strategy instead. " ai_settings = { "pick_override": 0.9, "weaken_fac": 1, "lower_bound": 0.02 } if top_5_pass: aimove = top_policy_move ai_thoughts += "Playing top one because one of them is pass." elif ai_mode == AI_POLICY: aimove = top_policy_move ai_thoughts += f"Playing top policy move {aimove.gtp()}." else: # weighted or pick-based legal_policy_moves = [(pol, mv) for pol, mv in policy_moves if not mv.is_pass and pol > 0] board_squares = size[0] * size[1] if ai_mode == AI_RANK: # calibrated, override from 0.8 at start to ~0.4 at full board override = 0.8 * ( 1 - 0.5 * (board_squares - len(legal_policy_moves)) / board_squares) overridetwo = 0.85 + max(0, 0.02 * (ai_settings["kyu_rank"] - 8)) else: override = ai_settings["pick_override"] overridetwo = 1.0 if policy_moves[0][0] > override: aimove = top_policy_move ai_thoughts += f"Top policy move has weight > {override:.1%}, so overriding other strategies." elif policy_moves[0][0] + policy_moves[1][0] > overridetwo: aimove = top_policy_move ai_thoughts += ( f"Top two policy moves have cumulative weight > {overridetwo:.1%}, so overriding other strategies." ) elif ai_mode == AI_WEIGHTED: aimove, ai_thoughts = policy_weighted_move( policy_moves, ai_settings["lower_bound"], ai_settings["weaken_fac"]) elif ai_mode in AI_STRATEGIES_PICK: if ai_mode != AI_RANK: n_moves = max( 1, int(ai_settings["pick_frac"] * len(legal_policy_moves) + ai_settings["pick_n"])) else: orig_calib_avemodrank = 0.063015 + 0.7624 * board_squares / ( 10**(-0.05737 * ai_settings["kyu_rank"] + 1.9482)) norm_leg_moves = len(legal_policy_moves) / board_squares modified_calib_avemodrank = ( 0.3931 + 0.6559 * norm_leg_moves * math.exp( -1 * (3.002 * norm_leg_moves * norm_leg_moves - norm_leg_moves - 0.034889 * ai_settings["kyu_rank"] - 0.5097)**2) - 0.01093 * ai_settings["kyu_rank"]) * orig_calib_avemodrank n_moves = board_squares * norm_leg_moves / ( 1.31165 * (modified_calib_avemodrank + 1) - 0.082653) n_moves = max(1, round(n_moves)) if ai_mode in [ AI_INFLUENCE, AI_TERRITORY, AI_LOCAL, AI_TENUKI ]: if cn.depth > ai_settings["endgame"] * board_squares: weighted_coords = [(pol, 1, *mv.coords) for pol, mv in legal_policy_moves] x_ai_thoughts = ( f"Generated equal weights as move number >= {ai_settings['endgame'] * size[0] * size[1]}. " ) n_moves = int( max(n_moves, len(legal_policy_moves) // 2)) elif ai_mode in [AI_INFLUENCE, AI_TERRITORY]: weighted_coords, x_ai_thoughts = generate_influence_territory_weights( ai_mode, ai_settings, policy_grid, size) else: # ai_mode in [AI_LOCAL, AI_TENUKI] weighted_coords, x_ai_thoughts = generate_local_tenuki_weights( ai_mode, ai_settings, policy_grid, cn, size) ai_thoughts += x_ai_thoughts else: # ai_mode in [AI_PICK, AI_RANK]: weighted_coords = [(policy_grid[y][x], 1, x, y) for x in range(size[0]) for y in range(size[1]) if policy_grid[y][x] > 0] pick_moves = weighted_selection_without_replacement( weighted_coords, n_moves) ai_thoughts += f"Picked {min(n_moves,len(weighted_coords))} random moves according to weights. " if pick_moves: new_top = [ (p, Move((x, y), player=cn.next_player)) for p, wt, x, y in heapq.nlargest(5, pick_moves) ] aimove = new_top[0][1] ai_thoughts += f"Top 5 among these were {fmt_moves(new_top)} and picked top {aimove.gtp()}. " if new_top[0][0] < pass_policy: ai_thoughts += f"But found pass ({pass_policy:.2%} to be higher rated than {aimove.gtp()} ({new_top[0][0]:.2%}) so will play top policy move instead." aimove = top_policy_move else: aimove = top_policy_move ai_thoughts += f"Pick policy strategy {ai_mode} failed to find legal moves, so is playing top policy move {aimove.gtp()}." else: raise ValueError(f"Unknown Policy-based AI mode {ai_mode}") else: # Engine based move candidate_ai_moves = cn.candidate_moves if ai_mode == AI_HANDICAP: candidate_ai_moves = handicap_analysis["moveInfos"] top_cand = Move.from_gtp(candidate_ai_moves[0]["move"], player=cn.next_player) if top_cand.is_pass and ai_mode not in [ AI_DEFAULT, AI_HANDICAP ]: # don't play suicidal to balance score aimove = top_cand ai_thoughts += f"Top move is pass, so passing regardless of strategy. " else: if ai_mode == AI_JIGO: sign = cn.player_sign(cn.next_player) jigo_move = min( candidate_ai_moves, key=lambda move: abs(sign * move["scoreLead"] - ai_settings["target_score"])) aimove = Move.from_gtp(jigo_move["move"], player=cn.next_player) ai_thoughts += f"Jigo strategy found {len(candidate_ai_moves)} candidate moves (best {top_cand.gtp()}) and chose {aimove.gtp()} as closest to 0.5 point win" elif ai_mode == AI_SCORELOSS: c = ai_settings["strength"] moves = [( d["pointsLost"], math.exp(min(200, -c * max(0, d["pointsLost"]))), Move.from_gtp(d["move"], player=cn.next_player), ) for d in candidate_ai_moves] topmove = weighted_selection_without_replacement(moves, 1)[0] aimove = topmove[2] ai_thoughts += f"ScoreLoss strategy found {len(candidate_ai_moves)} candidate moves (best {top_cand.gtp()}) and chose {aimove.gtp()} (weight {topmove[1]:.3f}, point loss {topmove[0]:.1f}) based on score weights." else: if ai_mode not in [AI_DEFAULT, AI_HANDICAP]: game.katrain.log( f"Unknown AI mode {ai_mode} or policy missing, using default.", OUTPUT_INFO) ai_thoughts += f"Strategy {ai_mode} not found or unexpected fallback." aimove = top_cand if ai_mode == AI_HANDICAP: ai_thoughts += f"Handicap strategy found {len(candidate_ai_moves)} moves returned from the engine and chose {aimove.gtp()} as top move. PDA based score {cn.format_score(handicap_analysis['rootInfo']['scoreLead'])} and win rate {cn.format_winrate(handicap_analysis['rootInfo']['winrate'])}" else: ai_thoughts += f"Default strategy found {len(candidate_ai_moves)} moves returned from the engine and chose {aimove.gtp()} as top move" game.katrain.log(f"AI thoughts: {ai_thoughts}", OUTPUT_DEBUG) played_node = game.play(aimove) played_node.ai_thoughts = ai_thoughts return aimove, played_node
def generate_ai_move(game: Game, ai_mode: str, ai_settings: Dict) -> Tuple[Move, GameNode]: cn = game.current_node if ai_mode == AI_HANDICAP: pda = ai_settings["pda"] if ai_settings["automatic"]: n_handicaps = len(game.root.get_list_property("AB", [])) MOVE_VALUE = 14 # could be rules dependent b_stones_advantage = max( n_handicaps - 1, 0) - (cn.komi - MOVE_VALUE / 2) / MOVE_VALUE pda = min(3, max( -3, -b_stones_advantage * (3 / 8))) # max PDA at 8 stone adv, normal 9 stone game is 8.46 handicap_analysis = request_ai_analysis( game, cn, { "playoutDoublingAdvantage": pda, "playoutDoublingAdvantagePla": "BLACK" }) if not handicap_analysis: game.katrain.log(f"Error getting handicap-based move", OUTPUT_ERROR) ai_mode = AI_DEFAULT while not cn.analysis_complete: time.sleep(0.01) game.engines[cn.next_player].check_alive(exception_if_dead=True) ai_thoughts = "" if (ai_mode in AI_STRATEGIES_POLICY) and cn.policy: # pure policy based move policy_moves = cn.policy_ranking pass_policy = cn.policy[-1] # dont make it jump around for the last few sensible non pass moves top_5_pass = any([polmove[1].is_pass for polmove in policy_moves[:5]]) size = game.board_size policy_grid = var_to_grid(cn.policy, size) # type: List[List[float]] top_policy_move = policy_moves[0][1] ai_thoughts += f"Using policy based strategy, base top 5 moves are {fmt_moves(policy_moves[:5])}. " if (ai_mode == AI_POLICY and cn.depth <= ai_settings["opening_moves"] ) or (ai_mode in [AI_LOCAL, AI_TENUKI] and not (cn.move and cn.move.coords)): ai_mode = AI_WEIGHTED ai_thoughts += f"Strategy override, using policy-weighted strategy instead. " ai_settings = { "pick_override": 0.9, "weaken_fac": 1, "lower_bound": 0.02 } if top_5_pass: aimove = top_policy_move ai_thoughts += "Playing top one because one of them is pass." elif ai_mode == AI_POLICY: aimove = top_policy_move ai_thoughts += f"Playing top policy move {aimove.gtp()}." else: # weighted or pick-based legal_policy_moves = [(pol, mv) for pol, mv in policy_moves if not mv.is_pass and pol > 0] board_squares = size[0] * size[1] if ai_mode == AI_RANK: # calibrated, override from 0.8 at start to ~0.4 at full board override = 0.8 * ( 1 - 0.5 * (board_squares - len(legal_policy_moves)) / board_squares) overridetwo = 0.85 + max(0, 0.02 * (ai_settings["kyu_rank"] - 8)) else: override = ai_settings["pick_override"] overridetwo = 1.0 if policy_moves[0][0] > override: aimove = top_policy_move ai_thoughts += f"Top policy move has weight > {override:.1%}, so overriding other strategies." elif policy_moves[0][0] + policy_moves[1][0] > overridetwo: aimove = top_policy_move ai_thoughts += ( f"Top two policy moves have cumulative weight > {overridetwo:.1%}, so overriding other strategies." ) elif ai_mode == AI_WEIGHTED: aimove, ai_thoughts = policy_weighted_move( policy_moves, ai_settings["lower_bound"], ai_settings["weaken_fac"]) elif ai_mode in AI_STRATEGIES_PICK: if ai_mode != AI_RANK: n_moves = max( 1, int(ai_settings["pick_frac"] * len(legal_policy_moves) + ai_settings["pick_n"])) else: orig_calib_avemodrank = 0.063015 + 0.7624 * board_squares / ( 10**(-0.05737 * ai_settings["kyu_rank"] + 1.9482)) norm_leg_moves = len(legal_policy_moves) / board_squares modified_calib_avemodrank = ( 0.3931 + 0.6559 * norm_leg_moves * math.exp( -1 * (3.002 * norm_leg_moves * norm_leg_moves - norm_leg_moves - 0.034889 * ai_settings["kyu_rank"] - 0.5097)**2) - 0.01093 * ai_settings["kyu_rank"]) * orig_calib_avemodrank n_moves = board_squares * norm_leg_moves / ( 1.31165 * (modified_calib_avemodrank + 1) - 0.082653) n_moves = max(1, round(n_moves)) if ai_mode in [ AI_INFLUENCE, AI_TERRITORY, AI_LOCAL, AI_TENUKI ]: if cn.depth > ai_settings["endgame"] * board_squares: weighted_coords = [(pol, 1, *mv.coords) for pol, mv in legal_policy_moves] x_ai_thoughts = ( f"Generated equal weights as move number >= {ai_settings['endgame'] * size[0] * size[1]}. " ) n_moves = int( max(n_moves, len(legal_policy_moves) // 2)) elif ai_mode in [AI_INFLUENCE, AI_TERRITORY]: weighted_coords, x_ai_thoughts = generate_influence_territory_weights( ai_mode, ai_settings, policy_grid, size) else: # ai_mode in [AI_LOCAL, AI_TENUKI] weighted_coords, x_ai_thoughts = generate_local_tenuki_weights( ai_mode, ai_settings, policy_grid, cn, size) ai_thoughts += x_ai_thoughts else: # ai_mode in [AI_PICK, AI_RANK]: weighted_coords = [(policy_grid[y][x], 1, x, y) for x in range(size[0]) for y in range(size[1]) if policy_grid[y][x] > 0] pick_moves = weighted_selection_without_replacement( weighted_coords, n_moves) ai_thoughts += f"Picked {min(n_moves,len(weighted_coords))} random moves according to weights. " if pick_moves: new_top = [ (p, Move((x, y), player=cn.next_player)) for p, wt, x, y in heapq.nlargest(5, pick_moves) ] aimove = new_top[0][1] ai_thoughts += f"Top 5 among these were {fmt_moves(new_top)} and picked top {aimove.gtp()}. " if new_top[0][0] < pass_policy: ai_thoughts += f"But found pass ({pass_policy:.2%} to be higher rated than {aimove.gtp()} ({new_top[0][0]:.2%}) so will play top policy move instead." aimove = top_policy_move else: aimove = top_policy_move ai_thoughts += f"Pick policy strategy {ai_mode} failed to find legal moves, so is playing top policy move {aimove.gtp()}." else: raise ValueError(f"Unknown Policy-based AI mode {ai_mode}") else: # Engine based move candidate_ai_moves = cn.candidate_moves if ai_mode == AI_HANDICAP: candidate_ai_moves = handicap_analysis["moveInfos"] top_cand = Move.from_gtp(candidate_ai_moves[0]["move"], player=cn.next_player) if top_cand.is_pass and ai_mode not in [ AI_DEFAULT, AI_HANDICAP, ]: # don't play suicidal to balance score aimove = top_cand ai_thoughts += f"Top move is pass, so passing regardless of strategy. " else: if ai_mode == AI_JIGO: sign = cn.player_sign(cn.next_player) jigo_move = min( candidate_ai_moves, key=lambda move: abs(sign * move["scoreLead"] - ai_settings["target_score"])) aimove = Move.from_gtp(jigo_move["move"], player=cn.next_player) ai_thoughts += f"Jigo strategy found {len(candidate_ai_moves)} candidate moves (best {top_cand.gtp()}) and chose {aimove.gtp()} as closest to 0.5 point win" elif ai_mode == AI_SCORELOSS: c = ai_settings["strength"] moves = [( d["pointsLost"], math.exp(min(200, -c * max(0, d["pointsLost"]))), Move.from_gtp(d["move"], player=cn.next_player), ) for d in candidate_ai_moves] topmove = weighted_selection_without_replacement(moves, 1)[0] aimove = topmove[2] ai_thoughts += f"ScoreLoss strategy found {len(candidate_ai_moves)} candidate moves (best {top_cand.gtp()}) and chose {aimove.gtp()} (weight {topmove[1]:.3f}, point loss {topmove[0]:.1f}) based on score weights." elif ai_mode in [AI_SIMPLE_OWNERSHIP, AI_SETTLE_STONES]: stones_with_player = {(*s.coords, s.player) for s in game.stones} next_player_sign = cn.player_sign(cn.next_player) if ai_mode == AI_SIMPLE_OWNERSHIP: def settledness(d, player_sign, player): return sum([ abs(o) for o in d["ownership"] if player_sign * o > 0 ]) else: board_size_x, board_size_y = game.board_size def settledness(d, player_sign, player): ownership_grid = var_to_grid( d["ownership"], (board_size_x, board_size_y)) return sum([ abs(ownership_grid[s.coords[0]][s.coords[1]]) for s in game.stones if s.player == player ]) def is_attachment(move): if move.is_pass: return False attach_opponent_stones = sum( (move.coords[0] + dx, move.coords[1] + dy, cn.player) in stones_with_player for dx in [-1, 0, 1] for dy in [-1, 0, 1] if abs(dx) + abs(dy) == 1) nearby_own_stones = sum( (move.coords[0] + dx, move.coords[1] + dy, cn.next_player) in stones_with_player for dx in [-2, 0, 1, 2] for dy in [-2 - 1, 0, 1, 2] if abs(dx) + abs(dy) <= 2 # allows clamps/jumps ) return attach_opponent_stones >= 1 and nearby_own_stones == 0 def is_tenuki(d): return not d.is_pass and not any( not node or not node.move or node.move.is_pass or max( abs(last_c - cand_c) for last_c, cand_c in zip( node.move.coords, d.coords)) < 5 for node in [cn, cn.parent]) moves_with_settledness = sorted( [( move, settledness(d, next_player_sign, cn.next_player), settledness(d, -next_player_sign, cn.player), is_attachment(move), is_tenuki(move), d, ) for d in candidate_ai_moves if d["pointsLost"] < ai_settings["max_points_lost"] and "ownership" in d and ( d["order"] <= 1 or d["visits"] >= ai_settings.get("min_visits", 1)) for move in [Move.from_gtp(d["move"], player=cn.next_player)] if not (move.is_pass and d["pointsLost"] > 0.75)], key=lambda t: t[5]["pointsLost"] + ai_settings[ "attach_penalty"] * t[3] + ai_settings[ "tenuki_penalty"] * t[4] - ai_settings[ "settled_weight"] * (t[1] + ai_settings["opponent_fac"] * t[2]), ) if moves_with_settledness: cands = [ f"{move.gtp()} ({d['pointsLost']:.1f} pt lost, {d['visits']} visits, {settled:.1f} settledness, {oppsettled:.1f} opponent settledness{', attachment' if isattach else ''}{', tenuki' if istenuki else ''})" for move, settled, oppsettled, isattach, istenuki, d in moves_with_settledness[:5] ] ai_thoughts += f"{ai_mode} strategy. Top 5 Candidates {', '.join(cands)} " aimove = moves_with_settledness[0][0] else: raise (Exception( "No moves found - are you using an older KataGo with no per-move ownership info?" )) else: if ai_mode not in [AI_DEFAULT, AI_HANDICAP]: game.katrain.log( f"Unknown AI mode {ai_mode} or policy missing, using default.", OUTPUT_INFO) ai_thoughts += f"Strategy {ai_mode} not found or unexpected fallback." aimove = top_cand if ai_mode == AI_HANDICAP: ai_thoughts += f"Handicap strategy found {len(candidate_ai_moves)} moves returned from the engine and chose {aimove.gtp()} as top move. PDA based score {cn.format_score(handicap_analysis['rootInfo']['scoreLead'])} and win rate {cn.format_winrate(handicap_analysis['rootInfo']['winrate'])}" else: ai_thoughts += f"Default strategy found {len(candidate_ai_moves)} moves returned from the engine and chose {aimove.gtp()} as top move" game.katrain.log(f"AI thoughts: {ai_thoughts}", OUTPUT_DEBUG) played_node = game.play(aimove) played_node.ai_thoughts = ai_thoughts return aimove, played_node
class KaTrainGui(Screen, KaTrainBase): """Top level class responsible for tying everything together""" zen = NumericProperty(0) controls = ObjectProperty(None) def __init__(self, **kwargs): super().__init__(**kwargs) self.engine = None self.new_game_popup = None self.fileselect_popup = None self.config_popup = None self.ai_settings_popup = None self.teacher_settings_popup = None self.timer_settings_popup = None self.idle_analysis = False self.message_queue = Queue() self.last_key_down = None self.last_focus_event = 0 def log(self, message, level=OUTPUT_INFO): super().log(message, level) if level == OUTPUT_KATAGO_STDERR and "ERROR" not in self.controls.status.text: if "starting" in message.lower(): self.controls.set_status("KataGo engine starting...", STATUS_INFO) if message.startswith("Tuning"): self.controls.set_status( "KataGo is tuning settings for first startup, please wait." + message, STATUS_INFO ) return if "ready" in message.lower(): self.controls.set_status("KataGo engine ready.", STATUS_INFO) if ( level == OUTPUT_ERROR or (level == OUTPUT_KATAGO_STDERR and "error" in message.lower() and "tuning" not in message.lower()) ) and getattr(self, "controls", None): self.controls.set_status(f"ERROR: {message}", STATUS_ERROR) def animate_pondering(self, *_args): if not self.idle_analysis: self.board_controls.engine_status_pondering = -1 else: self.board_controls.engine_status_pondering += 5 @property def play_analyze_mode(self): return self.play_mode.mode def toggle_continuous_analysis(self): if self.idle_analysis: self.controls.set_status("", STATUS_INFO) self.idle_analysis = not self.idle_analysis self.update_state() def start(self): if self.engine: return 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() 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 prisoners = self.game.prisoner_count top, bot = [w.__self__ for w in self.board_controls.circles] # no weakref if self.next_player_info.player == "W": top, bot = bot, top self.controls.players["W"].active = True self.controls.players["B"].active = False else: self.controls.players["W"].active = False self.controls.players["B"].active = True self.board_controls.mid_circles_container.clear_widgets() self.board_controls.mid_circles_container.add_widget(bot) self.board_controls.mid_circles_container.add_widget(top) self.controls.players["W"].captures = prisoners["W"] self.controls.players["B"].captures = prisoners["B"] # 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 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 = self.engine.queries_remaining() # redraw board/stones if redraw_board: self.board_gui.draw_board() self.board_gui.redraw_board_contents_trigger() self.controls.update_evaluation() self.controls.update_timer(1) # update move tree self.controls.move_tree.current_node = self.game.current_node def update_state(self, redraw_board=False): # redirect to message queue thread self("update_state", redraw_board=redraw_board) def _do_update_state( self, redraw_board=False ): # is called after every message and on receiving analyses and config changes # AI and Trainer/auto-undo handlers if not self.game or not self.game.current_node: return cn = self.game.current_node last_player, next_player = self.players_info[cn.player], self.players_info[cn.next_player] if self.play_analyze_mode == MODE_PLAY and self.nav_drawer.state != "open" and self.popup_open is None: teaching_undo = cn.player and last_player.being_taught and cn.parent if ( teaching_undo and cn.analysis_complete and cn.parent.analysis_complete and not cn.children and not self.game.end_result ): self.game.analyze_undo(cn) # not via message loop if ( cn.analysis_complete and next_player.ai and not cn.children and not self.game.end_result and not (teaching_undo and cn.auto_undo is None) ): # 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 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? def update_player(self, bw, **kwargs): super().update_player(bw, **kwargs) if self.game: sgf_name = self.game.root.get_property("P" + bw) self.players_info[bw].name = None if not sgf_name or SGF_INTERNAL_COMMENTS_MARKER in sgf_name else sgf_name if self.controls: self.controls.update_players() self.update_state() for player_setup_block in PlayerSetupBlock.INSTANCES: player_setup_block.update_player_info(bw, self.players_info[bw]) def set_note(self, note): self.game.current_node.note = note # The message loop is here to make sure moves happen in the right order, and slow operations don't hang the GUI def _message_loop_thread(self): while True: game, msg, args, kwargs = self.message_queue.get() try: self.log(f"Message Loop Received {msg}: {args} for Game {game}", OUTPUT_EXTRA_DEBUG) if game != self.game.game_id: self.log( f"Message skipped as it is outdated (current game is {self.game.game_id}", OUTPUT_EXTRA_DEBUG ) continue fn = getattr(self, f"_do_{msg.replace('-','_')}") fn(*args, **kwargs) if msg != "update_state": self._do_update_state() except Exception as exc: self.log(f"Exception in processing message {msg} {args}: {exc}", OUTPUT_ERROR) traceback.print_exc() def __call__(self, message, *args, **kwargs): if self.game: if message.endswith("popup"): # gui code needs to run in main kivy thread. fn = getattr(self, f"_do_{message.replace('-', '_')}") Clock.schedule_once(lambda _dt: fn(*args, **kwargs), -1) else: # game related actions self.message_queue.put([self.game.game_id, message, args, kwargs]) def _do_new_game(self, move_tree=None, analyze_fast=False, sgf_filename=None): self.idle_analysis = False mode = self.play_analyze_mode if (move_tree is not None and mode == MODE_PLAY) or (move_tree is None and mode == MODE_ANALYZE): 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 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 player_info.player_subtype = PLAYING_NORMAL player_info.sgf_rank = move_tree.root.get_property(bw + "R") player_info.calculated_rank = None self.update_player(bw) self.controls.graph.initialize_from_game(self.game.root) self.update_state(redraw_board=True) def _do_insert_mode(self, mode="toggle"): self.game.set_insert_mode(mode) if self.play_analyze_mode != MODE_ANALYZE: self.play_mode.switch_ui_mode() def _do_ai_move(self, node=None): if node is None or self.game.current_node == node: mode = self.next_player_info.strategy settings = self.config(f"ai/{mode}") if settings is not None: generate_ai_move(self.game, mode, settings) else: self.log(f"AI Mode {mode} not found!", OUTPUT_ERROR) def _do_undo(self, n_times=1): if n_times == "smart": n_times = 1 if self.play_analyze_mode == MODE_PLAY and self.last_player_info.ai and self.next_player_info.human: n_times = 2 self.board_gui.animating_pv = None self.game.undo(n_times) def _do_resign(self): self.game.current_node.end_state = f"{self.game.current_node.player}+R" def _do_redo(self, n_times=1): self.board_gui.animating_pv = None self.game.redo(n_times) def _do_find_mistake(self, fn="redo"): self.board_gui.animating_pv = None getattr(self.game, fn)(9999, stop_on_mistake=self.config("trainer/eval_thresholds")[-4]) def _do_cycle_children(self, *args): self.board_gui.animating_pv = None self.game.cycle_children(*args) def _do_switch_branch(self, *args): self.board_gui.animating_pv = None self.controls.move_tree.switch_branch(*args) def _do_play(self, coords): self.board_gui.animating_pv = None try: self.game.play(Move(coords, player=self.next_player_info.player)) except IllegalMoveException as e: self.controls.set_status(f"Illegal Move: {str(e)}", STATUS_ERROR) def _do_analyze_extra(self, mode, **kwargs): self.game.analyze_extra(mode, **kwargs) def _do_play_to_end(self): self.game.play_to_end() def _do_select_box(self): self.controls.set_status(i18n._("analysis:region:start"), STATUS_INFO) self.board_gui.selecting_region_of_interest = True def _do_new_game_popup(self): self.controls.timer.paused = True if not self.new_game_popup: self.new_game_popup = I18NPopup( title_key="New Game title", size=[dp(800), dp(800)], content=NewGamePopup(self) ).__self__ self.new_game_popup.content.popup = self.new_game_popup self.new_game_popup.open() self.new_game_popup.content.update_from_current_game() def _do_timer_popup(self): self.controls.timer.paused = True if not self.timer_settings_popup: self.timer_settings_popup = I18NPopup( title_key="timer settings", size=[dp(600), dp(500)], content=ConfigTimerPopup(self) ).__self__ self.timer_settings_popup.content.popup = self.timer_settings_popup self.timer_settings_popup.open() def _do_teacher_popup(self): self.controls.timer.paused = True if not self.teacher_settings_popup: self.teacher_settings_popup = I18NPopup( title_key="teacher settings", size=[dp(800), dp(800)], content=ConfigTeacherPopup(self) ).__self__ self.teacher_settings_popup.content.popup = self.teacher_settings_popup self.teacher_settings_popup.open() def _do_config_popup(self): self.controls.timer.paused = True if not self.config_popup: self.config_popup = I18NPopup( title_key="general settings title", size=[dp(1200), dp(950)], content=ConfigPopup(self) ).__self__ self.config_popup.content.popup = self.config_popup self.config_popup.open() def _do_ai_popup(self): self.controls.timer.paused = True if not self.ai_settings_popup: self.ai_settings_popup = I18NPopup( title_key="ai settings", size=[dp(750), dp(750)], content=ConfigAIPopup(self) ).__self__ self.ai_settings_popup.content.popup = self.ai_settings_popup self.ai_settings_popup.open() def load_sgf_file(self, file, fast=False, rewind=True): try: move_tree = KaTrainSGF.parse_file(file) except (ParseError, FileNotFoundError) as e: self.log(i18n._("Failed to load SGF").format(error=e), OUTPUT_ERROR) return self._do_new_game(move_tree=move_tree, analyze_fast=fast, sgf_filename=file) if not rewind: self.game.redo(999) def _do_analyze_sgf_popup(self): if not self.fileselect_popup: popup_contents = LoadSGFPopup() popup_contents.filesel.path = os.path.abspath(os.path.expanduser(self.config("general/sgf_load", "."))) self.fileselect_popup = I18NPopup( title_key="load sgf title", size=[dp(1200), dp(800)], content=popup_contents ).__self__ def readfile(*_args): filename = popup_contents.filesel.filename self.fileselect_popup.dismiss() path, file = os.path.split(filename) if path != self.config("general/sgf_load"): self.log(f"Updating sgf load path default to {path}", OUTPUT_DEBUG) self._config["general"]["sgf_load"] = path self.save_config("general") self.load_sgf_file(filename, popup_contents.fast.active, popup_contents.rewind.active) popup_contents.filesel.on_success = readfile popup_contents.filesel.on_submit = readfile self.fileselect_popup.open() self.fileselect_popup.content.filesel.ids.list_view._trigger_update() def _do_save_game(self, filename=None): filename = filename or self.game.sgf_filename if not filename: return self("save-game-as-popup") try: msg = self.game.write_sgf(filename) self.log(msg, OUTPUT_INFO) self.controls.set_status(msg, STATUS_INFO, check_level=False) except Exception as e: self.log(f"Failed to save SGF to {filename}: {e}", OUTPUT_ERROR) def _do_save_game_as_popup(self): popup_contents = SaveSGFPopup(suggested_filename=self.game.generate_filename()) save_game_popup = I18NPopup( title_key="save sgf title", size=[dp(1200), dp(800)], content=popup_contents ).__self__ def readfile(*_args): filename = popup_contents.filesel.filename if not filename.lower().endswith(".sgf"): filename += ".sgf" save_game_popup.dismiss() path, file = os.path.split(filename.strip()) if not path: path = popup_contents.filesel.path # whatever dir is shown if path != self.config("general/sgf_save"): self.log(f"Updating sgf save path default to {path}", OUTPUT_DEBUG) self._config["general"]["sgf_save"] = path self.save_config("general") self._do_save_game(os.path.join(path, file)) popup_contents.filesel.on_success = readfile popup_contents.filesel.on_submit = readfile save_game_popup.open() def load_sgf_from_clipboard(self): clipboard = Clipboard.paste() if not clipboard: self.controls.set_status("Ctrl-V pressed but clipboard is empty.", STATUS_INFO) return url_match = re.match(r"(?P<url>https?://[^\s]+)", clipboard) if url_match: self.log("Recognized url: " + url_match.group(), OUTPUT_INFO) http = urllib3.PoolManager() response = http.request("GET", url_match.group()) clipboard = response.data.decode("utf-8") try: move_tree = KaTrainSGF.parse_sgf(clipboard) except Exception as exc: self.controls.set_status( i18n._("Failed to import from clipboard").format(error=exc, contents=clipboard[:50]), STATUS_INFO ) return move_tree.nodes_in_tree[-1].analyze( self.engine, analyze_fast=False ) # speed up result for looking at end of game self._do_new_game(move_tree=move_tree, analyze_fast=True) self("redo", 9999) self.log("Imported game from clipboard.", OUTPUT_INFO) def on_touch_up(self, touch): if ( self.board_gui.collide_point(*touch.pos) or self.board_controls.collide_point(*touch.pos) or self.controls.move_tree.collide_point(*touch.pos) ): if touch.is_mouse_scrolling: if touch.button == "scrollup": self("redo") elif touch.button == "scrolldown": self("undo") return super().on_touch_up(touch) @property def shortcuts(self): return { "q": self.analysis_controls.show_children, "w": self.analysis_controls.eval, "e": self.analysis_controls.hints, "t": self.analysis_controls.ownership, "r": self.analysis_controls.policy, "enter": ("ai-move",), "numpadenter": ("ai-move",), "a": ("analyze-extra", "extra"), "s": ("analyze-extra", "equalize"), "d": ("analyze-extra", "sweep"), "f": ("analyze-extra", "alternative"), "g": ("select-box",), "i": ("insert-mode",), "p": ("play", None), "l": ("play-to-end",), "b": ("undo", "branch"), "down": ("switch-branch", 1), "up": ("switch-branch", -1), "f5": ("timer-popup",), "f6": ("teacher-popup",), "f7": ("ai-popup",), "f8": ("config-popup",), } @property def popup_open(self) -> Popup: app = App.get_running_app() if app: first_child = app.root_window.children[0] 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 popup = self.popup_open if popup: if keycode[1] in ["f5", "f6", "f7", "f8"]: # switch between popups popup.dismiss() return elif keycode[1] in ["enter", "numpadenter"]: fn = getattr(popup.content, "on_submit", None) if fn: fn() return else: return shift_pressed = "shift" in modifiers shortcuts = self.shortcuts if keycode[1] == "spacebar": self.toggle_continuous_analysis() elif keycode[1] == "k": self.board_gui.toggle_coordinates() elif keycode[1] in ["pause", "break", "f15"] and not ctrl_pressed: self.controls.timer.paused = not self.controls.timer.paused elif keycode[1] in ["`", "~", "f12"]: self.zen = (self.zen + 1) % 3 elif keycode[1] in ["left", "z"]: self("undo", 1 + shift_pressed * 9 + ctrl_pressed * 9999) elif keycode[1] in ["right", "x"]: self("redo", 1 + shift_pressed * 9 + ctrl_pressed * 9999) elif keycode[1] == "home": self("undo", 9999) elif keycode[1] == "end": self("redo", 9999) elif keycode[1] == "pageup": self.controls.move_tree.make_selected_node_main_branch() elif keycode[1] == "n" and not ctrl_pressed: self("find-mistake", "undo" if shift_pressed else "redo") elif keycode[1] == "delete" and ctrl_pressed: self.controls.move_tree.delete_selected_node() elif keycode[1] == "c" and not ctrl_pressed: self.controls.move_tree.toggle_selected_node_collapse() elif keycode[1] == "n" and ctrl_pressed: self("new-game-popup") elif keycode[1] == "l" and ctrl_pressed: self("analyze-sgf-popup") elif keycode[1] == "s" and ctrl_pressed: self("save-game") elif keycode[1] == "d" and ctrl_pressed: self("save-game-as-popup") elif keycode[1] == "c" and ctrl_pressed: Clipboard.copy(self.game.root.sgf()) self.controls.set_status(i18n._("Copied SGF to clipboard."), STATUS_INFO) elif keycode[1] == "v" and ctrl_pressed: self.load_sgf_from_clipboard() elif keycode[1] == "b" and shift_pressed: self("undo", "main-branch") elif keycode[1] in shortcuts.keys() and not ctrl_pressed: shortcut = shortcuts[keycode[1]] if isinstance(shortcut, Widget): shortcut.trigger_action(duration=0) else: self(*shortcut) elif keycode[1] == "f9" and self.debug_level >= OUTPUT_EXTRA_DEBUG: import yappi yappi.set_clock_type("cpu") yappi.start() self.log("starting profiler", OUTPUT_ERROR) elif keycode[1] == "f10" and self.debug_level >= OUTPUT_EXTRA_DEBUG: import time import yappi stats = yappi.get_func_stats() filename = f"callgrind.{int(time.time())}.prof" stats.save(filename, type="callgrind") self.log(f"wrote profiling results to {filename}", OUTPUT_ERROR) 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()
num_passes = sum([ int(n.is_pass or False) for n in game.current_node.nodes_from_root[::-1][0:2 * MAX_PASS:2] ]) bx, by = game.board_size if num_passes >= MAX_PASS and game.current_node.depth - 2 * MAX_PASS >= bx + by: logger.log(f"Forced pass as opponent is passing {MAX_PASS} times", OUTPUT_ERROR) pol = game.current_node.policy 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.current_node.next_player)).move else: move, node = generate_ai_move(game, ai_strategy, ai_settings) logger.log(f"Generated move {move}", OUTPUT_ERROR) print(f"= {move.gtp()}\n") sys.stdout.flush() malkovich_analysis(game.current_node) continue elif line.startswith("play"): _, player, move = line.split(" ") node = game.play(Move.from_gtp(move.upper(), player=player[0].upper()), analyze=False) logger.log(f"played {player} {move}", OUTPUT_ERROR) elif line.startswith("final_score"): logger.log("line=" + line, OUTPUT_ERROR) if "{" in line: