analysis back up
This commit is contained in:
1 parent
15276c9062
commit
25093fe35e
10 files changed
+128
-87
No files matched your search
+5
-3
@@ -2,7 +2,8 @@
|
||||
"engine": {
|
||||
"command": "KataGo/katago analysis -model models/b15-1.3.2.txt.gz -config KataGo/analysis_config.cfg -analysis-threads 8",
|
||||
"visits": 2000,
|
||||
"visits_fast": 500
|
||||
"visits_fast": 500,
|
||||
"enable_ownership": true
|
||||
},
|
||||
"game": {
|
||||
"size": 19,
|
||||
@@ -26,11 +27,12 @@
|
||||
"ghost_alpha": 0.5,
|
||||
"undo_alpha": 0.5,
|
||||
"undo_scale": 0.95,
|
||||
"eval_colors": [[0.537, 0.129, 0.42], [1, 0, 0], [1, 0.95, 0], [0.5, 0.6, 0], [0.117, 0.588, 0]],
|
||||
"eval_thresholds": [10,5,1.5,0.5],
|
||||
"eval_colors": [[0.537, 0.129, 0.42], [1, 0, 0], [1,0.5,0], [0.95, 0.95, 0.25], [0.67, 0.95, 0.18], [0.117, 0.588, 0]],
|
||||
"eval_thresholds": [12,6,3,1.5,0.5],
|
||||
"board_margin": 1.5,
|
||||
"starpoint_size": 0.1,
|
||||
"stone_size": 0.475,
|
||||
"eval_dot_max_size": 0.5,
|
||||
"board_color": [0.85, 0.68, 0.40],
|
||||
"line_color": [0,0,0],
|
||||
"eval_off_show_last": 3
|
||||
|
||||
@@ -15,6 +15,10 @@ from game_node import GameNode
|
||||
class KataGoEngine:
|
||||
"""Starts and communicates with the KataGO analysis engine"""
|
||||
|
||||
# TODO: we don't support suicide in game.py, so no "tt": "tromp-taylor", "nz": "new-zealand"
|
||||
RULESETS = {"jp": "japanese", "cn": "chinese", "ko": "korean", "aga":"aga"}
|
||||
RULESETS.update({v: v for v in RULESETS.values()})
|
||||
|
||||
def __init__(self, katrain, config):
|
||||
self.command = os.path.join(config["command"])
|
||||
self.katrain = katrain
|
||||
@@ -26,6 +30,7 @@ class KataGoEngine:
|
||||
self.fast = True
|
||||
self.query_counter = 0
|
||||
self.katago_process = None
|
||||
|
||||
try:
|
||||
self.katago_process = subprocess.Popen(self.command, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||
self.analysis_thread = threading.Thread(target=self._analysis_read_thread, daemon=True).start()
|
||||
@@ -44,31 +49,38 @@ class KataGoEngine:
|
||||
else:
|
||||
callback, start_time = self.queries[analysis["id"]]
|
||||
time_taken = time.time() - start_time
|
||||
self.katrain.log(f"[{time_taken:.1f}][{analysis['id']}] KataGo Analysis Received:", line[:80], "...")
|
||||
self.katrain.log(f"[{time_taken:.1f}][{analysis['id']}] KataGo Analysis Received: {line}...") # [:80]
|
||||
callback(analysis)
|
||||
self.katrain.update_evaluation() # TODO: ??
|
||||
self.katrain.redraw()
|
||||
|
||||
def request_analysis(self, analysis_node: GameNode, callback: Callable, faster=False, min_visits=0, priority=0):
|
||||
def request_analysis(
|
||||
self, analysis_node: GameNode, callback: Callable, faster=False, min_visits=0, priority=0, ownership=None
|
||||
):
|
||||
query_id = f"QUERY:{str(self.query_counter)}"
|
||||
self.query_counter += 1
|
||||
visits = 100 # TODO / fast = self.ai_fast.active
|
||||
if faster:
|
||||
visits /= 5
|
||||
moves = [m for node in analysis_node.nodes_from_root for m in node.move_with_placements]
|
||||
|
||||
if ownership is None:
|
||||
ownership = self.config["enable_ownership"]
|
||||
|
||||
|
||||
query = {
|
||||
"id": query_id,
|
||||
"moves": [[m.player, m.gtp()] for m in moves],
|
||||
"includeOwnership": True,
|
||||
"maxVisits": max(min_visits, visits),
|
||||
"rules": self.RULESETS.get( str(analysis_node.ruleset).lower(), "japanese"),
|
||||
"priority": priority,
|
||||
"rules": "japanese",
|
||||
"analyzeTurns": [len(moves)],
|
||||
"maxVisits": max(min_visits, visits),
|
||||
"komi": analysis_node.komi,
|
||||
"boardXSize": analysis_node.board_size,
|
||||
"boardYSize": analysis_node.board_size,
|
||||
"analyzeTurns": [len(moves)],
|
||||
"includeOwnership": ownership,
|
||||
"moves": [[m.player, m.gtp()] for m in moves],
|
||||
}
|
||||
self.queries[query_id] = (callback, time.time())
|
||||
if self.katago_process:
|
||||
self.katrain.log(f"Sending query {query_id}: {str(query)[:80]}", OUTPUT_DEBUG)
|
||||
self.katrain.log(f"Sending query {query_id}: {str(query)}", OUTPUT_DEBUG) # [:80]
|
||||
self.katago_process.stdin.write((json.dumps(query) + "\n").encode())
|
||||
self.katago_process.stdin.flush()
|
||||
@@ -25,14 +25,17 @@ class Game:
|
||||
self.katrain = katrain
|
||||
self.engine = engine
|
||||
self.config = config
|
||||
self.board_size = board_size or self.config.get("size", 19)
|
||||
self.komi = self.config.get(f"komi_{self.board_size}", 6.5)
|
||||
self.game_id = datetime.strftime(datetime.now(), "%Y-%m-%d %H %M %S")
|
||||
|
||||
if move_tree:
|
||||
self.root = move_tree
|
||||
self.board_size = self.root.board_size
|
||||
self.komi = self.root.komi
|
||||
else:
|
||||
self.root = GameNode(properties={"RU": "JP", "SZ": self.board_size, "KM": self.komi, "PC": "KaTrain: https://github.com/sanderland/katrain", "DT": self.game_id})
|
||||
self.board_size = board_size or config['size']
|
||||
self.komi = self.config.get(f"komi_{self.board_size}",6.5)
|
||||
self.root = GameNode(properties={"RU": "JP", "SZ": self.board_size, "KM": self.komi, "AP": "[KaTrain:https://github.com/sanderland/katrain]", "DT": self.game_id})
|
||||
|
||||
self.current_node = self.root
|
||||
for node in self.root.nodes_in_tree:
|
||||
node.analyze(self.engine)
|
||||
|
||||
@@ -43,6 +43,12 @@ class GameNode(SGFNode):
|
||||
score = score or self.score
|
||||
return f"{'B' if score >= 0 else 'W'}+{abs(score):.1f}"
|
||||
|
||||
def format_win_rate(self, win_rate=None):
|
||||
win_rate = win_rate or self.analysis[0]['winrate']
|
||||
b_adv = win_rate-0.5
|
||||
return f"{'B' if b_adv > 0 else 'W'}+{abs(b_adv):.1%}"
|
||||
|
||||
|
||||
def comment(self, sgf=False, eval=False, hints=False):
|
||||
single_move = self.single_move
|
||||
if not self.parent or not single_move: # root
|
||||
|
||||
+17
-14
@@ -65,27 +65,30 @@ class BadukPanWidget(Widget):
|
||||
self.draw_board()
|
||||
self.draw_board_contents()
|
||||
|
||||
def draw_stone(self, x, y, col, outline_col=None, innercol=None, evalcol=None, evalsize=10.0, scale=1.0):
|
||||
def draw_stone(self, x, y, col, outline_col=None, innercol=None, evalcol=None, evalscale=1.0, scale=1.0):
|
||||
stone_size = self.stone_size * scale
|
||||
draw_circle((self.gridpos[x], self.gridpos[y]), stone_size, col)
|
||||
if outline_col:
|
||||
Color(*outline_col)
|
||||
Line(circle=(self.gridpos[x], self.gridpos[y], stone_size), width=0.05 * stone_size)
|
||||
|
||||
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) * stone_size * 0.5, evalcol)
|
||||
evalsize = self.stone_size * evalscale * self.config['eval_dot_max_size']
|
||||
draw_circle((self.gridpos[x], self.gridpos[y]), evalsize, evalcol)
|
||||
# highlight_col = [ ((1-c)*0.33+e)/1.33 for c,e in zip(col,evalcol) ]
|
||||
# Color(*highlight_col[:3],0.5)
|
||||
# Line(circle=(self.gridpos[x], self.gridpos[y], evalsize))
|
||||
|
||||
if innercol:
|
||||
Color(*innercol)
|
||||
Line(circle=(self.gridpos[x], self.gridpos[y], stone_size * 0.45 / 0.85), width=0.125 * stone_size) # 1.75
|
||||
|
||||
def _eval_spectrum(self, points_lost):
|
||||
EVAL_COLORS = self.config["eval_colors"]
|
||||
EVAL_THRESHOLDS = self.config["eval_thresholds"]
|
||||
def eval_color(self, points_lost):
|
||||
colors = self.config["eval_colors"]
|
||||
thresholds = self.config["eval_thresholds"]
|
||||
i = 0
|
||||
while points_lost < EVAL_THRESHOLDS[i] and i < len(EVAL_COLORS):
|
||||
while i < len(thresholds) and points_lost < thresholds[i]:
|
||||
i += 1
|
||||
return EVAL_COLORS[i]
|
||||
return colors[min(i,len(colors)-1)]
|
||||
|
||||
def draw_board(self, *args):
|
||||
if not self.config:
|
||||
@@ -151,7 +154,7 @@ class BadukPanWidget(Widget):
|
||||
for m in node.move_with_placements:
|
||||
if has_stone[m.coords]: # skip captures, draw over repeat plays
|
||||
move_eval_on = full_eval_on[m.player] or i >= len(nodes) - show_n_eval
|
||||
evalcol = self._eval_spectrum(eval) if move_eval_on and eval and evalsize > self.config.get("min_eval_temperature", 0.5) else None
|
||||
evalcol = self.eval_color(eval) if move_eval_on and eval and evalsize > self.config.get("min_eval_temperature", 0.5) else None
|
||||
inner = stone_color[m.opponent] if (m is current_node) else None
|
||||
self.draw_stone(m.coords[0], m.coords[1], stone_color[m.player], outline_color[m.player], inner, evalcol, evalsize)
|
||||
|
||||
@@ -172,20 +175,20 @@ class BadukPanWidget(Widget):
|
||||
undo_coords = set()
|
||||
alpha = self.config["undo_alpha"]
|
||||
for child_node in current_node.children:
|
||||
eval_info = child_node.evaluation_info
|
||||
points_lost = node.points_lost
|
||||
m = child_node.single_move
|
||||
if m and m.coords[0] is not None:
|
||||
undo_coords.add(m.coords)
|
||||
evalcol = (*self._eval_spectrum(eval_info[0]), alpha) if eval_info[0] else None
|
||||
evalcol = (*self.eval_color(points_lost), alpha) if points_lost else None
|
||||
scale = self.config.get("undo_scale", 0.95)
|
||||
self.draw_stone(m.coords[0], m.coords[1], (*stone_color[m.player][:3], alpha), None, None, evalcol, self.EVAL_BOUNDS[1], scale=scale)
|
||||
self.draw_stone(m.coords[0], m.coords[1], (*stone_color[m.player][:3], alpha), None, None, evalcol, evalscale=scale, scale=scale)
|
||||
|
||||
# hints
|
||||
if katrain.controls.hints.active(next_player):
|
||||
hint_moves = current_node.ai_moves
|
||||
for i, d in enumerate(hint_moves):
|
||||
move = Move.from_gtp(d["move"])
|
||||
c = [*self._eval_spectrum(d["evaluation"]), 0.5]
|
||||
c = [*self.eval_color(d["evaluation"]), 0.5]
|
||||
if move.coords[0] is not None and move.coords not in undo_coords:
|
||||
if i == 0:
|
||||
scale = 1.0
|
||||
|
||||
+37
-38
@@ -21,15 +21,14 @@ class Controls(GridLayout):
|
||||
def set_status(self, msg):
|
||||
self.info.text = msg
|
||||
|
||||
def show_evaluation_stats(self, move):
|
||||
if move.analysis_ready:
|
||||
self.score.text = move.format_score().replace("-", "\u2013")
|
||||
self.temperature.text = f"{move.temperature_stats[2]:.1f}"
|
||||
if move.parent and move.parent.analysis_ready:
|
||||
if move.evaluation is not None:
|
||||
self.evaluation.text = f"{move.evaluation:.1%}"
|
||||
else:
|
||||
self.evaluation.text = f"?"
|
||||
def show_evaluation_stats(self, node):
|
||||
if node.analysis_ready:
|
||||
self.score.text = node.format_score().replace("-", "\u2013")
|
||||
self.win_rate.text = node.format_win_rate()
|
||||
if node.points_lost is not None:
|
||||
self.points_lost.text = f"{node.points_lost:.1f}"
|
||||
else:
|
||||
self.points_lost.text = f"?"
|
||||
|
||||
# handles showing completed analysis and triggered actions like auto undo and ai move
|
||||
def update_evaluation(self):
|
||||
@@ -40,36 +39,36 @@ class Controls(GridLayout):
|
||||
|
||||
if current_player_is_human_or_both_robots and not current_node.is_root:
|
||||
self.info.text = current_node.comment(eval=True, hints=self.hints.active(move.player))
|
||||
self.evaluation.text = ""
|
||||
self.points_lost.text = ""
|
||||
if current_player_is_human_or_both_robots:
|
||||
self.show_evaluation_stats(current_node)
|
||||
|
||||
if current_node.analysis_ready and current_node.parent and current_node.parent.analysis_ready and not current_node.children and not current_node.x_comment.get("undo"):
|
||||
# handle automatic undo
|
||||
if self.auto_undo.active(move.player) and not self.ai_auto.active(move.player) and not current_node.auto_undid:
|
||||
ts = self.train_settings
|
||||
# TODO: is this overly generous wrt low visit outdated evaluations?
|
||||
evaluation = current_node.evaluation if current_node.evaluation is not None else 1 # assume move is fine if temperature is negative
|
||||
move_eval = max(evaluation, current_node.outdated_evaluation or 0)
|
||||
points_lost = (current_node.parent or current_node).temperature_stats[2] * (1 - move_eval)
|
||||
if move_eval < ts["undo_eval_threshold"] and points_lost >= ts["undo_point_threshold"]:
|
||||
if self.num_undos(current_node) == 0:
|
||||
current_node.x_comment["undid"] = f"Move was below threshold, but no undo granted (probability is {ts['num_undo_prompts']:.0%}).\n"
|
||||
self.update_evaluation()
|
||||
if False: # TODO: UNDO AND AI MOVE
|
||||
if current_node.analysis_ready and current_node.parent and current_node.parent.analysis_ready and not current_node.children and not current_node.x_comment.get("undo"):
|
||||
# handle automatic undo
|
||||
if self.auto_undo.active(move.player) and not self.ai_auto.active(move.player) and not current_node.auto_undid:
|
||||
ts = self.train_settings
|
||||
# TODO: is this overly generous wrt low visit outdated evaluations?
|
||||
evaluation = current_node.evaluation if current_node.evaluation is not None else 1 # assume move is fine if temperature is negative
|
||||
move_eval = max(evaluation, current_node.outdated_evaluation or 0)
|
||||
points_lost = (current_node.parent or current_node).temperature_stats[2] * (1 - move_eval)
|
||||
if move_eval < ts["undo_eval_threshold"] and points_lost >= ts["undo_point_threshold"]:
|
||||
if self.num_undos(current_node) == 0:
|
||||
current_node.x_comment["undid"] = f"Move was below threshold, but no undo granted (probability is {ts['num_undo_prompts']:.0%}).\n"
|
||||
self.update_evaluation()
|
||||
else:
|
||||
current_node.auto_undid = True
|
||||
self.parent.game.undo()
|
||||
if len(current_node.parent.children) >= ts["num_undo_prompts"] + 1:
|
||||
best_move = sorted([m for m in current_node.parent.children], key=lambda m: -(m.evaluation_info[0] or 0))[0]
|
||||
best_move.x_comment["undo_autoplay"] = f"Automatically played as best option after max. {ts['num_undo_prompts']} undo(s).\n"
|
||||
self.parent.game.play(best_move)
|
||||
self.update_evaluation()
|
||||
return
|
||||
# ai player doesn't technically need parent ready, but don't want to override waiting for undo
|
||||
current_node = self.parent.game.current_node # this effectively checks undo didn't just happen
|
||||
if self.ai_auto.active(move.opponent) and not self.parent.game.game_ended:
|
||||
if current_node.children:
|
||||
self.info.text = "AI paused since moves were undone. Press 'AI Move' or choose a move for the AI to continue playing."
|
||||
else:
|
||||
current_node.auto_undid = True
|
||||
self.parent.game.undo()
|
||||
if len(current_node.parent.children) >= ts["num_undo_prompts"] + 1:
|
||||
best_move = sorted([m for m in current_node.parent.children], key=lambda m: -(m.evaluation_info[0] or 0))[0]
|
||||
best_move.x_comment["undo_autoplay"] = f"Automatically played as best option after max. {ts['num_undo_prompts']} undo(s).\n"
|
||||
self.parent.game.play(best_move)
|
||||
self.update_evaluation()
|
||||
return
|
||||
# ai player doesn't technically need parent ready, but don't want to override waiting for undo
|
||||
current_node = self.parent.game.current_node # this effectively checks undo didn't just happen
|
||||
if self.ai_auto.active(move.opponent) and not self.parent.game.game_ended:
|
||||
if current_node.children:
|
||||
self.info.text = "AI paused since moves were undone. Press 'AI Move' or choose a move for the AI to continue playing."
|
||||
else:
|
||||
self._do_aimove()
|
||||
self.redraw(include_board=False)
|
||||
self._do_aimove()
|
||||
@@ -3,3 +3,6 @@ from kivy.uix.boxlayout import BoxLayout
|
||||
|
||||
class LoadSGFPopup(BoxLayout):
|
||||
pass
|
||||
|
||||
class NewGamePopup(BoxLayout):
|
||||
pass
|
||||
+21
-17
@@ -172,6 +172,10 @@
|
||||
active: True
|
||||
color: (0.95, 0.95, 0.95)
|
||||
|
||||
<NewGamePopup>
|
||||
Label:
|
||||
text: "New Game"
|
||||
|
||||
<BadukPanWidget>:
|
||||
size: self.parent.height, self.parent.height
|
||||
engine: self.parent.controls
|
||||
@@ -180,8 +184,8 @@
|
||||
cols: 1
|
||||
rows: 9
|
||||
info: info
|
||||
temperature: temperature
|
||||
evaluation: evaluation
|
||||
win_rate: win_rate
|
||||
points_lost: points_lost
|
||||
score: score
|
||||
hints: hints
|
||||
ownership: ownership
|
||||
@@ -215,17 +219,17 @@
|
||||
id: eval
|
||||
text: 'all eval'
|
||||
default_active: False
|
||||
on_active: root.redraw()
|
||||
on_active: root.parent.redraw()
|
||||
BWCheckBoxHint:
|
||||
size_hint: 0.2, 0.5
|
||||
id: hints
|
||||
text: 'hints'
|
||||
on_active: root.parent.board.engine.update_evaluation()
|
||||
on_active: root.parent.redraw()
|
||||
BWCheckBoxHint:
|
||||
size_hint: 0.2, 0.5
|
||||
id: auto_undo
|
||||
text: 'undo'
|
||||
on_active: root.redraw()
|
||||
on_active: root.parent.redraw()
|
||||
BWCheckBoxHint:
|
||||
size_hint: 0.166, 0.5
|
||||
text: 'ai'
|
||||
@@ -235,7 +239,7 @@
|
||||
size_hint: 0.2, 0.5
|
||||
id: ownership
|
||||
text: 'show\nowner'
|
||||
on_active: root.redraw()
|
||||
on_active: root.parent.redraw()
|
||||
GridLayout:
|
||||
cols: 4
|
||||
rows: 1
|
||||
@@ -244,7 +248,7 @@
|
||||
id: ai_move
|
||||
size_hint: 0.5, 0.5
|
||||
text: 'AI Move'
|
||||
on_press: root.action("aimove")
|
||||
on_press: root.parent("aimove")
|
||||
CheckBoxHint:
|
||||
size_hint: 0.166, 0.5
|
||||
text: 'fast'
|
||||
@@ -267,26 +271,26 @@
|
||||
StyledButton:
|
||||
id: undo
|
||||
text: 'Undo'
|
||||
on_press: root.action("undo")
|
||||
on_press: root.parent("undo")
|
||||
default_active: True
|
||||
StyledButton:
|
||||
text: 'Pass'
|
||||
on_press: root.action("play",(None,None))
|
||||
on_press: root.parent("play",(None,None))
|
||||
CensorableLabel:
|
||||
id: temperature
|
||||
id: win_rate
|
||||
size_hint: 1, 0.025
|
||||
label: 'Temperature'
|
||||
text: '...'
|
||||
CensorableLabel:
|
||||
id: evaluation
|
||||
size_hint: 1, 0.025
|
||||
label: 'Evaluation'
|
||||
label: 'Win rate'
|
||||
text: '...'
|
||||
CensorableScoreLabel:
|
||||
id: score
|
||||
size_hint: 1, 0.025
|
||||
label: 'Score'
|
||||
text: '...'
|
||||
CensorableLabel:
|
||||
id: points_lost
|
||||
size_hint: 1, 0.025
|
||||
label: 'Point loss'
|
||||
text: '...'
|
||||
TextInput:
|
||||
id: info
|
||||
size_hint: 1, 0.2
|
||||
@@ -303,7 +307,7 @@
|
||||
text: 'Load'
|
||||
id: load
|
||||
size_hint: 0.5, 1
|
||||
on_press: root.action("analyze-sgf",info.text)
|
||||
on_press: root.parent("analyze-sgf",info.text)
|
||||
GridLayout:
|
||||
size_hint: 1, 0.05
|
||||
cols: 4
|
||||
|
||||
+3
-1
@@ -140,7 +140,8 @@ class KaTrainGui(BoxLayout):
|
||||
self.game.analyze_extra(mode)
|
||||
|
||||
def analyze_movetree(self, root, faster=False):
|
||||
self._do_init(root["SZ"], root["KM"])
|
||||
self.game = Game(self, self.engine, self.config("game"), move_tree=root)
|
||||
|
||||
self.parent.game.root = root
|
||||
handicap = root["HA"]
|
||||
if handicap is not None and root["AB"] is None:
|
||||
@@ -171,6 +172,7 @@ class KaTrainGui(BoxLayout):
|
||||
if include_board:
|
||||
Clock.schedule_once(self.board_gui.draw_board, -1) # main thread needs to do this
|
||||
Clock.schedule_once(self.board_gui.draw_board_contents, -1)
|
||||
self.controls.update_evaluation()
|
||||
|
||||
def _on_keyboard_down(self, keyboard, keycode, text, modifiers):
|
||||
if keycode[1] == "up":
|
||||
|
||||
+9
-2
@@ -3,6 +3,8 @@ import re
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# TODO: handle AE ?
|
||||
# https://www.red-bean.com/sgf/properties.html
|
||||
|
||||
class ParseError(Exception):
|
||||
pass
|
||||
@@ -92,7 +94,7 @@ class SGFNode:
|
||||
"""Get the list of values for a property."""
|
||||
return self.properties.get(property, default)
|
||||
|
||||
def get_first(self, property, default) -> Any:
|
||||
def get_first(self, property, default=None) -> Any:
|
||||
"""Get the first value of the property, typically when exactly one is expected."""
|
||||
return self.properties.get(property, [default])[0]
|
||||
|
||||
@@ -121,6 +123,7 @@ class SGFNode:
|
||||
self._depth = self.parent.depth + 1
|
||||
return self._depth
|
||||
|
||||
# root properties available on any node
|
||||
@property
|
||||
def board_size(self) -> int:
|
||||
return int(self.root.get_first("SZ", 19))
|
||||
@@ -129,6 +132,10 @@ class SGFNode:
|
||||
def komi(self) -> float:
|
||||
return float(self.root.get_first("KM", 6.5))
|
||||
|
||||
@property
|
||||
def ruleset(self) -> str:
|
||||
return self.root.get_first("RU")
|
||||
|
||||
@property
|
||||
def moves(self) -> List[Move]:
|
||||
"""Returns all moves in the node."""
|
||||
@@ -201,7 +208,7 @@ class SGF:
|
||||
if match:
|
||||
encoding = match[1].decode("ascii")
|
||||
else:
|
||||
encoding = "utf-8" # default
|
||||
encoding = "ISO-8859-1" # default
|
||||
decoded = bin_contents.decode(encoding=encoding)
|
||||
return SGF.parse(decoded)
|
||||
|
||||
|
||||
Reference in new issue
Block a user