diff --git a/.gitignore b/.gitignore index b6e4761..109e59a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ +# Mine +KataGoData +old +models +.idea +gtp.log +*.zip + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a2d9b5 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +Manual +====== + +Installation +------------ +* pip install kivy +* change the `engine.command` field in `config.json` to your kata installation (for example, to `path/to/lizzie/katago/katago.exe`) +* start the app by running `python katrain.py` (or `python3` if needed) + +Options +------- +* Top row options + * Eval: show the coloured dots on the moves for this player. + * Hints: show suggested moves for this player. + * Undo: automatically undo poor moves for this player and make them try again. + * Lock AI: disallow extra undos, changing hints options, changing auto move, or AI move. + * Show owner: show expected control of territory. + +* Temperature/Evaluation/Score: Not that these fields can be hidden by clicking on the text. + * Temperature is the point difference between passing and the best move. + * Evaluation is where on this scale the last move was, from 0% (equivalent to a pass) to 100% (best move). + This can be < 0% in case of suicidal moves, or >100% when Kata did not consider the move before, or further analysis shows it to be better than the best one considered. + * Score: Expected score. + +Play +---- + +* Play against the AI + * Turn on auto move. + * Choose whether to turn on `balance score` to make the AI play slack moves. + * Choose whether to turn on `undo` for your colour to be prompted to re-try poor moves. + * Choose whether or not to turn on `fast` to make the AI play faster but read less deeply (NB: with balance score, faster AI can be a stronger opponent, as there are fewer mediocre moves considered). + * Possibly lock AI to prevent yourself from peeking at hints, etc. + * Possibly hide score or temperature. + * Possibly hide evaluation for the AI player. + * Play by playing a move or clicking AI move if you want white. + +* Engine-assisted play + * Turn off auto move. + * Choose whether to turn on `undo` for either colour to be prompted to re-try poor moves. + * Possibly lock AI to prevent peeking at hints. + * Possibly hide score or temperature. + * Play with a friend with instant feedback and/or undos for both, or see how many stones stronger you are with one undo. (But please play unranked and be honest to your opponent on what you're doing) + +* Analysis + * Copy the SGF into the text box + * Choose whether or not to turn on `fast` to make the AI weaker but analyze faster. + * Click `Analyze` + +* Save game + * Click save to get an sgf as `out.sgf` with comments (and a short version in the text box). + +Configuration +------------- +`config.json` has a number of options, many of them are stylistic. + +The `trainer` block has the following options to tweak: + +* `balance_play_target_score`: indicates how many points the AI aims to win by when using 'balance score'. +* `balance_play_randomize_eval`: when not needing to balance score, the AI will pick a random move which is at least this good. +* `balance_play_min_eval`: when needing to balance score, the AI will pick a move which is at least this good. +* `balance_play_min_visits`: never pick a move with fewer playouts than this. +* `undo_eval_threshold`, `undo_point_threshold`: prompt player to undo if move is worse than this in terms of points AND evaluation. +* `undo_outdated_eval_threshold`: don't prompt undo if last move's evaluation is >= `undo_eval_threshold` and the NEW evaluation is greater than this. (this decreases frustration when hints are on, or when kata over-estimates the best move). +* `num_undo_prompts`: automatically undo bad moves when `undo` is on at most this many times. +* `show_ai_options`: show which moves the AI considered. + +The cfg file has additional configuration for kata. In particular, it changes the default to being more exploratory and score-based (and therefore nicer as an opponent, but weaker as analysis tool). + +TODO +---- +* Prisoner count +* Better name +* .... diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config.json b/config.json new file mode 100644 index 0000000..512aa3b --- /dev/null +++ b/config.json @@ -0,0 +1,46 @@ +{ + "analysis": { + "pass_visits": 200, + "pass_visits_fast": 50, + "visits": 3500, + "visits_fast": 1500, + "nopass_visits": 10 + }, + "board": { + "size": 19, + "komi": 7.5 + }, + "ui": { + "size_min": 1, + "size_max": 15, + "stones": [ [0.05, 0.05, 0.05], [0.95, 0.95, 0.95] ], + "ghost_alpha": 0.5, + "eval_colors": [[0.537, 0.129, 0.42], [1, 0, 0], [1, 0.95, 0], [0.117, 0.588, 0]], + "undo_circle_col": [0.88,0.02,0.17,0.5], + "undo_alpha": 0.5, + "eval_knots": [0, 0.5, 0.875, 1], + "eval_bounds": [1,12], + "board_margin": 1.5, + "starpoint_size": 0.1, + "stone_size": 0.475, + "board_color": [0.85, 0.68, 0.40], + "line_color": [0,0,0] + }, + "engine": { + "command": "../lizzie/katago/katago.exe gtp -model models/b10.gz -config gtp_explore_score.cfg" + }, + "trainer": { + "balance_play_target_score": 2, + "balance_play_randomize_eval": 0.95, + "balance_play_min_eval": 0.875, + "balance_play_min_visits": 20, + "undo_eval_threshold": 0.875, + "undo_outdated_eval_threshold": 0.8, + "undo_point_threshold": 1, + "num_undo_prompts": 1, + "show_ai_options": true + }, + "debug": { + "level": 1 + } +} diff --git a/controller.py b/controller.py new file mode 100644 index 0000000..eae59f9 --- /dev/null +++ b/controller.py @@ -0,0 +1,99 @@ +from kivy.storage.jsonstore import JsonStore +from kivy.uix.gridlayout import GridLayout + +from engine import KataEngine +from move import Move + +Config = JsonStore("config.json") + + +class EngineControls(GridLayout): + def __init__(self, **kwargs): + super(EngineControls, self).__init__(**kwargs) + self.engine = KataEngine(self, Config) + + def restart(self, boardsize=None): + self.engine.restart(boardsize) + + def action(self, message, *args): + self.engine.action(message, *args) + + @property + def ready(self): + return self.engine.ready + + @property + def boardsize(self): + return self.engine.boardsize + + @property + def stones(self): + return self.engine.stones + + @property + def moves(self): + return self.engine.moves + + @property + def current_player(self): + return self.engine.current_player() + + def redraw(self, include_board=False): + if include_board: + self.parent.board.draw_board() + self.parent.board.redraw() + + def update_analysis(self, analysis, mode, ownership): + for d in analysis: + d["scoreMean"] = float(d["scoreMean"]) + + if mode == 0: + pm = [d for d in analysis if d["move"] == "pass"] + npm = [d for d in analysis if d["move"] != "pass"] + if pm: + pv = sum([int(d["visits"]) for d in pm], 0) + npv = sum([int(d["visits"]) for d in npm], 0) + print("pass visits", pv, "other", npv) + if pv > npv: + print(analysis) + self.moves[-1].pass_analysis = [d for d in analysis if d["move"] != "pass"] + else: + if ownership: + self.moves[-1].ownership = [float(p) for p in ownership[0].strip().split(" ")] + best = analysis[0]["scoreMean"] + worst = -self.moves[-1].pass_analysis[0]["scoreMean"] + for d in analysis: + d["evaluation"] = (d["scoreMean"] - worst) / (best - worst) + self.moves[-1].analysis = analysis + + if self.eval.active(1 - self.current_player): + self.temperature.text = f"{self.moves[-1].temperature():.1f}" + self.score.text = f"{Move.PLAYERS[self.current_player]}{float(analysis[0]['scoreMean']):+.1f}".replace("-", "\u2013") # en dash + if len(self.moves) >= 2 and self.moves[-2].analysis: + self.moves[-1].evaluate(self.moves[-2]) + if self.eval.active(1 - self.current_player): + if self.moves[-1].evaluation: + self.evaluation.text = f"{100 * self.moves[-1].evaluation:.1f}%" + else: + self.evaluation.text = "N/A" + self.redraw(include_board=False) # for dots and stuff + + def sgf(self): + def sgfify(mvs): + return f"(;GM[1]FF[4]SZ[{self.boardsize}]KM[{self.engine.komi}]RU[CN];" + ";".join(mvs) + ")" + + def format_move(m, pm): + undo_comment = "".join(f"\nUndo: {u.gtp()} was {100*u.evaluation:.1f}%" for u in pm.undos if u.evaluation) + undo_cr = "".join(f"MA[{u.sgfcoords(self.boardsize)}]" for u in pm.undos if u.coords[0]) + if pm.analysis and pm.analysis[0]["move"] != "pass": + best_sq = f"SQ[{Move(gtpcoords=pm.analysis[0]['move'],player=0).sgfcoords(self.boardsize)}]" + else: + best_sq = "" + return m.sgf(self.boardsize) + f"C[{m.comment}{undo_comment}]{undo_cr}{best_sq}" + + sgfmoves_small = [mv.sgf(self.boardsize) for mv in self.moves[1:]] + sgfmoves = [format_move(mv, pmv) for mv, pmv in zip(self.moves[1:], self.moves[:-1])] + + with open("out.sgf", "w") as f: + f.write(sgfify(sgfmoves)) + return sgfify(sgfmoves_small) diff --git a/engine.py b/engine.py new file mode 100644 index 0000000..5bc4056 --- /dev/null +++ b/engine.py @@ -0,0 +1,278 @@ +import re +import random +import shlex +import subprocess +import threading +import time +from queue import Queue +from move import Move + + +class KataEngine: + def __init__(self, controls, config): + self.controls = controls + self.command = shlex.split(config.get("engine")["command"]) + + analysis_settings = config.get("analysis") + self.visits = [[analysis_settings["pass_visits"], analysis_settings["visits"]], [analysis_settings["pass_visits_fast"], analysis_settings["visits_fast"]]] + self.min_nopass_visits = analysis_settings["nopass_visits"] + self.train_settings = config.get("trainer") + self.debug = config.get("debug")["level"] + self.boardsize = config.get("board")["size"] + self.komi = config.get("board")["komi"] + self.ready = False + self.stones = [] + self.message_queue = None + self.moves = [Move(player=1, coords=(None, None))] # sentinel + + self.kata = None + + def current_player(self): + return 1 - self.moves[-1].player + + def restart(self, boardsize): + self.ready = False + if not self.message_queue: + self.message_queue = Queue() + self.analysis_semaphore = threading.Semaphore(1) + self.stop_analyzing = True + self.thread = threading.Thread(target=self._engine_thread, daemon=True).start() + else: + with self.message_queue.mutex: + self.message_queue.queue.clear() + self.stones = [] + self.action("init", boardsize or self.boardsize) + + def action(self, message, *args): + self.message_queue.put([message, *args]) + + def gtpread(self): + lines = [] + while self.kata: + lines.append(self.kata.stdout.readline().decode()) + if lines[-1].strip() == "": + break + return lines[:-1] + + def gtpwrite(self, cmd): + if self.debug: + print("WRITE", cmd) + try: + self.kata.stdin.write((cmd + "\n").encode("utf-8")) + self.kata.stdin.flush() + except Exception: + self.controls.info.text = "Engine died, please restart app" + raise + + def gtpcommand(self, cmd): + self.gtpwrite(cmd) + return self.gtpread() + + def raw_gtpplaycommand(self, move): + if move == "undo": + output = self.gtpcommand("undo") + else: + output = self.gtpcommand(f"play {Move.PLAYERS[move.player]} {move.gtp()}") + output = "".join(output) + if self.debug and "?" in output: + print(move, output) + return "?" not in output + + def update_stones(self): + board_output = self.gtpcommand("showboard") + board = [re.sub(r"[^\.ox]", "", l.lower()) for l in board_output[2:]] + self.stones = [] + for y, line in enumerate(board[::-1]): + for x, st in enumerate(line): + if st != ".": + self.stones.append(("xo".index(st), x, y)) + self.controls.redraw(include_board=False) + + def gtpplaycommand(self, move): + self.stop_analyzing = True + self.analysis_semaphore.acquire() + if self.raw_gtpplaycommand(move): # update moves array if engine accepts move + if move == "undo": + self.moves[-2].undos.append(self.moves[-1]) + self.moves.pop() + else: + self.moves[-1].undos = [m for m in self.moves[-1].undos if m.coords != move.coords] + self.moves.append(move) + self.update_stones() + # start analyzing new board position + self.stop_analyzing = False + self.analysis_semaphore.release() + + # engine main loop + def _engine_thread(self): + self.kata = subprocess.Popen(self.command, stdin=subprocess.PIPE, stdout=subprocess.PIPE) + print(self.command, self.kata) + analysis_thread = threading.Thread(target=self._analyze_thread, args=(25,), daemon=True).start() + self.stop_analyzing = False + + msg, *args = self.message_queue.get() + while True: + try: + if self.debug: + print("MESSAGE", msg, args) + getattr(self, f"_do_{msg.replace('-','_')}")(*args) + except Exception as e: + self.controls.info.text = f"Exception in Engine thread: {e}" + raise + msg, *args = self.message_queue.get() + + # engine action functions + def _do_play(self, *args): + self.gtpplaycommand(Move(player=self.current_player(), coords=args[0])) + self.controls.undo.disabled = True # undo while waiting for this does weird things + undid = False + self.controls.info.text = "" + if self.controls.auto_undo.active(1 - self.current_player()): + print("undo active", self.current_player(), self.controls.auto_undo.active(self.current_player())) + undid = self._auto_undo() + if self.controls.ai_auto.active and not undid: + self._do_aimove(True) + self.controls.undo.disabled = False + + def _evaluate_move(self, show=True): + while not self.moves[-1].analysis: # ensure analysis has started, otherwise race condition on multi ai move + time.sleep(0.01) + self.analysis_semaphore.acquire() and self.analysis_semaphore.release() # wait for analysis to finish + if self.moves[-1].evaluation and show: + self.controls.info.text = f"Your move {self.moves[-1].gtp()} was {100 * self.moves[-1].evaluation:.1f}% efficient and lost {self.moves[-1].points_lost:.1f} point(s).\n" + + def _auto_undo(self): + ts = self.train_settings + self.controls.info.text = "Evaluating..." + self._evaluate_move() + if ( + self.moves[-1].evaluation + and self.moves[-1].evaluation < ts["undo_eval_threshold"] + and self.moves[-1].points_lost >= ts["undo_point_threshold"] + and ts["num_undo_prompts"] > 0 + ): + if self.moves[-1].outdated_evaluation: + outdated_points_lost = (1 - self.moves[-1].outdated_evaluation) * self.moves[-1].points_lost / (1 - self.moves[-1].evaluation) + # so if the move was not that far off (>undo_outdated_eval_threshold) and according to last move's analysis it was fine, don't undo. + if ( + self.moves[-1].outdated_evaluation + and (self.moves[-1].outdated_evaluation >= ts["undo_eval_threshold"] or outdated_points_lost < ts["undo_point_threshold"]) + and (self.moves[-1].evaluation > ts["undo_outdated_eval_threshold"] or outdated_points_lost < ts["undo_point_threshold"]) + ): + self.controls.info.text += f"\nBut according to my previous evaluation it was {self.moves[-1].outdated_evaluation*100:.1f}% effective and lost {outdated_points_lost:.1f} point(s), so let's continue anyway.\n" + else: + if len(self.moves[-2].undos) < ts["num_undo_prompts"]: + self.controls.info.text += f"\nLet's try again.\n" + self.gtpplaycommand("undo") + return True + else: + evaled_moves = sorted([m for m in self.moves[-2].undos + [self.moves[-1]] if m.evaluation], key=lambda m: -m.evaluation) + if evaled_moves and evaled_moves[0].coords != self.moves[-1].coords: + self.gtpplaycommand("undo") + self.gtpplaycommand(evaled_moves[0]) + summary = "\n".join(f"{m.gtp()}: {100*m.evaluation:.1f}% effective" for m in evaled_moves) + self.controls.info.text += f"\nYour moves:\n{summary}.\nLet's continue with {evaled_moves[0].gtp()}.\n" + return False + + def _do_aimove(self, auto=False): + ts = self.train_settings + if not auto: + self.controls.info.text = "Thinking..." + self._evaluate_move(auto and not self.controls.auto_undo.active(1 - self.current_player())) + # select move + pos_moves = [(d["move"], float(d["scoreMean"]), d["evaluation"]) for d in self.moves[-1].analysis if int(d["visits"]) >= ts["balance_play_min_visits"]] + if ts["show_ai_options"]: + self.controls.info.text += "AI Options: " + " ".join([f"{move}({100*eval:.0f}%,{score:.1f}pt)" for move, score, eval in pos_moves]) + selmove = pos_moves[0][0] + if self.controls.ai_balance.active and pos_moves[0][0] != "pass": # don't play suicidal to balance score - pass when it's best + selmoves = [ + move + for move, score, eval in pos_moves + if eval > ts["balance_play_randomize_eval"] or eval > ts["balance_play_min_eval"] and score > ts["balance_play_target_score"] + ] + selmove = random.choice(selmoves) # some kind of when further ahead play worse? + self.gtpplaycommand(Move(player=self.current_player(), gtpcoords=selmove, robot=True)) + + def _do_undo(self): + if self.controls.ai_auto.active and self.moves[-1].robot: + self.gtpplaycommand("undo") + if self.controls.ai_lock.active and self.controls.auto_undo.active(self.moves[-2].player) and len(self.moves[-2].undos) >= self.train_settings["num_undo_prompts"]: + self.controls.info.text = f"Can't undo more than {self.train_settings['num_undo_prompts']} time(s) when locked" + return + self.gtpplaycommand("undo") + + def _do_init(self, boardsize, komi=None): + self.boardsize = boardsize + self.stop_analyzing = True + self.analysis_semaphore.acquire() + self.stones = [] + self.moves = [Move(player=1, coords=(None, None))] # sentinel + self.controls.redraw(include_board=True) + self.gtpcommand(f"boardsize {boardsize}") + self.gtpcommand(f"komi {komi or self.komi}") + self.gtpcommand("clear_board") + self.ready = True + self.analysis_semaphore.release() + self.stop_analyzing = False + + def _do_analyze_sgf(self, sgf): + self._do_init(self.boardsize, self.komi) + sgfmoves = re.findall(r"([BW])\[([a-z]{2})\]", sgf) + for move in [Move(player=Move.PLAYERS.index(p.upper()), sgfcoords=(mv, self.boardsize)) for p, mv in sgfmoves]: + while not self.moves[-1].analysis: + time.sleep(0.01) + self.analysis_semaphore.acquire() and self.analysis_semaphore.release() # wait for analysis to finish + self.gtpplaycommand(move) + self.controls.info.text = f"Analyzing move {move.gtp()}" + self.controls.info.text = "Analysis done!" + + # analysis thread + def _analyze_thread(self, interval): + while True: + num_visits = self.visits[1 if self.controls.ai_fast.active else 0] + while self.stop_analyzing: # TODO: cleaner concurrency? + time.sleep(0.01) + self.analysis_semaphore.acquire() + for mode in [0, 1]: # pass, analyze + if self.stop_analyzing: + break + if mode == 0: + passmove = Move(player=self.current_player(), gtpcoords="pass") + if len(self.moves) > 2 and len({self.moves[-2].coords, self.moves[-1].coords} - {(x, y) for _, x, y in self.stones}) == 0: # nothing got captured + undo_mode = 0 + self.raw_gtpplaycommand("undo") + self.raw_gtpplaycommand("undo") + self.raw_gtpplaycommand(passmove) + self.raw_gtpplaycommand(self.moves[-1]) + self.raw_gtpplaycommand(self.moves[-2]) + else: + undo_mode = 1 + for coords in [(0, 0), (0, self.boardsize - 1), (self.boardsize - 1, 0), (self.boardsize - 1, self.boardsize - 1), (None, None)]: + if self.raw_gtpplaycommand(Move(player=self.current_player(), coords=coords)): + break + self.gtpwrite(f"kata-analyze interval {interval} minmoves 2 {'ownership true' if mode==1 else ''}") + self.kata.stdout.readline() # = + tot_visits = tot_nopass_visits = 0 + while not self.stop_analyzing and (tot_visits < num_visits[mode] or tot_nopass_visits < self.min_nopass_visits): + line = self.kata.stdout.readline().decode() + line, *ownership = line.split("ownership") + moves = [re.sub("pv .*", "", str).split(" ") for str in line.split("info ")[1:]] + move_dicts = [{move[i]: move[i + 1] for i in range(0, len(move) - 1, 2)} for move in moves] + self.controls.update_analysis(move_dicts, mode, ownership) + tot_visits = sum([int(d["visits"]) for d in move_dicts], 0) + tot_nopass_visits = sum([int(d["visits"]) for d in move_dicts if d["move"] != "pass"], 0) + if self.debug: + print("mode=", mode, "visits=", tot_visits, "nopass=", tot_nopass_visits) # , "stop_analyzing?", stop_analyzing + self.gtpcommand("stop") # reads for analyze empty line + self.gtpread() # for stop line empty line + # for modes loop + if mode == 0: # undo A1 + self.raw_gtpplaycommand("undo") + if undo_mode == 0: + self.raw_gtpplaycommand("undo") + self.raw_gtpplaycommand("undo") + self.raw_gtpplaycommand(self.moves[-2]) + self.raw_gtpplaycommand(self.moves[-1]) + else: + self.stop_analyzing = True # ehh + self.analysis_semaphore.release() # signal other threads waiting for analysis to finish diff --git a/gtp_explore_score.cfg b/gtp_explore_score.cfg new file mode 100644 index 0000000..530f9f9 --- /dev/null +++ b/gtp_explore_score.cfg @@ -0,0 +1,252 @@ +# Example config for C++ (non-python) gtp bot + +# RUNNING ON AN ONLINE SERVER OR IN A REAL TOURNAMENT OR MATCH: +# If you plan to do so, you may want to read through the "Rules" section +# below carefully for proper handling of komi and handicap games and end-of-game cleanup +# and various other details. + +# NOTES ABOUT PERFORMANCE AND MEMORY USAGE: +# You will likely want to tune one or more the following: +# +# numSearchThreads: +# The number of CPU threads to use. If your GPU is powerful, it can actually be much higher than +# the number of cores on your processor because you will need many threads to feed large enough +# batches to make good use of the GPU. +# +# nnMaxBatchSize: +# The maximum GPU batch size. Should often be at least as large as numSearchThreads. +# Larger won't do anything, but also won't hurt except use a little bit more GPU memory. +# Smaller can be fine if you have more than one GPU, since the GPUs will be sharing the work +# of servicing the CPU threads. +# +# cudaUseFP16 and cudaUseNHWC: +# These have a good chance of improving peformance at larger threads/batch sizes if +# you are using the CUDA implementation with an NVIDIA GPU with FP16 tensor cores. +# +# nnCacheSizePowerOfTwo: +# This controls the NN Cache size, which is the primary RAM/memory use. +# Each neural net entry takes very approximately 1.5KB, except when using whole-board +# ownership/territory visualizations, each entry will take very approximately 3KB. +# The number of entries is (2 ** nnCacheSizePowerOfTwo), for example 2 ** 18 = 262144. +# Increase this if you don't mind the memory use and want better performance +# for searches with tens of thousands of visits or more (due to birthday paradox +# it can start mattering well before cache actually fills entirely up). +# Decrease this if you want to limit memory usage. +# +# OTHER NOTES: +# If you have more than one GPU, take a look at "OpenCL GPU settings" or "CUDA GPU settings" below. +# +# If using OpenCL, you will want to verify that KataGo is picking up the correct device! +# (e.g. some systems may have both an Intel CPU OpenCL and GPU OpenCL, if KataGo appears to pick +# the wrong one, you correct this by specifying "openclGpuToUse" below). +# +# You may also want to adjust "maxVisits", "ponderingEnabled", "resignThreshold", and possibly +# other parameters depending on your intended usage. + + +# Logs------------------------------------------------------------------------------------ + +# Where to output log? +logFile = gtp.log +# Logging options +logAllGTPCommunication = true +logSearchInfo = true +logToStderr = false + +# KataGo will display some info to stderr on GTP startup +# Uncomment this to suppress that and remain silent +# startupPrintMessageToStderr = false + +# Chat some stuff to stderr, for use in things like malkovich chat to OGS. +# ogsChatToStderr = true + +# Configure the maximum length of analysis printed out by lz-analyze and other places. +# Controls the number of moves after the first move in a variation. +# analysisPVLen = 9 + +# Report winrates for chat and analysis as (BLACK|WHITE|SIDETOMOVE). +# Default is SIDETOMOVE, which is what tools that use LZ probably also expect +# reportAnalysisWinratesAs = SIDETOMOVE + +# Rules------------------------------------------------------------------------------------ + +# koRule = SIMPLE #Simple ko rules (triple ko = no result) +koRule = POSITIONAL #Positional superko +# koRule = SITUATIONAL #Situational superko +# koRule = SPIGHT #Spight superko - https://senseis.xmp.net/?SpightRules + +scoringRule = AREA #Area scoring +# scoringRule = TERRITORY #Territory scoring (uses a sort of special computer-friendly territory ruleset) + +multiStoneSuicideLegal = false #Is multiple-stone suicide legal? (Single-stone suicide is always illegal). + +# Make the bot capture stones that are part of pass-alive territory +# This is necessary to get correct play under tromp-taylor rules since the bot otherwise assumes (and is trained under) +# a ruleset where those stones need not be captured. It obviously should NOT be enabled if playing under territory scoring. +cleanupBeforePass = false + +# Uncomment this to make it so that if the game seems to be a handicap game, assume that white gets +1 point per +# black handicap stone. Some Go servers like OGS will silently give white such points without including it in the komi. +# whiteBonusPerHandicapStone = 1 + +# Resignation occurs if for at least resignConsecTurns in a row, +# the winLossUtility (which is on a [-1,1] scale) is below resignThreshold. +allowResignation = false +resignThreshold = -0.98 +resignConsecTurns = 3 + +# Assume that if black makes many moves in a row right at the start of the game, then the game is a handicap game. +# This is necessary on some servers and for some GUIs and also when initializing from many SGF files, which may +# set up a handicap games using repeated GTP "play" commands for black rather than GTP "place_free_handicap" commands. +# However, it may also lead to incorrect undersanding of komi if whiteBonusPerHandicapStone = 1 and a server does NOT +# have such a practice. +# Defaults to true. Uncomment and set to false to disable this behavior. +# assumeMultipleStartingBlackMovesAreHandicap = false + +# Search limits----------------------------------------------------------------------------------- + +# If provided, limit maximum number of root visits per search to this much. (With tree reuse, visits do count earlier search) +maxVisits = 1000 +# If provided, limit maximum number of new playouts per search to this much. (With tree reuse, playouts do not count earlier search) +# maxPlayouts = 1000 +# If provided, cap search time at this many seconds (search will still try to follow GTP time controls) +# maxTime = 60 + +# Ponder on the opponent's turn? +ponderingEnabled = false + +# Same limits but for ponder searches if pondering is enabled +# maxVisitsPondering = 1000 +# maxPlayoutsPondering = 1000 +# maxTimePondering = 60 + +# Number of seconds to buffer for lag for GTP time controls +lagBuffer = 1.0 + +# Number of threads to use in search +numSearchThreads = 1 + +# Play a little faster if the opponent is passing, for friendliness +searchFactorAfterOnePass = 0.50 +searchFactorAfterTwoPass = 0.25 +# Play a little faster if super-winning, for friendliess +searchFactorWhenWinning = 0.40 +searchFactorWhenWinningThreshold = 0.95 + +# GPU Settings------------------------------------------------------------------------------- + +# Maximum number of positions to send to GPU at once. Note that you will also need to increase numSearchThreads +# to make use of this, as every thread in KataGo is synchronous, so with 1 thread max batch will only be 1 anyways. +nnMaxBatchSize = 16 +# Cache up to 2 ** this many neural net evaluations in case of transpositions in the tree. +nnCacheSizePowerOfTwo = 18 +# Size of mutex pool for nnCache is 2 ** this +nnMutexPoolSizePowerOfTwo = 14 +# Randomize board orientation when running neural net evals? +nnRandomize = true +# If provided, force usage of a specific seed for nnRandomize instead of randomizing +# nnRandSeed = abcdefg + +# How many threads should there be to feed positions to the neural net? +# Server threads are indexed 0,1,...(n-1) for the purposes of the below GPU settings arguments +# that specify which threads should use which GPUs. +# NOTE: This parameter is probably ONLY useful if you have multiple GPUs, since each GPU will need a thread. +# If you're tuning single-GPU performance, use numSearchThreads instead. +numNNServerThreadsPerModel = 1 + +# CUDA GPU settings-------------------------------------- +# These only apply when using CUDA as the backend for inference. +# (For GTP, we only ever have one model, when playing matches, we might have more than one, see match_example.cfg) + +# Default behavior tries to guess the 'best' GPU or device +# You will want to uncomment and adjust one or more of these lines to take advantage of a multi-gpu machine +# cudaDeviceToUse = 0 #use device 0 for all server threads (numNNServerThreadsPerModel) unless otherwise specified per-model or per-thread-per-model +# cudaDeviceToUseModel0 = 3 #use device 3 for model 0 for all threads unless otherwise specified per-thread for this model +# cudaDeviceToUseModel1 = 2 #use device 2 for model 1 for all threads unless otherwise specified per-thread for this model +# cudaDeviceToUseModel0Thread0 = 3 #use device 3 for model 0, server thread 0 +# cudaDeviceToUseModel0Thread1 = 2 #use device 2 for model 0, server thread 1 + +# Uncomment these on NVIDIA devices with FP16 tensor cores for probably a speedup, at the cost of introducing some precision loss in the nn calculation. +# cudaUseFP16 = true +# cudaUseNHWC = true + +# OpenCL GPU settings-------------------------------------- +# These only apply when using OpenCL as the backend for inference. +# (For GTP, we only ever have one model, when playing matches, we might have more than one, see match_example.cfg) + +# Default behavior tries to guess the 'best' GPU or device +# You will want to uncomment and adjust one or more of these lines to take advantage of a multi-gpu machine +# openclDeviceToUse = 0 #use device 0 for all server threads (numNNServerThreadsPerModel) unless otherwise specified per-model or per-thread-per-model +# openclDeviceToUseModel0 = 3 #use device 3 for model 0 for all threads unless otherwise specified per-thread for this model +# openclDeviceToUseModel1 = 2 #use device 2 for model 1 for all threads unless otherwise specified per-thread for this model +# openclDeviceToUseModel0Thread0 = 3 #use device 3 for model 0, server thread 0 +# openclDeviceToUseModel0Thread1 = 2 #use device 2 for model 0, server thread 1 + +# Uncomment to tune OpenCL for every board size separately, rather than only the largest possible size +# openclReTunePerBoardSize = true + +# Search randomization------------------------------------------------------------------------------ +# Note that multithreading can also introduce a significant amount of nondeterminism. + +# If provided, force usage of a specific seed for various things in the search instead of randomizing +# searchRandSeed = hijklmn + +# Temperature for the early game, randomize between chosen moves with this temperature +chosenMoveTemperatureEarly = 0.5 +# Decay temperature for the early game by 0.5 every this many moves, scaled with board size. +chosenMoveTemperatureHalflife = 19 +# At the end of search after the early game, randomize between chosen moves with this temperature +chosenMoveTemperature = 0.10 +# Subtract this many visits from each move prior to applying chosenMoveTemperature +# (unless all moves have too few visits) to downweight unlikely moves +chosenMoveSubtract = 0 +# The same as chosenMoveSubtract but only prunes moves that fall below the threshold, does not affect moves above +chosenMovePrune = 1 + +# Use dirichlet noise for the root node policy? +rootNoiseEnabled = false +# Dirichlet noise alpha is set to this divided by number of legal moves. 10.83 produces an alpha of 0.03 on an empty 19x19 board. +rootDirichletNoiseTotalConcentration = 10.83 +# Proportion of root policy that is noise +rootDirichletNoiseWeight = 0.25 + +# Using LCB for move selection? +useLcbForSelection = true +# How many stdevs a move needs to be better than another for LCB selection +lcbStdevs = 5.0 +# Only use LCB override when a move has this proportion of visits as the top move +minVisitPropForLCB = 0.15 + +# Internal params------------------------------------------------------------------------------ + +# Scales the utility of winning/losing +winLossUtilityFactor = 0.0 +# Scales the utility for trying to maximize score +staticScoreUtilityFactor = 0.6 +dynamicScoreUtilityFactor = 0.4 +# Adjust dynamic score center this proportion of the way towards zero, capped at a reasonable amount. +dynamicScoreCenterZeroWeight = 0.20 +# The utility of getting a "no result" due to triple ko or other long cycle in non-superko rulesets (-1 to 1) +noResultUtilityForWhite = 0.0 +# The number of wins that a draw counts as, for white. (0 to 1) +drawEquivalentWinsForWhite = 0.5 + +# Exploration constant for mcts +cpuctExploration = 2.0 +# FPU reduction constant for mcts +fpuReductionMax = 0.2 +# Use parent average value for fpu base point instead of point value net estimate +fpuUseParentAverage = true +# Amount to apply a downweighting of children with very bad values relative to good ones +valueWeightExponent = 0.5 +# Slight incentive for the bot to behave human-like with regard to passing at the end, filling the dame, +# not wasting time playing in its own territory, etc, and not play moves that are equivalent in terms of +# points but a bit more unfriendly to humans. +rootEndingBonusPoints = 0.5 +# Make the bot prune useless moves that are just prolonging the game to avoid losing yet +rootPruneUselessMoves = true + +# How big to make the mutex pool for search synchronization +mutexPoolSize = 8192 +# How many virtual losses to add when a thread descends through a node +numVirtualLossesPerThread = 1 diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..204ec05 Binary files /dev/null and b/icon.png differ diff --git a/kata.py b/kata.py new file mode 100644 index 0000000..3927efb --- /dev/null +++ b/kata.py @@ -0,0 +1,223 @@ +import random +import re +import shlex +import signal +import subprocess +import sys +import threading + +DEBUG = True + + +class GoEngine: + GTP_COORD = "ABCDEFGHJKLMNOPQRSTUVWYXYZ" + SGF_COORD = [chr(i) for i in range(97, 123)] + + def __init__(self, boardsize=19): + self.boardsize = boardsize + self.moves = [] + self.stones = [] # TODO refactor stones vs moves distinction + self.komi = 7.5 + self.turn = 0 + + def start(self, boardsize): + self.__init__(boardsize) + + def play(self, coords, player=None, temp=False): + if not temp: + self.moves.append((player or self.turn, *(coords or [None, None]))) # pass is x=y=None + if not player: + self.turn = 1 - self.turn + + def generate_move(self): + for _ in range(1000): + move = (random.randint(0, self.boardsize - 1), random.randint(0, self.boardsize - 1)) + if move not in [(x, y) for _, x, y in self.stones]: + break + self.play(move) + + def undo(self): + if self.moves: + self.moves.pop() + self.turn = 1 - self.turn + + def gtp2ix(self, gtpmove): + if "pass" in gtpmove: + return (None, None) + return (GoEngine.GTP_COORD.index(gtpmove[0]), int(gtpmove[1:]) - 1) + + def ix2gtp(self, coords): + if not coords: + return "pass" + return GoEngine.GTP_COORD[coords[0]] + str(coords[1] + 1) + + def coord2sgf(self, pl, x, y): + if x is None: + return f"{'BW'[pl]}[]" + else: + return f"{'BW'[pl]}[{GoEngine.SGF_COORD[x]}{GoEngine.SGF_COORD[self.boardsize - y - 1]}]" + + def sgf(self): + sgfmoves = [self.coord2sgf(pl, x, y) for pl, x, y in self.moves] + return f"(;GM[1]SZ[{self.boardsize}]KM[{self.komi}];" + ";".join(sgfmoves) + ")" + + +NEXT_BEST_PLAYOUTS = 1000 +PASS_PLAYOUTS = 250 + + +class KataEngine(GoEngine): + # CMD = "kg/cpp/katago gtp -model modelb6/model.txt.gz -config katagtp.cfg" + CMD = "../lizzie/katago/katago.exe gtp -model ../lizzie/katanetwork.gz -config katagtp.cfg" + + def __init__(self, boardsize=19): + super().__init__(boardsize) + self.stones = [] + self.temperature = 0 + if getattr(self, "kata", None): + self.stop() + else: + signal.signal(signal.SIGINT, lambda *args: self.stop() and sys.exit(0)) + self.lock = threading.Lock() + threading.Thread(target=self.create_pipe, daemon=True).start() + + def create_pipe(self): + with self.lock: # prevent other commands until started + self.kata = subprocess.Popen(shlex.split(KataEngine.CMD), stdin=subprocess.PIPE, stdout=subprocess.PIPE) + self._command(f"boardsize {self.boardsize}") + self.calc_temperature() + + def stop(self): + if self.kata: + print("STOPPING KATA") + self.kata.terminate() + + def start(self, boardsize): + self.__init__(boardsize) + + def _read(self): + lines = [] + while self.kata: + lines.append(self.kata.stdout.readline().decode()) + if DEBUG: + print("READ", lines[-1].rstrip()) + if lines[-1].strip() == "": + break + return lines[:-1] + + def _write(self, cmd): + if DEBUG: + print("WRITE", cmd) + self.kata.stdin.write((cmd + "\n").encode("utf-8")) + self.kata.stdin.flush() + + def _command(self, cmd): + self._write(cmd) + return self._read() + + def _eq_command(self, cmd): + return [l for l in self._command(cmd) if "=" in l][0][1:].strip() + + def current_player(self): + return "BW"[self.turn] + + def generate_move(self): + with self.lock: # lock to ensure temp is done / hacky eh + coords = self.gtp2ix(self.best_analysis[0]["move"]) + return self.play(coords) + + def _play(self, coords, player=None): + self._command(f"play {player or self.current_player()} {self.ix2gtp(coords)}") + + def play(self, coords, player=None): + with self.lock: + self._play(coords, player) + super().play(coords, player) + self.update_position() + best_score = float(self.best_analysis[0]["scoreMean"]) + worst_score = -float(self.pass_analysis[0]["scoreMean"]) + self.calc_temperature() + last_move_score = -float(self.best_analysis[0]["scoreMean"]) + print("BEST", best_score, "WORST", worst_score, "LAST MOVE", last_move_score) + return (last_move_score - worst_score) / (best_score - worst_score) + + def undo(self): + with self.lock: + super().undo() + self._command("undo") + self.update_position() + + def showboard(self): + with self.lock: + output = self._command("showboard") + return [re.sub("[^\.ox]", "", l.lower()) for l in output[2:]] + + def update_position(self): + print("UPDATING POSITION") + board = self.showboard() + self.stones = [] + for y, line in enumerate(board[::-1]): + for x, st in enumerate(line): + if st != ".": + self.stones.append(("xo".index(st), x, y)) + + def analyze(self, nvisits=100, interval=10): + self._write(f"kata-analyze interval {interval} ownership true") + stopped = False + move_dicts = [] + while self.kata: + line = self.kata.stdout.readline().decode() + if stopped and line.strip() == "": + self._read() # stop cause previous line break and then =, another double line break + break + elif "info" not in line: + continue + line, ownership = line.split("ownership") + moves = [re.sub("pv .*", "", str).split(" ") for str in line.split("info ")[1:]] + move_dicts = [{move[i]: move[i + 1] for i in range(0, len(move) - 1, 2)} for move in moves] + tot_visits = sum([int(d["visits"]) for d in move_dicts], 0) + if not stopped and tot_visits > nvisits: + stopped = True + self._write("stop") + print("analyzed", move_dicts) + return move_dicts # {d['move']: d for d in move_dict} #/by order? + + def calc_temperature(self): + with self.lock: + self.best_analysis = self.analyze(NEXT_BEST_PLAYOUTS) + print("playing pass") + self._play((0, 0)) # pass does some weird things with pass being optimal + print("analyzing post pass") + self.pass_analysis = self.analyze(PASS_PLAYOUTS) + self._command("undo") + # score after best move - score after pass = temp, but score after pass is negated here bc opponent's perspective + self.temperature = float(self.best_analysis[0]["scoreMean"]) + float(self.pass_analysis[0]["scoreMean"]) + return self.temperature + + +if __name__ == "__main__": + + # https://github.com/lightvector/KataGo/issues/25 + k = KataEngine() + + # print(k.genmove("b")) + # print(k.showboard()) + # print(k.genmove("w")) + # print(k.showboard()) + # print(k.genmove("w")) + # print(k.showboard()) + print(k.play((3, 3))) + print("TEMPERATURE:", k.temperature()) + print(k.play((15, 3))) + print("TEMPERATURE:", k.temperature()) + print(k.showboard()) + + print(k.analyze(1000)) + print(k.play((0, 0))) + print("TEMPERATURE:", k.temperature()) + print(k.showboard()) + + # k.stop() + + # k.play('b','pass') + # md = k.analyze() diff --git a/katrain.kv b/katrain.kv new file mode 100644 index 0000000..5b6db27 --- /dev/null +++ b/katrain.kv @@ -0,0 +1,288 @@ +#:kivy 1.11.0 + + + + +: + text_color: 0.95,0.95,0.95,1 + button_color: 0.157,0.455,0.753,1 + button_color_down: (*[c/2 for c in self.button_color[:3]],1) + margin: (2,1) + bold: True + font_size: self.size[1] * 0.4 + radius: int(self.size[1]/3) + # dont set these + disabled_mask: (0.5,0.5,0.5,1) if self.disabled else (1,1,1,1) + color: self.disabled_mask if self.disabled else self.text_color + background_color: 0,0,0,0 + background_normal: '' + face_color: [c*m for c,m in zip(self.button_color if root.state=='normal' else self.button_color_down,self.disabled_mask)] + canvas.before: + Color: + rgba: root.face_color or [0,0,0,0] + RoundedRectangle: + size: self.size[0]-2*self.margin[0],self.size[1]-2*self.margin[1] + pos: (self.pos[0]+self.margin[0],self.pos[1]+self.margin[1]) + radius: (root.radius or 0,) + +: + color: (0.05,0.05,0.05,1) + +: + bold: True + + + color: (0.05,0.05,0.05,1) + +: + halign: 'center' + valign: 'center' + + + orientation: 'vertical' + checkbox: checkbox + text: '' + default_active: False + CheckBoxHintLabel: + size_hint: 1,0.45 + font_size: root.height * 0.2 + text: root.text + on_press: if not checkbox.disabled: checkbox._do_press() + CheckBox: + size_hint: 1,0.55 + id: checkbox + on_active: root.dispatch('on_active') + active: root.default_active + + + black: black + white: white + orientation: 'vertical' + text: '' + default_active: False + CheckBoxHintLabel: + size_hint: 1,0.2 + text: root.text + font_size: self.height + on_press: if not white.disabled: white._do_press(); black._do_press() + CheckBox: + size_hint: 1,0.4 + id: black + active: root.default_active + on_active: root.dispatch('on_active') + CheckBox: + size_hint: 1,0.4 + id: white + active: root.default_active + on_active: root.dispatch('on_active') + + + + orientation: 'horizontal' + text: '' + label: '' + CheckBoxHintLabel: + size_hint: 0.6,1 + text: root.label + font_size: self.height * 0.6 + on_press: value.opacity = 1 if value.opacity==0 else 0 + DarkLabel: + size_hint: 0.4,1 + text: root.text + font_size: self.height * 0.6 + id: value + bold: True + +: + size: self.parent.height, self.parent.height + engine: self.parent.controls + + + cols: 1 + rows: 9 + info: info + temperature: temperature + evaluation: evaluation + score: score + hints: hints + ownership: ownership + eval: eval + ai_balance: ai_balance + ai_auto: ai_auto + ai_fast: ai_fast + ai_lock: ai_lock + auto_undo: auto_undo + undo: undo + GridLayout: + cols: 7 + rows: 1 + size_hint: 1, 0.05 + Label: + size_hint: 0.01, 1 + BoxLayout: + orientation: 'vertical' + size_hint: 0.1, 0.5 + Label: + size_hint: 1, 0.2 + DarkLabel: + pos: self.parent.pos[0] + self.width * 0.1, self.parent.pos[1] + size_hint: 1, 0.4 + text: 'B' + color: 0.95,0.95,0.95,1 + font_size: min(self.height,self.width) * 0.6 + halign: 'center' + valign: 'center' + bold: True + canvas.before: + Color: + rgba: 0.05,0.05,0.05,1 + Ellipse: + pos: self.pos[0] + self.width/2 - min(self.height,self.width) * 0.48, self.pos[1] + self.height/2 - min(self.height,self.width) * 0.48 + size: min(self.height,self.width) * 0.96, min(self.height,self.width) * 0.96 + DarkLabel: + size_hint: 1, 0.4 + text: 'W' + halign: 'center' + valign: 'center' + font_size: min(self.height,self.width) * 0.6 + bold: True + canvas.after: + Color: + rgba: 0.05,0.05,0.05,1 + Line: + circle: self.pos[0] + self.width/2, self.pos[1] + self.height/2, min(self.height,self.width)*0.45 + width: 1.1 + BWCheckBoxHint: + size_hint: 0.2, 0.5 + id: eval + text: 'eval' + default_active: True + on_active: root.parent.board.redraw() + BWCheckBoxHint: + size_hint: 0.2, 0.5 + id: hints + text: 'hints' + on_active: root.parent.board.redraw() + BWCheckBoxHint: + size_hint: 0.2, 0.5 + id: auto_undo + text: 'undo' + on_active: root.parent.board.redraw() + CheckBoxHint: + size_hint: 0.2, 0.5 + text: 'lock\nai' + id: ai_lock + on_active: self.checkbox.disabled = hints.black.disabled = hints.white.disabled = ai_move.disabled = auto_undo.black.disabled = auto_undo.white.disabled = ai_auto.checkbox.disabled = True + CheckBoxHint: + size_hint: 0.2, 0.5 + id: ownership + text: 'show\nowner' + on_active: root.parent.board.redraw() + GridLayout: + cols: 4 + rows: 1 + size_hint: 1, 0.05 + StyledButton: + id: ai_move + size_hint: 0.5, 0.5 + text: 'AI Move' + on_press: root.action("aimove") + CheckBoxHint: + size_hint: 0.166, 0.5 + text: 'auto\nmove' + id: ai_auto + default_active: False + CheckBoxHint: + size_hint: 0.166, 0.5 + text: 'balance\nscore' + id: ai_balance + default_active: True + CheckBoxHint: + size_hint: 0.166, 0.5 + text: 'fast' + id: ai_fast + default_active: True + GridLayout: + cols: 2 + rows: 1 + size_hint: 1, 0.05 + StyledButton: + id: undo + text: 'Undo' + on_press: root.action("undo") + default_active: True + StyledButton: + text: 'Pass' + on_press: root.action("play",(None,None)) + CensorableLabel: + id: temperature + size_hint: 1, 0.025 + label: 'Temperature' + text: '...' + CensorableLabel: + id: evaluation + size_hint: 1, 0.025 + label: 'Evaluation' + text: '...' + CensorableLabel: + id: score + size_hint: 1, 0.025 + label: 'Score' + text: '...' + TextInput: + id: info + size_hint: 1, 0.2 + valign: 'middle' + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.05 + StyledButton: + text: 'Save' + size_hint: 0.37, 1 + on_press: info.text = root.sgf() + StyledButton: + text: 'Analyze' + size_hint: 0.49, 1 + margin: (0,1) + on_press: root.action("analyze-sgf",info.text) + GridLayout: + size_hint: 1, 0.05 + cols: 4 + rows: 1 + LargeLabel: + size_hint: 0.3, 0.25 + text: ' New\nGame' + font_size: 0.3*self.size[1] + StyledButton: + size_hint: 0.233, 1 + text: '9' + margin: (1,1) + on_press: root.restart(9) + StyledButton: + size_hint: 0.233, 1 + text: '13' + margin: (0,1) + on_press: root.restart(13) + StyledButton: + size_hint: 0.233, 1 + text: '19' + margin: (1,1) + on_press: root.restart(19) + +: + board: board + controls: controls + canvas.before: + Color: + rgba: 0.95, 0.95, 0.95, 1 + Rectangle: + pos: self.pos + size: self.size + Badukpan: + id: board + pos_hint: {"x":0, "top":0} + EngineControls: + id: controls + pos_hint: {"right":1, "top":1} + size_hint: (self.parent.width - self.parent.height)/self.parent.width, 1 + diff --git a/katrain.py b/katrain.py new file mode 100644 index 0000000..f2bc3df --- /dev/null +++ b/katrain.py @@ -0,0 +1,191 @@ +from kivy.app import App +from kivy.graphics import * +from kivy.properties import NumericProperty, ObjectProperty +from kivy.uix.floatlayout import FloatLayout +from kivy.uix.widget import Widget + +import math + +from controller import Config +from move import Move +from kivyutils import * + +# (;GM[1]SZ[9]KM[7.5]RU[JP];B[fe];W[de];B[ec];W[dc];B[eg];W[dg];B[dh];W[ed];B[fd];W[ef];B[ff];W[eb];B[fc];W[eh];B[fg];W[ch];B[ee];W[df];B[dd];W[cd];B[db];W[cc];B[cb];W[fb];B[gb];W[bb];B[ea];W[ca];B[fa];W[fh];B[gh];W[ba];B[fi];W[di];B[da];W[ed];B[bc];W[bd];B[dd];W[ei];B[gi];W[ed]) +# (;GM[1]SZ[19]KM[7.5]RU[JP];B[qd];W[pp];B[cd];W[cp];B[ec];W[od];B[oc];W[nc];B[pc];W[nd];B[qf];W[jc];B[eq];W[do];B[hq];W[jq];B[cr];W[qn];B[cj];W[cl];B[nq];W[oq];B[np];W[lp];B[cg];W[nn];B[lr];W[kq];B[mo];W[kn];B[qi];W[mn];B[hc];W[qk];B[lc];W[je];B[jb];W[kb];B[kc];W[ib];B[jd];W[ja];B[lb];W[id];B[kd];W[ic];B[oj];W[pd];B[qe];W[qc];B[qb];W[rc];B[rb];W[pb];B[ob];W[nb];B[pa];W[lf];B[la];W[ma];B[mj];W[nk];B[ge];W[hd];B[hg];W[fc];B[fb];W[gc];B[ed];W[og];B[of];W[nf];B[pg];W[fq];B[fp];W[er];B[fr];W[dq];B[gq];W[dr];B[eo];W[en];B[fn];W[fm];B[gn];W[gm];B[dn];W[em];B[co];W[dp];B[hn];W[ep];B[fq];W[fj];B[jo];W[jn];B[jr];W[kr];B[hr];W[fo];B[js];W[ks];B[iq];W[io];B[ng];W[mg];B[oh];W[ne];B[hm];W[hl];B[fg];W[ip];B[go];W[gs];B[fs];W[ok];B[mh];W[lh];B[li];W[hp];B[im];W[hs];B[il];W[ir];B[ke];W[kf];B[gp];W[bk];B[kl];W[bj];B[lo];W[ko];B[ll];W[ml];B[rj];W[rk];B[kh];W[ci];B[lg];W[if];B[hk];W[ei];B[gi];W[bg];B[bh];W[ch];B[bf];W[dg];B[cf];W[df];B[pj];W[pk];B[sk];W[sl];B[sj];W[rl];B[gb];W[hb];B[gl];W[fl];B[gj];W[de];B[bi];W[ai];B[ag];W[dd];B[dc];W[ce];B[be];W[cc];B[bd];W[qj];B[ri];W[eh];B[lm];W[ln];B[jg];W[jf];B[mf];W[me];B[ig];W[mk];B[lk];W[nj];B[ni];W[eo];B[mm];W[nm];B[fd];W[gd];B[hf];W[ga];B[ea];W[jm];B[jl];W[pe];B[fh];W[fi];B[es];W[ds];B[fk];W[ek];B[gk];W[mb];B[eg];W[oa];B[na];W[fe];B[he];W[oa];B[pb];W[ee];B[cb];W[ie];B[ff];W[dh];B[pf];W[mg];B[ej];W[dj];B[kg];W[mf];B[in];W[jp];B[na];W[aj];B[ah];W[oa];B[pq];W[or];B[na];W[ha];B[oa];W[fa];B[eb];W[];B[gr];W[is];B[oe];W[ho];B[km];W[ef]) + +COLORS = Config.get("ui")["stones"] +GHOST_ALPHA = Config.get("ui")["ghost_alpha"] + + +class Badukpan(Widget): + def __init__(self, **kwargs): + super(Badukpan, self).__init__(**kwargs) + self.ghost_stone = [] + self.gridpos = [] + self.grid_size = 0 + self.stone_size = 0 + self.last_eval = 0 + self.EVAL_COLORS = Config.get("ui")["eval_colors"] + self.EVAL_KNOTS = Config.get("ui")["eval_knots"] + self.EVAL_BOUNDS = Config.get("ui")["eval_bounds"] + + # stone placement functions + def _find_closest(self, pos): + return sorted([(abs(p - pos), i) for i, p in enumerate(self.gridpos)])[0] + + def on_touch_down(self, touch): + xd, xp = self._find_closest(touch.x) + yd, yp = self._find_closest(touch.y) + prevghost = self.ghost_stone + if self.engine.ready and max(yd, xd) < self.grid_size / 2 and (xp, yp) not in [(x, y) for _, x, y in self.engine.stones]: + self.ghost_stone = (xp, yp) + else: + self.ghost_stone = None + if prevghost != self.ghost_stone: + self.redraw() + + def on_touch_move(self, touch): # on_motion on_touch_move + return self.on_touch_down(touch) + + def on_touch_up(self, touch): + if self.ghost_stone: + self.engine.action("play", self.ghost_stone) + self.ghost_stone = None + self.redraw() + + # drawing functions + def on_size(self, *args): + self.draw_board() + self.redraw() + + def draw_stone(self, x, y, col, innercol=None, evalcol=None, evalsize=10.0): + draw_circle((self.gridpos[x], self.gridpos[y]), self.stone_size, col) + if evalcol: + evalsize = min(self.EVAL_BOUNDS[1], max(evalsize, self.EVAL_BOUNDS[0])) / self.EVAL_BOUNDS[1] + draw_circle((self.gridpos[x], self.gridpos[y]), math.sqrt(evalsize) * self.stone_size * 0.5, evalcol) + if innercol: + Color(*innercol) + Line(circle=(self.gridpos[x], self.gridpos[y], self.stone_size * 0.45 / 0.85), width=1.75) + + def _eval_spectrum(self, score): + score = max(0, score) + for i in range(len(self.EVAL_KNOTS) - 1): + if self.EVAL_KNOTS[i] <= score < self.EVAL_KNOTS[i + 1]: + t = (score - self.EVAL_KNOTS[i]) / (self.EVAL_KNOTS[i + 1] - self.EVAL_KNOTS[i]) + return [a + t * (b - a) for a, b in zip(self.EVAL_COLORS[i], self.EVAL_COLORS[i + 1])] + return self.EVAL_COLORS[-1] + + def draw_board(self): + self.canvas.before.clear() + with self.canvas.before: + # board + sz = self.height + Color(*Config.get("ui")["board_color"]) + board = Rectangle(pos=(0, 0), size=(sz, sz)) + + # grid lines + margin = Config.get("ui")["board_margin"] + self.grid_size = board.size[0] / (self.engine.boardsize - 1 + 1.5 * margin) + self.stone_size = self.grid_size * Config.get("ui")["stone_size"] + self.gridpos = [math.floor((margin + i) * self.grid_size + 0.5) for i in range(self.engine.boardsize)] + + line_color = Config.get("ui")["line_color"] + Color(*line_color) + lo, hi = self.gridpos[0], self.gridpos[-1] + for i in range(self.engine.boardsize): + Line(points=[(self.gridpos[i], lo), (self.gridpos[i], hi)]) + Line(points=[(lo, self.gridpos[i]), (hi, self.gridpos[i])]) + + # star points + star_point_pos = 3 if self.engine.boardsize <= 11 else 4 + starpt_size = self.grid_size * Config.get("ui")["starpoint_size"] + for x in [star_point_pos - 1, self.engine.boardsize - star_point_pos, int(self.engine.boardsize / 2)]: + for y in [star_point_pos - 1, self.engine.boardsize - star_point_pos, int(self.engine.boardsize / 2)]: + draw_circle((self.gridpos[x], self.gridpos[y]), starpt_size, line_color) + + # coordinates + Color(0.25, 0.25, 0.25) + for i in range(self.engine.boardsize): + draw_text(pos=(self.gridpos[i], lo / 2), text=Move.GTP_COORD[i], font_size=self.grid_size / 1.5) + draw_text(pos=(lo / 2, self.gridpos[i]), text=str(i + 1), font_size=self.grid_size / 1.5) + + def redraw(self): + self.canvas.clear() + with self.canvas: + # stones + last_move = self.engine.moves[-1].coords + eval_map = {m.coords: (m.evaluation, m.previous_temperature) for m in self.engine.moves} + eval_on = [self.engine.eval.active(0), self.engine.eval.active(1)] + has_stone = {} + for i, (ci, x, y) in enumerate(self.engine.stones): + has_stone[(x, y)] = ci + eval, evalsize = eval_map.get((x, y), (None, None)) + evalcol = self._eval_spectrum(eval) if eval_on[ci] and eval else None + inner = COLORS[1 - ci] if ((x, y) == last_move) else None + self.draw_stone(x, y, COLORS[ci], inner, evalcol, evalsize) + + # ownership + ownership = self.engine.moves[-1].ownership + if self.engine.ownership.active and ownership: + rsz = self.grid_size * 0.2 + ix = 0 + cp = self.engine.current_player + for y in range(self.engine.boardsize - 1, -1, -1): + for x in range(self.engine.boardsize): + ix_owner = cp if ownership[ix] > 0 else 1 - cp + if ix_owner != (has_stone.get((x, y), -1)): + Color(*COLORS[ix_owner], abs(ownership[ix])) + Rectangle(pos=(self.gridpos[x] - rsz / 2, self.gridpos[y] - rsz / 2), size=(rsz, rsz)) + ix = ix + 1 + + # undos + undo_coords = set() + alpha = Config.get("ui")["undo_alpha"] + for m in self.engine.moves[-1].undos: + if m.evaluation and m.coords[0] is not None: + undo_coords.add(m.coords) + evalcol = (*self._eval_spectrum(m.evaluation), alpha) + self.draw_stone(m.coords[0], m.coords[1], (*COLORS[m.player][:3], alpha), Config.get("ui")["undo_circle_col"], evalcol, self.EVAL_BOUNDS[1]) + + # hints + if self.engine.moves[-1].analysis and self.engine.hints.active(self.engine.current_player): + for d in self.engine.moves[-1].analysis: + move = Move(gtpcoords=d["move"], player=0) + c = [*self._eval_spectrum(d["evaluation"]), 0.5] + if move.coords[0] is not None and move.coords not in undo_coords: + self.draw_stone(move.coords[0], move.coords[1], c) + + # hover next move ghost stone + if self.ghost_stone: + self.draw_stone(*self.ghost_stone, (*COLORS[self.engine.current_player], GHOST_ALPHA)) + + # pass circle + passed = len(self.engine.moves) > 1 and self.engine.moves[-1].gtp() == "pass" + if passed: + if len(self.engine.moves) > 2 and self.engine.moves[-2].gtp() == "pass": + text = "game\nend" + else: + text = "pass" + Color(0.45, 0.05, 0.45, 0.5) + center = self.gridpos[int(self.engine.boardsize / 2)] + Ellipse(pos=(center - self.grid_size * 1.5, center - self.grid_size * 1.5), size=(self.grid_size * 3, self.grid_size * 3)) + Color(0.15, 0.15, 0.15) + draw_text(pos=(center, center), text=text, font_size=self.grid_size * 0.66, halign="center", outline_color=[0.95, 0.95, 0.95]) + + +class KaTrainGui(FloatLayout): + pass + + +class KaTrainApp(App): + def build(self): + self.icon = "./icon.png" + self.gui = KaTrainGui() + return self.gui + + def on_start(self): + self.gui.controls.restart() + + +if __name__ == "__main__": + KaTrainApp().run() diff --git a/kivyutils.py b/kivyutils.py new file mode 100644 index 0000000..4aa6d68 --- /dev/null +++ b/kivyutils.py @@ -0,0 +1,41 @@ +from kivy.core.text import Label as CoreLabel +from kivy.graphics import * +from kivy.uix.boxlayout import BoxLayout + + +class CheckBoxHint(BoxLayout): + __events__ = ("on_active",) + + @property + def active(self): + return self.checkbox.active + + def on_active(self, *args): + pass + + +class BWCheckBoxHint(BoxLayout): + __events__ = ("on_active",) + + def active(self, player): + return [self.black, self.white][player].active + + def on_active(self, *args): + pass + + +class CensorableLabel(BoxLayout): + @property + def text(self): + return self.value.text + + +def draw_text(pos, text, **kw): + label = CoreLabel(text=text, bold=True, **kw) + label.refresh() + Rectangle(texture=label.texture, pos=(pos[0] - label.texture.size[0] / 2, pos[1] - label.texture.size[1] / 2), size=label.texture.size) + + +def draw_circle(pos, r, col): + Color(*col) + Ellipse(pos=(pos[0] - r, pos[1] - r), size=(2 * r, 2 * r)) diff --git a/models/b10.gz b/models/b10.gz new file mode 100644 index 0000000..c984a32 Binary files /dev/null and b/models/b10.gz differ diff --git a/move.py b/move.py new file mode 100644 index 0000000..395cc93 --- /dev/null +++ b/move.py @@ -0,0 +1,82 @@ +from kivy.storage.jsonstore import JsonStore + + +class Move: + GTP_COORD = "ABCDEFGHJKLMNOPQRSTUVWYXYZ" + PLAYERS = "BW" + SGF_COORD = [chr(i) for i in range(97, 123)] + + def __init__(self, player, coords=None, gtpcoords=None, sgfcoords=None, robot=False): + self.player = player + self.robot = robot + self.coords = coords or (gtpcoords and self.gtp2ix(gtpcoords)) or self.sgf2ix(sgfcoords) + self.analysis = None + self.outdated_evaluation = None + self.pass_analysis = None + self.evaluation = None + self.ownership = None + self.points_lost = 0 + self.previous_temperature = None + self.undos = [] + self.comment = "" + + def __repr__(self): + return f"{Move.PLAYERS[self.player]}{self.gtp()}" + + def temperature(self): + if self.analysis: + best_score = float(self.analysis[0]["scoreMean"]) + worst_score = -float(self.pass_analysis[0]["scoreMean"]) + return best_score - worst_score + else: + return 0 + + def evaluate(self, previous_move): + best_score = float(previous_move.analysis[0]["scoreMean"]) + worst_score = -float(previous_move.pass_analysis[0]["scoreMean"]) + last_move_score = -float(self.analysis[0]["scoreMean"]) + self.previous_temperature = best_score - worst_score + self.points_lost = best_score - last_move_score + prev_analysis_current_move = [d for d in previous_move.analysis if d["move"] == self.gtp()] + + if abs(self.previous_temperature) > 0.5: + self.evaluation = (last_move_score - worst_score) / (best_score - worst_score) + self.move_options = [previous_move.analysis[0]["scoreMean"]] + else: + self.evaluation = None + if self.evaluation: + self.comment = f"Evaluation: {100*self.evaluation:.1f}%{' (AI Move)' if self.robot else ''}\n" + if prev_analysis_current_move: + self.outdated_evaluation = (prev_analysis_current_move[0]["scoreMean"] - worst_score) / (best_score - worst_score) + self.comment += f"(Was considered last move as: {100 * self.outdated_evaluation:.1f}%)\n" + else: + self.comment = "Temperature too low for evaluation\n" + self.comment += f"Estimate point loss: {self.points_lost:.1f}\n" + self.comment += f"Last move score was {last_move_score:.1f}\n" + self.comment += f"Score of top move was {previous_move.analysis[0]['scoreMean']:.1f} @ {previous_move.analysis[0]['move']}\n" + self.comment += f"Pass score was {worst_score:.1f}\n" + + def gtp2ix(self, gtpmove): + if "pass" in gtpmove: + return (None, None) + return Move.GTP_COORD.index(gtpmove[0]), int(gtpmove[1:]) - 1 + + def sgf2ix(self, sgfmove_with_boardsize): + sgfmove, boardsize = sgfmove_with_boardsize + if sgfmove == "": + return (None, None) + return Move.SGF_COORD.index(sgfmove[0]), boardsize - Move.SGF_COORD.index(sgfmove[1]) - 1 + + def gtp(self): + if self.coords[0] is None: + return "pass" + return Move.GTP_COORD[self.coords[0]] + str(self.coords[1] + 1) + + def sgfcoords(self, boardsize): + return f"{Move.SGF_COORD[self.coords[0]]}{Move.SGF_COORD[boardsize - self.coords[1] - 1]}" + + def sgf(self, boardsize): + if self.coords[0] is None: + return f"{Move.PLAYERS[self.player]}[]" + else: + return f"{Move.PLAYERS[self.player]}[{self.sgfcoords(boardsize)}]"