* add numpy * fix rotation on new game * fix wide root zero * edit INSTALL.md * force hints on only if starting pondering * readme * edit README.md * Check that the platform is Windows, and the user32 dll has the SetProcessDpiAwarenessContext function (should exist for all versions >= Vista). Prevents blurry Kivy windows on high dpi displays. Reference: https://stackoverflow.com/questions/71704354/kivy-app-is-blurry-on-windows-with-high-resolution-screen * Prevents blurry Kivy windows on high dpi displays. Check that the platform is Windows, and the user32 dll has the SetProcessDpiAwarenessContext function (should exist for all versions >= Vista). Reference: https://stackoverflow.com/questions/71704354/kivy-app-is-blurry-on-windows-with-high-resolution-screen * Use kivy.platform instead of the platform package. * Fix bug when the board size is not square. * Set log level back to "warning" (was changed by mistake in a previous commit). * change pyinstaller icon file paths from fully qualified to relative, pointing to the ico file in the repository * add VSVersionInfo metadata to Windows executable file from PyInstaller * replace importLib modules with path modification and regular imports * Initial code for analysis in move range (not working yet). * Initial working code for move range analysis. Work in progress, i18n not done. * Initial working code for move range analysis. Work in progress, i18n not done. * Get rid of unnecessary code for getting dialog subwidgets. * Implemented greyed-out inputs when the move range checkbox is not selected. * Fix spacing which was changed by accident. * edit katrain/popups.kv, edit katrain/gui/popups.py * Fix invocation of game analysis. * Added i18n for the move range analysis. i18n done for Russian. * TODO removed for Russian. * Translated remaining Russian strings which were TODO:. * French strings for move range analysis. * Change the way analyze_extra is called when move range is specified. * scale=0 turns off * allow 3.10 * edit .github/workflows/test.yaml * Blended territory display (#556) * Check that the platform is Windows, and the user32 dll has the SetProcessDpiAwarenessContext function (should exist for all versions >= Vista). Prevents blurry Kivy windows on high dpi displays. Reference: https://stackoverflow.com/questions/71704354/kivy-app-is-blurry-on-windows-with-high-resolution-screen * Prevents blurry Kivy windows on high dpi displays. Check that the platform is Windows, and the user32 dll has the SetProcessDpiAwarenessContext function (should exist for all versions >= Vista). Reference: https://stackoverflow.com/questions/71704354/kivy-app-is-blurry-on-windows-with-high-resolution-screen * Changed territory display to be smoothly blended, for player expected territories and for loss in teaching games. Added an ownership mark to stones while displaying territory; the color of the mark indicates expected ownership, and the size of the mark is proportional to certainty. * Fixed bug: blended territory display does not work with rotation. * Revert "Prevents blurry Kivy windows on high dpi displays." This reverts commit 1cff741e3f7b6bcd8fe7d25cdc9a3fddbcaea242. * Revert "Check that the platform is Windows, and the user32 dll has the SetProcessDpiAwarenessContext function (should exist for all versions >= Vista)." This reverts commit c9ae4f6432efe566bed11eb4a9575b19df0a0db2. * Changed territory display to be smoothly blended, for player expected territories and for loss in teaching games. Added an ownership mark to stones while displaying territory; the color of the mark indicates expected ownership, and the size of the mark is proportional to certainty. Rebased blended territory display on the 1.12 branch. * Fixed bug: blended territory display does not work with rotation. Fixed for non-square board and rebased. * Revert "Prevents blurry Kivy windows on high dpi displays." This reverts commit 1cff741e3f7b6bcd8fe7d25cdc9a3fddbcaea242. * Revert "Check that the platform is Windows, and the user32 dll has the SetProcessDpiAwarenessContext function (should exist for all versions >= Vista)." This reverts commit c9ae4f6432efe566bed11eb4a9575b19df0a0db2. * Make blended territory work with non-square boards and their rotations. * Whitespace. * Make blended territory work with non-square boards and their rotations. * Change marks on stones from circles to squares. * Add Theme settings enabling different types of territory and stone marks displays. * Add Theme settings enabling different types of territory and stone marks displays. * Reformatted with black -l 120. * Documentation for territory display styles and themes. * Added acknowledgement for game used in screenshots. * spacing * Some cleanup of Theme variables. * Switch default mode back to "blended". * Added screenshot of blended style - weak stone marks. * Fix bold text. Co-authored-by: Jacob Minsky <jacob.minsky@gmail.com> * fix test yaml * edit spec/file_version.py, edit katrain/core/game.py and 3 other changes * edit katrain/core/constants.py * Fixes and enhancements for the blended territory feature. (#564) * Fix newly placed stone getting transparency before ownership is updated. * Set black and white territory colors separately. * Try dimming board when territory display is active. * Change tint of the board when in territory estimate mode and style is "blended". * Fix bug when loading SGF with initial position setup. * Revert "Change tint of the board when in territory estimate mode and style is "blended"." This reverts commit d5b46c8966d02b8660a2fe88e4c8d882af43f51f. * Marks on stone should be stone colors, not ownership colors - which look bad. * Version with new board texture (wood6.jpg from https://github.com/waltheri/wgo.js/tree/master/textures) and changed territory color parameters. * Added gamma-correction to territory coloring. * Add acknowledgement for board texture. Co-authored-by: rzcp66 <jacob.minsky@gm.com> * Remove numpy (#570) * Fix newly placed stone getting transparency before ownership is updated. * Set black and white territory colors separately. * Try dimming board when territory display is active. * Change tint of the board when in territory estimate mode and style is "blended". * Fix bug when loading SGF with initial position setup. * Revert "Change tint of the board when in territory estimate mode and style is "blended"." This reverts commit d5b46c8966d02b8660a2fe88e4c8d882af43f51f. * Marks on stone should be stone colors, not ownership colors - which look bad. * Version with new board texture (wood6.jpg from https://github.com/waltheri/wgo.js/tree/master/textures) and changed territory color parameters. * Added gamma-correction to territory coloring. * Eliminates numpy; working version with rotation without numpy. * Fixed stuff related to hover content and board rotation. * Bug fix for roi selection without numpy. * Added comment for rot90 implementation with lists. * Use reversed() instead of [::-1] for clarity. Co-authored-by: rzcp66 <jacob.minsky@gm.com> * Fix region of interest display when board is rotated. (#572) * themes Co-authored-by: Sander Land <sander@chatdesk.com> Co-authored-by: Sander Land <sander.land@cognite.com> Co-authored-by: Jacob Minsky <jacob.minsky@gmail.com> Co-authored-by: ulty4life <ulty4life@gmail.com> Co-authored-by: Jacob Minsky <35696962+jacobm-tech@users.noreply.github.com> Co-authored-by: rzcp66 <jacob.minsky@gm.com>
804 lines
34 KiB
Python
804 lines
34 KiB
Python
import copy
|
|
import math
|
|
import os
|
|
import re
|
|
import threading
|
|
from datetime import datetime
|
|
from typing import Dict, List, Optional, Union
|
|
|
|
from kivy.clock import Clock
|
|
|
|
from katrain.core.constants import (
|
|
OUTPUT_DEBUG,
|
|
OUTPUT_EXTRA_DEBUG,
|
|
OUTPUT_INFO,
|
|
PLAYER_AI,
|
|
PLAYER_HUMAN,
|
|
PROGRAM_NAME,
|
|
SGF_INTERNAL_COMMENTS_MARKER,
|
|
STATUS_ANALYSIS,
|
|
STATUS_ERROR,
|
|
STATUS_INFO,
|
|
STATUS_TEACHING,
|
|
PRIORITY_GAME_ANALYSIS,
|
|
PRIORITY_EXTRA_ANALYSIS,
|
|
PRIORITY_SWEEP,
|
|
PRIORITY_ALTERNATIVES,
|
|
PRIORITY_EQUALIZE,
|
|
PRIORITY_DEFAULT,
|
|
)
|
|
from katrain.core.engine import KataGoEngine
|
|
from katrain.core.game_node import GameNode
|
|
from katrain.core.lang import i18n, rank_label
|
|
from katrain.core.sgf_parser import SGF, Move
|
|
from katrain.core.utils import var_to_grid, weighted_selection_without_replacement
|
|
|
|
|
|
class IllegalMoveException(Exception):
|
|
pass
|
|
|
|
|
|
class KaTrainSGF(SGF):
|
|
_NODE_CLASS = GameNode
|
|
|
|
|
|
class BaseGame:
|
|
"""Represents a game of go, including an implementation of capture rules."""
|
|
|
|
DEFAULT_PROPERTIES = {"GM": 1, "FF": 4}
|
|
|
|
def __init__(
|
|
self,
|
|
katrain,
|
|
move_tree: GameNode = None,
|
|
game_properties: Optional[Dict] = None,
|
|
sgf_filename=None,
|
|
bypass_config=False, # TODO: refactor?
|
|
):
|
|
self.katrain = katrain
|
|
self._lock = threading.Lock()
|
|
self.game_id = datetime.strftime(datetime.now(), "%Y-%m-%d %H %M %S")
|
|
self.sgf_filename = sgf_filename
|
|
|
|
self.insert_mode = False
|
|
self.external_game = False # not generated by katrain at some point
|
|
|
|
if move_tree:
|
|
self.root = move_tree
|
|
self.external_game = PROGRAM_NAME not in self.root.get_property("AP", "")
|
|
handicap = int(self.root.handicap)
|
|
num_starting_moves_black = 0
|
|
node = self.root
|
|
while node.children:
|
|
node = node.children[0]
|
|
if node.player == "B":
|
|
num_starting_moves_black += 1
|
|
else:
|
|
break
|
|
|
|
if (
|
|
handicap >= 2
|
|
and not self.root.placements
|
|
and not (num_starting_moves_black == handicap)
|
|
and not (self.root.children and self.root.children[0].placements)
|
|
): # not really according to sgf, and not sure if still needed, last clause for fox
|
|
self.root.place_handicap_stones(handicap)
|
|
else:
|
|
default_properties = {**Game.DEFAULT_PROPERTIES, "DT": self.game_id}
|
|
if not bypass_config:
|
|
default_properties.update(
|
|
{
|
|
"SZ": katrain.config("game/size"),
|
|
"KM": katrain.config("game/komi"),
|
|
"RU": katrain.config("game/rules"),
|
|
}
|
|
)
|
|
self.root = GameNode(
|
|
properties={
|
|
**default_properties,
|
|
**(game_properties or {}),
|
|
}
|
|
)
|
|
handicap = katrain.config("game/handicap")
|
|
if not bypass_config and handicap:
|
|
self.root.place_handicap_stones(handicap)
|
|
|
|
if not self.root.get_property("RU"): # if rules missing in sgf, inherit current
|
|
self.root.set_property("RU", katrain.config("game/rules"))
|
|
|
|
self.set_current_node(self.root)
|
|
self.main_time_used = 0
|
|
|
|
# restore shortcuts
|
|
shortcut_id_to_node = {node.get_property("KTSID", None): node for node in self.root.nodes_in_tree}
|
|
for node in self.root.nodes_in_tree:
|
|
shortcut_id = node.get_property("KTSF", None)
|
|
if shortcut_id and shortcut_id in shortcut_id_to_node:
|
|
shortcut_id_to_node[shortcut_id].add_shortcut(node)
|
|
|
|
# -- move tree functions --
|
|
def _init_state(self):
|
|
board_size_x, board_size_y = self.board_size
|
|
self.board = [
|
|
[-1 for _x in range(board_size_x)] for _y in range(board_size_y)
|
|
] # type: List[List[int]] # board pos -> chain id
|
|
self.chains = [] # type: List[List[Move]] # chain id -> chain
|
|
self.prisoners = [] # type: List[Move]
|
|
self.last_capture = [] # type: List[Move]
|
|
|
|
def _calculate_groups(self):
|
|
with self._lock:
|
|
self._init_state()
|
|
try:
|
|
for node in self.current_node.nodes_from_root:
|
|
for m in node.move_with_placements:
|
|
self._validate_move_and_update_chains(
|
|
m, True
|
|
) # ignore ko since we didn't know if it was forced
|
|
if node.clear_placements: # handle AE by playing all moves left from empty board
|
|
clear_coords = {c.coords for c in node.clear_placements}
|
|
stones = [m for c in self.chains for m in c if m.coords not in clear_coords]
|
|
self._init_state()
|
|
for m in stones:
|
|
self._validate_move_and_update_chains(m, True)
|
|
except IllegalMoveException as e:
|
|
raise Exception(f"Unexpected illegal move ({str(e)})")
|
|
|
|
def _validate_move_and_update_chains(self, move: Move, ignore_ko: bool):
|
|
board_size_x, board_size_y = self.board_size
|
|
|
|
def neighbours(moves):
|
|
return {
|
|
self.board[m.coords[1] + dy][m.coords[0] + dx]
|
|
for m in moves
|
|
for dy, dx in [(-1, 0), (1, 0), (0, -1), (0, 1)]
|
|
if 0 <= m.coords[0] + dx < board_size_x and 0 <= m.coords[1] + dy < board_size_y
|
|
}
|
|
|
|
ko_or_snapback = len(self.last_capture) == 1 and self.last_capture[0] == move
|
|
self.last_capture = []
|
|
|
|
if move.is_pass:
|
|
return
|
|
|
|
if self.board[move.coords[1]][move.coords[0]] != -1:
|
|
raise IllegalMoveException("Space occupied")
|
|
|
|
# merge chains connected by this move, or create a new one
|
|
nb_chains = list({c for c in neighbours([move]) if c >= 0 and self.chains[c][0].player == move.player})
|
|
if nb_chains:
|
|
this_chain = nb_chains[0]
|
|
self.board = [[nb_chains[0] if sq in nb_chains else sq for sq in line] for line in self.board]
|
|
for oc in nb_chains[1:]:
|
|
self.chains[nb_chains[0]] += self.chains[oc]
|
|
self.chains[oc] = []
|
|
self.chains[nb_chains[0]].append(move)
|
|
else:
|
|
this_chain = len(self.chains)
|
|
self.chains.append([move])
|
|
self.board[move.coords[1]][move.coords[0]] = this_chain
|
|
|
|
# check captures
|
|
opp_nb_chains = {c for c in neighbours([move]) if c >= 0 and self.chains[c][0].player != move.player}
|
|
for c in opp_nb_chains:
|
|
if -1 not in neighbours(self.chains[c]): # no liberties
|
|
self.last_capture += self.chains[c]
|
|
for om in self.chains[c]:
|
|
self.board[om.coords[1]][om.coords[0]] = -1
|
|
self.chains[c] = []
|
|
if ko_or_snapback and len(self.last_capture) == 1 and not ignore_ko:
|
|
raise IllegalMoveException("Ko")
|
|
self.prisoners += self.last_capture
|
|
|
|
# suicide: check rules and throw exception if needed
|
|
if -1 not in neighbours(self.chains[this_chain]):
|
|
rules = self.rules
|
|
if len(self.chains[this_chain]) == 1: # even in new zealand rules, single stone suicide is not allowed
|
|
raise IllegalMoveException("Single stone suicide")
|
|
elif (isinstance(rules, str) and rules in ["tromp-taylor", "new zealand"]) or (
|
|
isinstance(rules, dict) and rules.get("suicide", False)
|
|
):
|
|
self.last_capture += self.chains[this_chain]
|
|
for om in self.chains[this_chain]:
|
|
self.board[om.coords[1]][om.coords[0]] = -1
|
|
self.chains[this_chain] = []
|
|
self.prisoners += self.last_capture
|
|
else: # suicide not allowed by rules
|
|
raise IllegalMoveException("Suicide")
|
|
|
|
# Play a Move from the current position, raise IllegalMoveException if invalid.
|
|
def play(self, move: Move, ignore_ko: bool = False):
|
|
board_size_x, board_size_y = self.board_size
|
|
if not move.is_pass and not (0 <= move.coords[0] < board_size_x and 0 <= move.coords[1] < board_size_y):
|
|
raise IllegalMoveException(f"Move {move} outside of board coordinates")
|
|
try:
|
|
self._validate_move_and_update_chains(move, ignore_ko)
|
|
except IllegalMoveException:
|
|
self._calculate_groups()
|
|
raise
|
|
with self._lock:
|
|
played_node = self.current_node.play(move)
|
|
self.current_node = played_node
|
|
return played_node
|
|
|
|
# Insert a list of moves from root, often just adding one.
|
|
def sync_branch(self, moves: List[Move]):
|
|
node = self.root
|
|
with self._lock:
|
|
for move in moves:
|
|
node = node.play(move)
|
|
return node
|
|
|
|
def set_current_node(self, node):
|
|
self.current_node = node
|
|
self._calculate_groups()
|
|
|
|
def undo(self, n_times=1, stop_on_mistake=None):
|
|
break_on_branch = False
|
|
cn = self.current_node # avoid race conditions
|
|
break_on_main_branch = False
|
|
last_branching_node = cn
|
|
if n_times == "branch":
|
|
n_times = 9999
|
|
break_on_branch = True
|
|
elif n_times == "main-branch":
|
|
n_times = 9999
|
|
break_on_main_branch = True
|
|
for move in range(n_times):
|
|
if (
|
|
stop_on_mistake is not None
|
|
and cn.points_lost is not None
|
|
and cn.points_lost >= stop_on_mistake
|
|
and self.katrain.players_info[cn.player].player_type != PLAYER_AI
|
|
):
|
|
self.set_current_node(cn.parent)
|
|
return
|
|
previous_cn = cn
|
|
if cn.shortcut_from:
|
|
cn = cn.shortcut_from
|
|
elif not cn.is_root:
|
|
cn = cn.parent
|
|
else:
|
|
break # root
|
|
if break_on_branch and len(cn.children) > 1:
|
|
break
|
|
elif break_on_main_branch and cn.ordered_children[0] != previous_cn: # implies > 1 child
|
|
last_branching_node = cn
|
|
if break_on_main_branch:
|
|
cn = last_branching_node
|
|
if cn is not self.current_node:
|
|
self.set_current_node(cn)
|
|
|
|
def redo(self, n_times=1, stop_on_mistake=None):
|
|
cn = self.current_node # avoid race conditions
|
|
for move in range(n_times):
|
|
if cn.children:
|
|
child = cn.ordered_children[0]
|
|
shortcut_to = [m for m, v in cn.shortcuts_to if child == v] # are we about to go to a shortcut node?
|
|
if shortcut_to:
|
|
child = shortcut_to[0]
|
|
cn = child
|
|
if (
|
|
move > 0
|
|
and stop_on_mistake is not None
|
|
and cn.points_lost is not None
|
|
and cn.points_lost >= stop_on_mistake
|
|
and self.katrain.players_info[cn.player].player_type != PLAYER_AI
|
|
):
|
|
self.set_current_node(cn.parent)
|
|
return
|
|
if stop_on_mistake is None:
|
|
self.set_current_node(cn)
|
|
|
|
@property
|
|
def komi(self):
|
|
return self.root.komi
|
|
|
|
@property
|
|
def board_size(self):
|
|
return self.root.board_size
|
|
|
|
@property
|
|
def stones(self):
|
|
with self._lock:
|
|
return sum(self.chains, [])
|
|
|
|
@property
|
|
def end_result(self):
|
|
if self.current_node.end_state:
|
|
return self.current_node.end_state
|
|
if self.current_node.parent and self.current_node.is_pass and self.current_node.parent.is_pass:
|
|
return self.manual_score or i18n._("board-game-end")
|
|
|
|
@property
|
|
def prisoner_count(
|
|
self,
|
|
) -> Dict: # returns prisoners that are of a certain colour as {B: black stones captures, W: white stones captures}
|
|
return {player: sum([m.player == player for m in self.prisoners]) for player in Move.PLAYERS}
|
|
|
|
@property
|
|
def rules(self):
|
|
return KataGoEngine.get_rules(self.root.ruleset)
|
|
|
|
@property
|
|
def manual_score(self):
|
|
rules = self.rules
|
|
if (
|
|
not self.current_node.ownership
|
|
or str(rules).lower() not in ["jp", "japanese"]
|
|
or not self.current_node.parent
|
|
or not self.current_node.parent.ownership
|
|
):
|
|
if not self.current_node.score:
|
|
return None
|
|
return self.current_node.format_score(round(2 * self.current_node.score) / 2) + "?"
|
|
board_size_x, board_size_y = self.board_size
|
|
mean_ownership = [(c + p) / 2 for c, p in zip(self.current_node.ownership, self.current_node.parent.ownership)]
|
|
ownership_grid = var_to_grid(mean_ownership, (board_size_x, board_size_y))
|
|
stones = {m.coords: m.player for m in self.stones}
|
|
lo_threshold = 0.15
|
|
hi_threshold = 0.85
|
|
max_unknown = 10
|
|
max_dame = 4 * (board_size_x + board_size_y)
|
|
|
|
def japanese_score_square(square, owner):
|
|
player = stones.get(square, None)
|
|
if (
|
|
(player == "B" and owner > hi_threshold)
|
|
or (player == "W" and owner < -hi_threshold)
|
|
or abs(owner) < lo_threshold
|
|
):
|
|
return 0 # dame or own stones
|
|
if player is None and abs(owner) >= hi_threshold:
|
|
return round(owner) # surrounded empty intersection
|
|
if (player == "B" and owner < -hi_threshold) or (player == "W" and owner > hi_threshold):
|
|
return 2 * round(owner) # captured stone
|
|
return math.nan # unknown!
|
|
|
|
scored_squares = [
|
|
japanese_score_square((x, y), ownership_grid[y][x])
|
|
for y in range(board_size_y)
|
|
for x in range(board_size_x)
|
|
]
|
|
num_sq = {t: sum([s == t for s in scored_squares]) for t in [-2, -1, 0, 1, 2]}
|
|
num_unkn = sum(math.isnan(s) for s in scored_squares)
|
|
prisoners = self.prisoner_count
|
|
score = sum([t * n for t, n in num_sq.items()]) + prisoners["W"] - prisoners["B"] - self.komi
|
|
self.katrain.log(
|
|
f"Manual Scoring: {num_sq} score by square with {num_unkn} unknown, {prisoners} captures, and {self.komi} komi -> score = {score}",
|
|
OUTPUT_DEBUG,
|
|
)
|
|
if num_unkn > max_unknown or (num_sq[0] - len(stones)) > max_dame:
|
|
return None
|
|
return self.current_node.format_score(score)
|
|
|
|
def __repr__(self):
|
|
return (
|
|
"\n".join("".join(self.chains[c][0].player if c >= 0 else "-" for c in line) for line in self.board)
|
|
+ f"\ncaptures: {self.prisoner_count}"
|
|
)
|
|
|
|
def update_root_properties(self):
|
|
def player_name(player_info):
|
|
if player_info.name and player_info.player_type == PLAYER_HUMAN:
|
|
return player_info.name
|
|
else:
|
|
return f"{i18n._(player_info.player_type)} ({i18n._(player_info.player_subtype)}){SGF_INTERNAL_COMMENTS_MARKER}"
|
|
|
|
root_properties = self.root.properties
|
|
x_properties = {}
|
|
for bw in "BW":
|
|
if not self.external_game:
|
|
x_properties["P" + bw] = player_name(self.katrain.players_info[bw])
|
|
player_info = self.katrain.players_info[bw]
|
|
if player_info.player_type == PLAYER_AI:
|
|
x_properties[bw + "R"] = rank_label(player_info.calculated_rank)
|
|
if "+" in str(self.end_result):
|
|
x_properties["RE"] = self.end_result
|
|
self.root.properties = {**root_properties, **{k: [v] for k, v in x_properties.items()}}
|
|
|
|
def generate_filename(self):
|
|
self.update_root_properties()
|
|
player_names = {
|
|
bw: re.sub(r"[\u200b\u3164'<>:\"/\\|?*]", "", self.root.get_property("P" + bw, bw)) for bw in "BW"
|
|
}
|
|
base_game_name = f"{PROGRAM_NAME}_{player_names['B']} vs {player_names['W']}"
|
|
return f"{base_game_name} {self.game_id}.sgf"
|
|
|
|
def write_sgf(self, filename: str, trainer_config: Optional[Dict] = None):
|
|
if trainer_config is None:
|
|
trainer_config = self.katrain.config("trainer", {})
|
|
save_feedback = trainer_config.get("save_feedback", False)
|
|
eval_thresholds = trainer_config["eval_thresholds"]
|
|
save_analysis = trainer_config.get("save_analysis", False)
|
|
save_marks = trainer_config.get("save_marks", False)
|
|
self.update_root_properties()
|
|
show_dots_for = {
|
|
bw: trainer_config.get("eval_show_ai", True) or self.katrain.players_info[bw].human for bw in "BW"
|
|
}
|
|
sgf = self.root.sgf(
|
|
save_comments_player=show_dots_for,
|
|
save_comments_class=save_feedback,
|
|
eval_thresholds=eval_thresholds,
|
|
save_analysis=save_analysis,
|
|
save_marks=save_marks,
|
|
)
|
|
self.sgf_filename = filename
|
|
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
|
with open(filename, "w", encoding="utf-8") as f:
|
|
f.write(sgf)
|
|
return i18n._("sgf written").format(file_name=filename)
|
|
|
|
|
|
class Game(BaseGame):
|
|
"""Extensions related to analysis etc."""
|
|
|
|
def __init__(
|
|
self,
|
|
katrain,
|
|
engine: Union[Dict, KataGoEngine],
|
|
move_tree: GameNode = None,
|
|
analyze_fast=False,
|
|
game_properties: Optional[Dict] = None,
|
|
sgf_filename=None,
|
|
):
|
|
super().__init__(
|
|
katrain=katrain, move_tree=move_tree, game_properties=game_properties, sgf_filename=sgf_filename
|
|
)
|
|
if not isinstance(engine, Dict):
|
|
engine = {"B": engine, "W": engine}
|
|
self.engines = engine
|
|
|
|
self.insert_mode = False
|
|
self.insert_after = None
|
|
self.region_of_interest = None
|
|
|
|
threading.Thread(
|
|
target=lambda: self.analyze_all_nodes(analyze_fast=analyze_fast, even_if_present=False),
|
|
daemon=True,
|
|
).start() # return faster, but bypass Kivy Clock
|
|
|
|
def analyze_all_nodes(self, priority=PRIORITY_GAME_ANALYSIS, analyze_fast=False, even_if_present=True):
|
|
for node in self.root.nodes_in_tree:
|
|
# forced, or not present, or something went wrong in loading
|
|
if even_if_present or not node.analysis_from_sgf or not node.load_analysis():
|
|
node.clear_analysis()
|
|
node.analyze(self.engines[node.next_player], priority=priority, analyze_fast=analyze_fast)
|
|
|
|
def set_current_node(self, node):
|
|
if self.insert_mode:
|
|
self.katrain.controls.set_status(i18n._("finish inserting before navigating"), STATUS_ERROR)
|
|
return
|
|
super().set_current_node(node)
|
|
|
|
def undo(self, n_times=1, stop_on_mistake=None):
|
|
if self.insert_mode: # in insert mode, undo = delete
|
|
cn = self.current_node # avoid race conditions
|
|
if n_times == 1 and cn not in self.insert_after.nodes_from_root:
|
|
cn.parent.children = [c for c in cn.parent.children if c != cn]
|
|
self.current_node = cn.parent
|
|
self._calculate_groups()
|
|
return
|
|
super().undo(n_times=n_times, stop_on_mistake=stop_on_mistake)
|
|
|
|
def reset_current_analysis(self):
|
|
cn = self.current_node
|
|
engine = self.engines[cn.next_player]
|
|
engine.terminate_queries(cn)
|
|
cn.clear_analysis()
|
|
cn.analyze(engine)
|
|
|
|
def redo(self, n_times=1, stop_on_mistake=None):
|
|
if self.insert_mode:
|
|
return
|
|
super().redo(n_times=n_times, stop_on_mistake=stop_on_mistake)
|
|
|
|
def set_insert_mode(self, mode):
|
|
if mode == "toggle":
|
|
mode = not self.insert_mode
|
|
if mode == self.insert_mode:
|
|
return
|
|
self.insert_mode = mode
|
|
if mode:
|
|
children = self.current_node.ordered_children
|
|
if not children:
|
|
self.insert_mode = False
|
|
else:
|
|
self.insert_after = self.current_node.ordered_children[0]
|
|
self.katrain.controls.set_status(i18n._("starting insert mode"), STATUS_INFO)
|
|
else:
|
|
copy_from_node = self.insert_after
|
|
copy_to_node = self.current_node
|
|
num_copied = 0
|
|
if copy_to_node != self.insert_after.parent:
|
|
above_insertion_root = self.insert_after.parent.nodes_from_root
|
|
already_inserted_moves = [
|
|
n.move for n in copy_to_node.nodes_from_root if n not in above_insertion_root and n.move
|
|
]
|
|
try:
|
|
while True:
|
|
for m in copy_from_node.move_with_placements:
|
|
if m not in already_inserted_moves:
|
|
self._validate_move_and_update_chains(m, True)
|
|
# this inserts
|
|
copy_to_node = GameNode(
|
|
parent=copy_to_node, properties=copy.deepcopy(copy_from_node.properties)
|
|
)
|
|
num_copied += 1
|
|
if not copy_from_node.children:
|
|
break
|
|
copy_from_node = copy_from_node.ordered_children[0]
|
|
except IllegalMoveException:
|
|
pass # illegal move = stop
|
|
self._calculate_groups() # recalculate groups
|
|
self.katrain.controls.set_status(
|
|
i18n._("ending insert mode").format(num_copied=num_copied), STATUS_INFO
|
|
)
|
|
self.analyze_all_nodes(analyze_fast=True, even_if_present=False)
|
|
else:
|
|
self.katrain.controls.set_status("", STATUS_INFO)
|
|
self.katrain.controls.move_tree.insert_node = self.insert_after if self.insert_mode else None
|
|
self.katrain.controls.move_tree.redraw()
|
|
self.katrain.update_state(redraw_board=True)
|
|
|
|
# Play a Move from the current position, raise IllegalMoveException if invalid.
|
|
def play(self, move: Move, ignore_ko: bool = False, analyze=True):
|
|
played_node = super().play(move, ignore_ko)
|
|
if analyze:
|
|
if self.region_of_interest:
|
|
played_node.analyze(self.engines[played_node.next_player], analyze_fast=True)
|
|
played_node.analyze(self.engines[played_node.next_player], region_of_interest=self.region_of_interest)
|
|
else:
|
|
played_node.analyze(self.engines[played_node.next_player])
|
|
return played_node
|
|
|
|
def set_region_of_interest(self, region_of_interest):
|
|
x1, x2, y1, y2 = region_of_interest
|
|
xmin, xmax = min(x1, x2), max(x1, x2)
|
|
ymin, ymax = min(y1, y2), max(y1, y2)
|
|
szx, szy = self.board_size
|
|
if not (xmin == xmax and ymin == ymax) and not (xmax - xmin + 1 >= szx and ymax - ymin + 1 >= szy):
|
|
self.region_of_interest = [xmin, xmax, ymin, ymax]
|
|
else:
|
|
self.region_of_interest = None
|
|
self.katrain.controls.set_status("", OUTPUT_INFO)
|
|
|
|
def analyze_extra(self, mode, **kwargs):
|
|
stones = {s.coords for s in self.stones}
|
|
cn = self.current_node
|
|
|
|
if mode == "stop":
|
|
self.katrain.pondering = False
|
|
for e in set(self.engines.values()):
|
|
e.stop_pondering()
|
|
e.terminate_queries()
|
|
return
|
|
|
|
engine = self.engines[cn.next_player]
|
|
|
|
if mode == "ponder":
|
|
cn.analyze(
|
|
engine,
|
|
ponder=True,
|
|
priority=PRIORITY_EXTRA_ANALYSIS,
|
|
region_of_interest=self.region_of_interest,
|
|
time_limit=False,
|
|
)
|
|
return
|
|
|
|
if mode == "extra":
|
|
visits = cn.analysis_visits_requested + engine.config["max_visits"]
|
|
self.katrain.controls.set_status(i18n._("extra analysis").format(visits=visits), STATUS_ANALYSIS)
|
|
cn.analyze(
|
|
engine,
|
|
visits=visits,
|
|
priority=PRIORITY_EXTRA_ANALYSIS,
|
|
region_of_interest=self.region_of_interest,
|
|
time_limit=False,
|
|
)
|
|
return
|
|
|
|
if mode == "game":
|
|
nodes = self.root.nodes_in_tree
|
|
only_mistakes = kwargs.get("mistakes_only", False)
|
|
move_range = kwargs.get("move_range", None)
|
|
if move_range:
|
|
if move_range[1] < move_range[0]:
|
|
move_range = reversed(move_range)
|
|
threshold = self.katrain.config("trainer/eval_thresholds")[-4]
|
|
if "visits" in kwargs:
|
|
visits = kwargs["visits"]
|
|
else:
|
|
min_visits = min(node.analysis_visits_requested for node in nodes)
|
|
visits = min_visits + engine.config["max_visits"]
|
|
for node in nodes:
|
|
max_point_loss = max(c.points_lost or 0 for c in [node] + node.children)
|
|
if only_mistakes and max_point_loss <= threshold:
|
|
continue
|
|
if move_range and (not node.depth - 1 in range(move_range[0], move_range[1] + 1)):
|
|
continue
|
|
node.analyze(engine, visits=visits, priority=-1_000_000, time_limit=False, report_every=None)
|
|
if not move_range:
|
|
self.katrain.controls.set_status(i18n._("game re-analysis").format(visits=visits), STATUS_ANALYSIS)
|
|
else:
|
|
self.katrain.controls.set_status(
|
|
i18n._("move range analysis").format(
|
|
start_move=move_range[0], end_move=move_range[1], visits=visits
|
|
),
|
|
STATUS_ANALYSIS,
|
|
)
|
|
return
|
|
|
|
elif mode == "sweep":
|
|
board_size_x, board_size_y = self.board_size
|
|
|
|
if cn.analysis_exists:
|
|
policy_grid = (
|
|
var_to_grid(self.current_node.policy, size=(board_size_x, board_size_y))
|
|
if self.current_node.policy
|
|
else None
|
|
)
|
|
analyze_moves = sorted(
|
|
[
|
|
Move(coords=(x, y), player=cn.next_player)
|
|
for x in range(board_size_x)
|
|
for y in range(board_size_y)
|
|
if (policy_grid is None and (x, y) not in stones) or policy_grid[y][x] >= 0
|
|
],
|
|
key=lambda mv: -policy_grid[mv.coords[1]][mv.coords[0]],
|
|
)
|
|
else:
|
|
analyze_moves = [
|
|
Move(coords=(x, y), player=cn.next_player)
|
|
for x in range(board_size_x)
|
|
for y in range(board_size_y)
|
|
if (x, y) not in stones
|
|
]
|
|
visits = engine.config["fast_visits"]
|
|
self.katrain.controls.set_status(i18n._("sweep analysis").format(visits=visits), STATUS_ANALYSIS)
|
|
priority = PRIORITY_SWEEP
|
|
elif mode in ["equalize", "alternative", "local"]:
|
|
if not cn.analysis_complete and mode != "local":
|
|
self.katrain.controls.set_status(i18n._("wait-before-extra-analysis"), STATUS_INFO, self.current_node)
|
|
return
|
|
if mode == "alternative": # also do a quick update on current candidates so it doesn't look too weird
|
|
self.katrain.controls.set_status(i18n._("alternative analysis"), STATUS_ANALYSIS)
|
|
cn.analyze(engine, priority=PRIORITY_ALTERNATIVES, time_limit=False, find_alternatives="alternative")
|
|
visits = engine.config["fast_visits"]
|
|
else: # equalize
|
|
visits = max(d["visits"] for d in cn.analysis["moves"].values())
|
|
self.katrain.controls.set_status(i18n._("equalizing analysis").format(visits=visits), STATUS_ANALYSIS)
|
|
priority = PRIORITY_EQUALIZE
|
|
analyze_moves = [Move.from_gtp(gtp, player=cn.next_player) for gtp, _ in cn.analysis["moves"].items()]
|
|
else:
|
|
raise ValueError("Invalid analysis mode")
|
|
|
|
for move in analyze_moves:
|
|
if cn.analysis["moves"].get(move.gtp(), {"visits": 0})["visits"] < visits:
|
|
cn.analyze(
|
|
engine, priority=priority, visits=visits, refine_move=move, time_limit=False
|
|
) # explicitly requested so take as long as you need
|
|
|
|
def selfplay(self, until_move, target_b_advantage=None):
|
|
cn = self.current_node
|
|
|
|
if target_b_advantage is not None:
|
|
analysis_kwargs = {"visits": max(25, self.katrain.config("engine/fast_visits"))}
|
|
engine_settings = {"wideRootNoise": 0.03}
|
|
else:
|
|
analysis_kwargs = engine_settings = {}
|
|
|
|
def set_analysis(node, result):
|
|
node.set_analysis(result)
|
|
analyze_and_play(node)
|
|
|
|
def request_analysis_for_node(node):
|
|
self.engines[node.player].request_analysis(
|
|
node,
|
|
callback=lambda result, _partial: set_analysis(node, result),
|
|
priority=PRIORITY_DEFAULT,
|
|
analyze_fast=True,
|
|
extra_settings=engine_settings,
|
|
**analysis_kwargs,
|
|
)
|
|
|
|
def analyze_and_play(node):
|
|
nonlocal cn, engine_settings
|
|
candidates = node.candidate_moves
|
|
if self.katrain.game is not self:
|
|
return # a new game happened
|
|
ai_thoughts = "Move generated by AI self-play\n"
|
|
if until_move != "end" and target_b_advantage is not None: # setup pos
|
|
if node.depth >= until_move or candidates[0]["move"] == "pass":
|
|
self.set_current_node(node)
|
|
return
|
|
target_score = cn.score + (node.depth - cn.depth + 1) * (target_b_advantage - cn.score) / (
|
|
until_move - cn.depth
|
|
)
|
|
max_loss = 5
|
|
stddev = min(3, 0.5 + (until_move - node.depth) * 0.15)
|
|
ai_thoughts += f"Selecting moves aiming at score {target_score:.1f} +/- {stddev:.2f} with < {max_loss} points lost\n"
|
|
if abs(node.score - target_score) < 3 * stddev:
|
|
weighted_cands = [
|
|
(
|
|
move,
|
|
math.exp(-0.5 * (abs(move["scoreLead"] - target_score) / stddev) ** 2)
|
|
* math.exp(-0.5 * (min(0, move["pointsLost"]) / max_loss) ** 2),
|
|
)
|
|
for i, move in enumerate(candidates)
|
|
if move["pointsLost"] < max_loss or i == 0
|
|
]
|
|
move_info = weighted_selection_without_replacement(weighted_cands, 1)[0][0]
|
|
for move, wt in weighted_cands:
|
|
self.katrain.log(
|
|
f"{'* ' if move_info == move else ' '} {move['move']} {move['scoreLead']} {wt}",
|
|
OUTPUT_EXTRA_DEBUG,
|
|
)
|
|
ai_thoughts += f"Move option: {move['move']} score {move['scoreLead']:.2f} loss {move['pointsLost']:.2f} weight {wt:.3e}\n"
|
|
else: # we're a bit lost, far away from target, just push it closer
|
|
move_info = min(candidates, key=lambda move: abs(move["scoreLead"] - target_score))
|
|
self.katrain.log(
|
|
f"* Played {move_info['move']} {move_info['scoreLead']} because score deviation between current score {node.score} and target score {target_score} > {3*stddev}",
|
|
OUTPUT_EXTRA_DEBUG,
|
|
)
|
|
ai_thoughts += f"Move played to close difference between score {node.score:.1f} and target {target_score:.1f} quickly."
|
|
|
|
self.katrain.log(
|
|
f"Self-play until {until_move} target {target_b_advantage}: {len(candidates)} candidates -> move {move_info['move']} score {move_info['scoreLead']} point loss {move_info['pointsLost']}",
|
|
OUTPUT_DEBUG,
|
|
)
|
|
move = Move.from_gtp(move_info["move"], player=node.next_player)
|
|
elif candidates: # just selfplay to end
|
|
move = Move.from_gtp(candidates[0]["move"], player=node.next_player)
|
|
else: # 1 visit etc
|
|
polmoves = node.policy_ranking
|
|
move = polmoves[0][1] if polmoves else Move(None)
|
|
if move.is_pass:
|
|
if self.current_node == cn:
|
|
self.set_current_node(node)
|
|
return
|
|
new_node = GameNode(parent=node, move=move)
|
|
new_node.ai_thoughts = ai_thoughts
|
|
if until_move != "end" and target_b_advantage is not None:
|
|
self.set_current_node(new_node)
|
|
self.katrain.controls.set_status(
|
|
i18n._("setup game status message").format(move=new_node.depth, until_move=until_move),
|
|
STATUS_INFO,
|
|
)
|
|
else:
|
|
if node != cn:
|
|
node.remove_shortcut()
|
|
cn.add_shortcut(new_node)
|
|
|
|
self.katrain.controls.move_tree.redraw_tree_trigger()
|
|
request_analysis_for_node(new_node)
|
|
|
|
request_analysis_for_node(cn)
|
|
|
|
def analyze_undo(self, node):
|
|
train_config = self.katrain.config("trainer")
|
|
move = node.move
|
|
if node != self.current_node or node.auto_undo is not None or not node.analysis_complete or not move:
|
|
return
|
|
points_lost = node.points_lost
|
|
thresholds = train_config["eval_thresholds"]
|
|
num_undo_prompts = train_config["num_undo_prompts"]
|
|
i = 0
|
|
while i < len(thresholds) and points_lost < thresholds[i]:
|
|
i += 1
|
|
num_undos = num_undo_prompts[i] if i < len(num_undo_prompts) else 0
|
|
if num_undos == 0:
|
|
undo = False
|
|
elif num_undos < 1: # probability
|
|
undo = int(node.undo_threshold < num_undos) and len(node.parent.children) == 1
|
|
else:
|
|
undo = len(node.parent.children) <= num_undos
|
|
|
|
node.auto_undo = undo
|
|
if undo:
|
|
self.undo(1)
|
|
self.katrain.controls.set_status(
|
|
i18n._("teaching undo message").format(move=move.gtp(), points_lost=points_lost), STATUS_TEACHING
|
|
)
|
|
self.katrain.update_state()
|