289 lines
14 KiB
Python
289 lines
14 KiB
Python
import re
|
|
import random
|
|
import shlex
|
|
import subprocess
|
|
import threading
|
|
import json
|
|
import time
|
|
from queue import Queue
|
|
from move import Move, MoveTree
|
|
|
|
|
|
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.move_tree = MoveTree()
|
|
|
|
self.kata = None
|
|
|
|
def current_player(self):
|
|
return self.move_tree.current_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")
|
|
info = self.gtpread() # new kata
|
|
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.move_tree.undo()
|
|
else:
|
|
self.move_tree.play(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.move_tree[-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.move_tree = MoveTree()
|
|
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:
|
|
undo_mode = 0 # reverse order mode
|
|
self.raw_gtpplaycommand("undo")
|
|
self.raw_gtpplaycommand("undo")
|
|
self.raw_gtpplaycommand(passmove)
|
|
if not self.raw_gtpplaycommand(self.moves[-1]): # could not change order -> restore state and fall back
|
|
undo_mode = 1
|
|
self.raw_gtpplaycommand("undo") # pass
|
|
self.raw_gtpplaycommand(self.moves[-2])
|
|
self.raw_gtpplaycommand(self.moves[-1])
|
|
elif not self.raw_gtpplaycommand(self.moves[-2]): # could not change order -> restore state and fall back
|
|
undo_mode = 1
|
|
self.raw_gtpplaycommand("undo") # moves[-1]
|
|
self.raw_gtpplaycommand("undo") # pass
|
|
self.raw_gtpplaycommand(self.moves[-2])
|
|
self.raw_gtpplaycommand(self.moves[-1])
|
|
else:
|
|
undo_mode = 1 # play corner for pass mode
|
|
if 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
|