1.10 (#481)
* edit katrain/__main__.py * delay / 0 * edit katrain/core/constants.py * edit ENGINE.md * fix bug * fix bug * fix scroll variations * edit katrain/core/sgf_parser.py, edit tests/test_parser.py and 4 other changes * fix touchscreen * antimirror * openssl dir try * openssl dir try * fix log * openssl * auto version in osx * edit .github/workflows/osxbuild.yaml * edit .github/workflows/osxbuild.yaml * edit katrain/KataGo/katago * better pondering * new downloads * middle click add variation * change middle click pv * more tweaks * black Co-authored-by: Sander Land <sander.land@futurefacts.nl> Co-authored-by: Sander Land <sander.land@cognite.com> Co-authored-by: Sander Land <sander@chatdesk.com>
This commit is contained in:
38 files changed
+263
-100
No files matched your search
+11
-1
@@ -10,6 +10,7 @@ from katrain.core.constants import (
|
||||
AI_INFLUENCE,
|
||||
AI_INFLUENCE_ELO_GRID,
|
||||
AI_JIGO,
|
||||
AI_ANTIMIRROR,
|
||||
AI_LOCAL,
|
||||
AI_LOCAL_ELO_GRID,
|
||||
AI_PICK,
|
||||
@@ -264,6 +265,11 @@ def generate_ai_move(game: Game, ai_mode: str, ai_settings: Dict) -> Tuple[Move,
|
||||
if not handicap_analysis:
|
||||
game.katrain.log("Error getting handicap-based move", OUTPUT_ERROR)
|
||||
ai_mode = AI_DEFAULT
|
||||
elif ai_mode == AI_ANTIMIRROR:
|
||||
antimirror_analysis = request_ai_analysis(game, cn, {"antiMirror": True})
|
||||
if not antimirror_analysis:
|
||||
game.katrain.log("Error getting antimirror move", OUTPUT_ERROR)
|
||||
ai_mode = AI_DEFAULT
|
||||
|
||||
while not cn.analysis_complete:
|
||||
time.sleep(0.01)
|
||||
@@ -388,6 +394,8 @@ def generate_ai_move(game: Game, ai_mode: str, ai_settings: Dict) -> Tuple[Move,
|
||||
candidate_ai_moves = cn.candidate_moves
|
||||
if ai_mode == AI_HANDICAP:
|
||||
candidate_ai_moves = handicap_analysis["moveInfos"]
|
||||
elif ai_mode == AI_ANTIMIRROR:
|
||||
candidate_ai_moves = antimirror_analysis["moveInfos"]
|
||||
|
||||
top_cand = Move.from_gtp(candidate_ai_moves[0]["move"], player=cn.next_player)
|
||||
if top_cand.is_pass and ai_mode not in [
|
||||
@@ -492,12 +500,14 @@ def generate_ai_move(game: Game, ai_mode: str, ai_settings: Dict) -> Tuple[Move,
|
||||
else:
|
||||
raise (Exception("No moves found - are you using an older KataGo with no per-move ownership info?"))
|
||||
else:
|
||||
if ai_mode not in [AI_DEFAULT, AI_HANDICAP]:
|
||||
if ai_mode not in [AI_DEFAULT, AI_HANDICAP, AI_ANTIMIRROR]:
|
||||
game.katrain.log(f"Unknown AI mode {ai_mode} or policy missing, using default.", OUTPUT_INFO)
|
||||
ai_thoughts += f"Strategy {ai_mode} not found or unexpected fallback."
|
||||
aimove = top_cand
|
||||
if ai_mode == AI_HANDICAP:
|
||||
ai_thoughts += f"Handicap strategy found {len(candidate_ai_moves)} moves returned from the engine and chose {aimove.gtp()} as top move. PDA based score {cn.format_score(handicap_analysis['rootInfo']['scoreLead'])} and win rate {cn.format_winrate(handicap_analysis['rootInfo']['winrate'])}"
|
||||
if ai_mode == AI_ANTIMIRROR:
|
||||
ai_thoughts += f"AntiMirror strategy found {len(candidate_ai_moves)} moves returned from the engine and chose {aimove.gtp()} as top move. antiMirror based score {cn.format_score(antimirror_analysis['rootInfo']['scoreLead'])} and win rate {cn.format_winrate(antimirror_analysis['rootInfo']['winrate'])}"
|
||||
else:
|
||||
ai_thoughts += f"Default strategy found {len(candidate_ai_moves)} moves returned from the engine and chose {aimove.gtp()} as top move"
|
||||
game.katrain.log(f"AI thoughts: {ai_thoughts}", OUTPUT_DEBUG)
|
||||
|
||||
@@ -54,6 +54,13 @@ class Player:
|
||||
return f"{self.player_type} ({self.player_subtype})"
|
||||
|
||||
|
||||
def parse_version(s):
|
||||
parts = [int(p) for p in s.split(".")]
|
||||
while len(parts) < 3:
|
||||
parts.append(0)
|
||||
return parts
|
||||
|
||||
|
||||
class KaTrainBase:
|
||||
USER_CONFIG_FILE = os.path.expanduser(os.path.join(DATA_FOLDER, "config.json"))
|
||||
PACKAGE_CONFIG_FILE = "katrain/config.json"
|
||||
@@ -101,11 +108,13 @@ class KaTrainBase:
|
||||
self.log(f"Copied package config to local file {config_file}", OUTPUT_INFO)
|
||||
else: # user file exists
|
||||
try:
|
||||
version = JsonStore(user_config_file).get("general")["version"]
|
||||
version_str = JsonStore(user_config_file).get("general")["version"]
|
||||
version = parse_version(version_str)
|
||||
except Exception: # noqa E722 broken file etc
|
||||
version = "0.0.0"
|
||||
if version < CONFIG_MIN_VERSION:
|
||||
backup = user_config_file + f".{version}.backup"
|
||||
version_str = "0.0.0"
|
||||
version = [0, 0, 0]
|
||||
if version < parse_version(CONFIG_MIN_VERSION):
|
||||
backup = f"{user_config_file}.{version_str}.backup"
|
||||
shutil.copyfile(user_config_file, backup)
|
||||
shutil.copyfile(package_config_file, user_config_file)
|
||||
self.log(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
PROGRAM_NAME = "KaTrain"
|
||||
VERSION = "1.9.3"
|
||||
VERSION = "1.10.0"
|
||||
HOMEPAGE = "https://github.com/sanderland/katrain"
|
||||
CONFIG_MIN_VERSION = "1.9.0" # keep config files from this version
|
||||
CONFIG_MIN_VERSION = "1.10.0" # keep config files from this version
|
||||
ANALYSIS_FORMAT_VERSION = "1.0"
|
||||
DATA_FOLDER = "~/.katrain"
|
||||
|
||||
@@ -42,6 +42,7 @@ AI_HANDICAP = "ai:handicap"
|
||||
AI_SCORELOSS = "ai:scoreloss"
|
||||
AI_WEIGHTED = "ai:p:weighted"
|
||||
AI_JIGO = "ai:jigo"
|
||||
AI_ANTIMIRROR = "ai:antimirror"
|
||||
AI_POLICY = "ai:policy"
|
||||
AI_PICK = "ai:p:pick"
|
||||
AI_LOCAL = "ai:p:local"
|
||||
@@ -54,7 +55,7 @@ AI_SETTLE_STONES = "ai:settle"
|
||||
|
||||
AI_CONFIG_DEFAULT = AI_RANK
|
||||
|
||||
AI_STRATEGIES_ENGINE = [AI_DEFAULT, AI_HANDICAP, AI_SCORELOSS, AI_SIMPLE_OWNERSHIP, AI_JIGO] # AI_SETTLE_STONES
|
||||
AI_STRATEGIES_ENGINE = [AI_DEFAULT, AI_HANDICAP, AI_SCORELOSS, AI_SIMPLE_OWNERSHIP, AI_JIGO, AI_ANTIMIRROR]
|
||||
AI_STRATEGIES_PICK = [AI_PICK, AI_LOCAL, AI_TENUKI, AI_INFLUENCE, AI_TERRITORY, AI_RANK]
|
||||
AI_STRATEGIES_POLICY = [AI_WEIGHTED, AI_POLICY] + AI_STRATEGIES_PICK
|
||||
AI_STRATEGIES = AI_STRATEGIES_ENGINE + AI_STRATEGIES_POLICY
|
||||
@@ -63,20 +64,21 @@ AI_STRATEGIES_RECOMMENDED_ORDER = [
|
||||
AI_RANK,
|
||||
AI_HANDICAP,
|
||||
AI_SIMPLE_OWNERSHIP,
|
||||
# AI_SETTLE_STONES,
|
||||
AI_SCORELOSS,
|
||||
AI_POLICY,
|
||||
AI_WEIGHTED,
|
||||
AI_JIGO,
|
||||
AI_ANTIMIRROR,
|
||||
AI_PICK,
|
||||
AI_LOCAL,
|
||||
AI_TENUKI,
|
||||
AI_TERRITORY,
|
||||
AI_INFLUENCE,
|
||||
AI_JIGO,
|
||||
]
|
||||
|
||||
AI_STRENGTH = { # dan ranks, backup if model is missing. TODO: remove some?
|
||||
AI_DEFAULT: 9,
|
||||
AI_ANTIMIRROR: 9,
|
||||
AI_POLICY: 5,
|
||||
AI_JIGO: float("nan"),
|
||||
AI_SCORELOSS: -4,
|
||||
@@ -260,7 +262,8 @@ TOP_MOVE_OPTIONS = [
|
||||
# TOP_MOVE_UTILITY,
|
||||
# TOP_MOVE_UTILITYLCB
|
||||
]
|
||||
REPORT_DT = 0.25
|
||||
REPORT_DT = 1
|
||||
PONDERING_REPORT_DT = 0.25
|
||||
|
||||
SGF_INTERNAL_COMMENTS_MARKER = "\u3164\u200b"
|
||||
SGF_SEPARATOR_MARKER = "\u3164\u3164"
|
||||
@@ -243,7 +243,9 @@ class KataGoContributeEngine(BaseEngine):
|
||||
game_properties["RU"] = json.dumps(analysis["rules"])
|
||||
game_properties["PB"] = analysis["blackPlayer"]
|
||||
game_properties["PW"] = analysis["whitePlayer"]
|
||||
current_game = BaseGame(self.katrain, game_properties=game_properties)
|
||||
current_game = BaseGame(
|
||||
self.katrain, game_properties=game_properties, bypass_config=True
|
||||
)
|
||||
self.active_games[game_id] = current_game
|
||||
last_node = current_game.sync_branch(
|
||||
[Move.from_gtp(coord, pl) for pl, coord in analysis["moves"]]
|
||||
|
||||
+51
-7
@@ -19,6 +19,7 @@ from katrain.core.constants import (
|
||||
OUTPUT_KATAGO_STDERR,
|
||||
DATA_FOLDER,
|
||||
KATAGO_EXCEPTION,
|
||||
PONDERING_REPORT_DT,
|
||||
)
|
||||
from katrain.core.game_node import GameNode
|
||||
from katrain.core.lang import i18n
|
||||
@@ -93,11 +94,14 @@ class BaseEngine: # some common elements between analysis and contribute engine
|
||||
class KataGoEngine(BaseEngine):
|
||||
"""Starts and communicates with the KataGO analysis engine"""
|
||||
|
||||
PONDER_KEY = "_kt_continuous"
|
||||
|
||||
def __init__(self, katrain, config):
|
||||
super().__init__(katrain, config)
|
||||
|
||||
self.allow_recovery = self.config.get("allow_recovery", True) # if false, don't give popups
|
||||
self.queries = {} # outstanding query id -> start time and callback
|
||||
self.ponder_query = None
|
||||
self.query_counter = 0
|
||||
self.katago_process = None
|
||||
self.base_priority = 0
|
||||
@@ -166,6 +170,7 @@ class KataGoEngine(BaseEngine):
|
||||
with self.thread_lock:
|
||||
self.write_queue = queue.Queue()
|
||||
self.terminate_queries(only_for_node=None, lock=False)
|
||||
self.ponder_query = None
|
||||
self.queries = {}
|
||||
|
||||
def terminate_queries(self, only_for_node=None, lock=True):
|
||||
@@ -176,10 +181,19 @@ class KataGoEngine(BaseEngine):
|
||||
if only_for_node is None or only_for_node is node:
|
||||
self.terminate_query(query_id)
|
||||
|
||||
def terminate_query(self, query_id):
|
||||
def stop_pondering(self):
|
||||
pq = self.ponder_query
|
||||
if pq:
|
||||
self.terminate_query(pq["id"], ignore_further_results=False)
|
||||
self.ponder_query = None
|
||||
|
||||
def terminate_query(self, query_id, ignore_further_results=True):
|
||||
self.katrain.log(f"Terminating query {query_id}", OUTPUT_DEBUG)
|
||||
|
||||
if query_id is not None:
|
||||
self.send_query({"action": "terminate", "terminateId": query_id}, None, None)
|
||||
self.queries.pop(query_id, None)
|
||||
if ignore_further_results:
|
||||
self.queries.pop(query_id, None)
|
||||
|
||||
def restart(self):
|
||||
self.queries = {}
|
||||
@@ -269,9 +283,10 @@ class KataGoEngine(BaseEngine):
|
||||
continue
|
||||
query_id = analysis["id"]
|
||||
if query_id not in self.queries:
|
||||
self.katrain.log(
|
||||
f"Query result {query_id} discarded -- recent new game or node reset?", OUTPUT_DEBUG
|
||||
)
|
||||
if analysis.get("action") != "terminate":
|
||||
self.katrain.log(
|
||||
f"Query result {query_id} discarded -- recent new game or node reset?", OUTPUT_DEBUG
|
||||
)
|
||||
continue
|
||||
callback, error_callback, start_time, next_move, _ = self.queries[query_id]
|
||||
if "error" in analysis:
|
||||
@@ -314,12 +329,38 @@ class KataGoEngine(BaseEngine):
|
||||
except queue.Empty:
|
||||
continue
|
||||
with self.thread_lock:
|
||||
self.katrain.log(f"GOT QUERY: {query}", OUTPUT_DEBUG)
|
||||
if "id" not in query:
|
||||
self.query_counter += 1
|
||||
query["id"] = f"QUERY:{str(self.query_counter)}"
|
||||
if query.get("action") != "terminate":
|
||||
|
||||
ponder = query.pop(self.PONDER_KEY, False)
|
||||
if ponder: # handle pondering in here to be in lock and such
|
||||
pq = self.ponder_query or {}
|
||||
# basically we handle pondering by just asking for these queries a lot and ignoring duplicates
|
||||
# when a different ponder query comes in, e.g. due to selecting a roi or different node, switch
|
||||
differences = {
|
||||
k: (pq.get(k), query.get(k))
|
||||
for k in (query.keys() | pq.keys()) - {"id", "maxVisits", "reportDuringSearchEvery"}
|
||||
if pq.get(k) != query.get(k)
|
||||
}
|
||||
if differences:
|
||||
# TODO:remove
|
||||
self.katrain.log(f"Found differences in ponder check: {differences}", OUTPUT_EXTRA_DEBUG)
|
||||
self.stop_pondering()
|
||||
query["maxVisits"] = 1_000_000
|
||||
query["reportDuringSearchEvery"] = PONDERING_REPORT_DT
|
||||
self.ponder_query = query
|
||||
else:
|
||||
# TODO:remove
|
||||
self.katrain.log("Found no differences in ponder check, skipping", OUTPUT_EXTRA_DEBUG)
|
||||
continue
|
||||
|
||||
terminate = query.get("action") == "terminate"
|
||||
if not terminate:
|
||||
self.queries[query["id"]] = (callback, error_callback, time.time(), next_move, node)
|
||||
self.katrain.log(f"Sending query {query['id']}: {json.dumps(query)}", OUTPUT_DEBUG)
|
||||
tag = "ponder " if ponder else ("terminate " if terminate else "")
|
||||
self.katrain.log(f"Sending {tag}query {query['id']}: {json.dumps(query)}", OUTPUT_DEBUG)
|
||||
try:
|
||||
self.katago_process.stdin.write((json.dumps(query) + "\n").encode())
|
||||
self.katago_process.stdin.flush()
|
||||
@@ -341,6 +382,7 @@ class KataGoEngine(BaseEngine):
|
||||
find_alternatives: bool = False,
|
||||
region_of_interest: Optional[List] = None,
|
||||
priority: int = 0,
|
||||
ponder=False, # infinite visits, cancellable
|
||||
ownership: Optional[bool] = None,
|
||||
next_move: Optional[GameNode] = None,
|
||||
extra_settings: Optional[Dict] = None,
|
||||
@@ -358,6 +400,7 @@ class KataGoEngine(BaseEngine):
|
||||
moves.append(next_move)
|
||||
if ownership is None:
|
||||
ownership = self.config["_enable_ownership"] and not next_move
|
||||
|
||||
if visits is None:
|
||||
visits = self.config["max_visits"]
|
||||
if analyze_fast and self.config.get("fast_visits"):
|
||||
@@ -411,6 +454,7 @@ class KataGoEngine(BaseEngine):
|
||||
"initialPlayer": analysis_node.initial_player,
|
||||
"moves": [[m.player, m.gtp()] for m in moves],
|
||||
"overrideSettings": {**settings, **(extra_settings or {})},
|
||||
self.PONDER_KEY: ponder,
|
||||
}
|
||||
if report_every is not None:
|
||||
query["reportDuringSearchEvery"] = report_every
|
||||
|
||||
+27
-14
@@ -53,6 +53,7 @@ class BaseGame:
|
||||
move_tree: GameNode = None,
|
||||
game_properties: Optional[Dict] = None,
|
||||
sgf_filename=None,
|
||||
bypass_config=False, # TODO: refactor?
|
||||
):
|
||||
self.katrain = katrain
|
||||
self._lock = threading.Lock()
|
||||
@@ -83,18 +84,24 @@ class BaseGame:
|
||||
): # not really according to sgf, and not sure if still needed, last clause for fox
|
||||
self.root.place_handicap_stones(handicap)
|
||||
else:
|
||||
board_size = katrain.config("game/size")
|
||||
rules = katrain.config("game/rules")
|
||||
self.komi = katrain.config("game/komi")
|
||||
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={
|
||||
**Game.DEFAULT_PROPERTIES,
|
||||
**{"SZ": board_size, "KM": self.komi, "DT": self.game_id, "RU": rules},
|
||||
**default_properties,
|
||||
**(game_properties or {}),
|
||||
}
|
||||
)
|
||||
self.komi = self.root.komi
|
||||
handicap = katrain.config("game/handicap")
|
||||
if 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
|
||||
@@ -558,22 +565,27 @@ class Game(BaseGame):
|
||||
cn = self.current_node
|
||||
|
||||
if mode == "stop":
|
||||
self.katrain.pondering = False
|
||||
for e in set(self.engines.values()):
|
||||
e.stop_pondering()
|
||||
e.terminate_queries()
|
||||
self.katrain.idle_analysis = False
|
||||
return
|
||||
|
||||
engine = self.engines[cn.next_player]
|
||||
Clock.schedule_once(self.katrain.analysis_controls.hints.activate, 0)
|
||||
|
||||
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":
|
||||
if kwargs.get("continuous", False):
|
||||
visits = min(
|
||||
1_000_000_000, max(engine.config["max_visits"], math.ceil(cn.analysis_visits_requested * 1.25))
|
||||
)
|
||||
else:
|
||||
visits = cn.analysis_visits_requested + engine.config["max_visits"]
|
||||
self.katrain.controls.set_status(i18n._("extra analysis").format(visits=visits), STATUS_ANALYSIS)
|
||||
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,
|
||||
@@ -583,6 +595,7 @@ class Game(BaseGame):
|
||||
time_limit=False,
|
||||
)
|
||||
return
|
||||
|
||||
if mode == "game":
|
||||
nodes = self.root.nodes_in_tree
|
||||
only_mistakes = kwargs.get("mistakes_only", False)
|
||||
|
||||
@@ -187,6 +187,7 @@ class GameNode(SGFNode):
|
||||
engine,
|
||||
priority=PRIORITY_DEFAULT,
|
||||
visits=None,
|
||||
ponder=False,
|
||||
time_limit=True,
|
||||
refine_move=None,
|
||||
analyze_fast=False,
|
||||
@@ -201,6 +202,7 @@ class GameNode(SGFNode):
|
||||
),
|
||||
priority=priority,
|
||||
visits=visits,
|
||||
ponder=ponder,
|
||||
analyze_fast=analyze_fast,
|
||||
time_limit=time_limit,
|
||||
next_move=refine_move,
|
||||
@@ -278,6 +280,10 @@ class GameNode(SGFNode):
|
||||
def analysis_complete(self):
|
||||
return self.analysis["completed"] and self.analysis["root"] is not None
|
||||
|
||||
@property
|
||||
def root_visits(self):
|
||||
return ((self.analysis or {}).get("root") or {}).get("visits", 0)
|
||||
|
||||
@property
|
||||
def score(self) -> Optional[float]:
|
||||
if self.analysis_exists:
|
||||
|
||||
+26
-10
@@ -45,7 +45,7 @@ class Move:
|
||||
self.coords = coords
|
||||
|
||||
def __repr__(self):
|
||||
return f"Move({self.player}{self.gtp()})"
|
||||
return f"Move({self.player or ''}{self.gtp()})"
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.coords == other.coords and self.player == other.player
|
||||
@@ -247,22 +247,38 @@ class SGFNode:
|
||||
]
|
||||
return self.moves_cache
|
||||
|
||||
def _expanded_placements(self, player):
|
||||
sgf_pl = player if player is not None else "E" # AE
|
||||
placements = self.get_list_property("A" + sgf_pl, [])
|
||||
if not placements:
|
||||
return []
|
||||
to_be_expanded = [p for p in placements if ":" in p]
|
||||
board_size = self.board_size
|
||||
if to_be_expanded:
|
||||
coords = {
|
||||
Move.from_sgf(sgf_coord, player=player, board_size=board_size)
|
||||
for sgf_coord in placements
|
||||
if ":" not in sgf_coord
|
||||
}
|
||||
for p in to_be_expanded:
|
||||
from_coord, to_coord = [Move.from_sgf(c, board_size=board_size) for c in p.split(":")[:2]]
|
||||
for x in range(from_coord.coords[0], to_coord.coords[0] + 1):
|
||||
for y in range(to_coord.coords[1], from_coord.coords[1] + 1): # sgf upside dn
|
||||
if 0 <= x < board_size[0] and 0 <= y < board_size[1]:
|
||||
coords.add(Move((x, y), player=player))
|
||||
return list(coords)
|
||||
else:
|
||||
return [Move.from_sgf(sgf_coord, player=player, board_size=board_size) for sgf_coord in placements]
|
||||
|
||||
@property
|
||||
def placements(self) -> List[Move]:
|
||||
"""Returns all placements (AB/AW) in the node."""
|
||||
return [
|
||||
Move.from_sgf(sgf_coords, player=pl, board_size=self.board_size)
|
||||
for pl in Move.PLAYERS
|
||||
for sgf_coords in self.get_list_property("A" + pl, [])
|
||||
]
|
||||
return [coord for pl in Move.PLAYERS for coord in self._expanded_placements(pl)]
|
||||
|
||||
@property
|
||||
def clear_placements(self) -> List[Move]:
|
||||
"""Returns all AE clear square commends in the node."""
|
||||
return [
|
||||
Move.from_sgf(sgf_coords, player=None, board_size=self.board_size)
|
||||
for sgf_coords in self.get_list_property("AE", [])
|
||||
]
|
||||
return self._expanded_placements(None)
|
||||
|
||||
@property
|
||||
def move_with_placements(self) -> List[Move]:
|
||||
|
||||
Reference in new issue
Block a user