diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..fc1810b --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,25 @@ +name: test + +on: + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python-version: [3.6, 3.7, 3.8] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + + - name: Run tests + run: pytest tests diff --git a/README.md b/README.md index 517e1be..5ba99ad 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ but has since grown to include a wide range of features, including: * Play against a stronger player and use the retry option instead of handicap stones. * Automatically generate focused SGF reviews which show your biggest mistakes. -## Screenshots +## Animated Screenshots | Analyze games | Play against an AI Teacher | | ------------- | ------------- | -| ![screenshot](img/screenshot_analyze.png) | ![screenshot](img/screenshot_play.png) | +| ![screenshot](img/anim_analyze.gif) | ![screenshot](img/anim_teach.gif) | ## Quickstart @@ -48,12 +48,13 @@ If you do not want to see 'Points lost' or other feedback for your moves, The dots indicate how many points were lost by that move. -* The colour indicates the size of the mistake according to kata +* The colour indicates the size of the mistake according to KataGo * The size indicates if the mistake was actually punished. Going from fully punished at maximal size, to no actual effect on the score at minimal size. In short, if you are a weaker player you should mostly on large dots that are red or purple, -while stronger players can pay more attention to smaller mistakes. +while stronger players can pay more attention to smaller mistakes. If you want to hide some colours, you +can do so under 'Configure Teacher'. #### AIs @@ -69,9 +70,12 @@ Available AIs, with strength indicating an estimate for the default settings, ar The setting `pick_override` determines the minimum value at which this process is bypassed to play the best move instead, preventing obvious blunders. This, along with 'Weighted' are probably the best choice for kyu players who want a chance of winning without playing the sillier bots below. Variants of this strategy include: * **[~2k]**: **P:Local** will pick such moves biased towards the last move with probability related to `local_stddev`. - * **[~10k]**: **P:Tenuki** is biased in the opposite way as P:Local, using the same setting. + * **[~5k]**: **P:Tenuki** is biased in the opposite way as P:Local, using the same setting. After about half the board is filled, it stops and plays like P:Pick. * **[~10k]**: **P:Influence** is biased towards 4th+ line moves, with every line below that dividing both the chance of considering the move and the policy value by `influence_weight`. Consider setting `pick_frac=1.0` to only affect the policy weight. - * **[~10k]**: **P:Territory** is biased in the opposite way, towards 1-3rd line moves, using the same setting. + * **[~10k]**: **P:Territory** is biased in the opposite way, towards 1-3rd line moves, using the same setting. Both of these also stop the strategy in endgame and revert to P:Pick. + +The Engine based AIs (Default, ScoreLoss, Balance, Jigo) are affected by both the model and choice of max_visits/max_time, + while the Policy net based AIs (Policy, P:...) are affected by the choice of model file, but work identically with 'max_visits' set to 1. ### Analysis diff --git a/core/game.py b/core/game.py index 74f7762..08f0aa2 100644 --- a/core/game.py +++ b/core/game.py @@ -26,7 +26,7 @@ class Game: def __init__(self, katrain, engine: Union[Dict, KataGoEngine], config: Dict, move_tree: GameNode = None, analyze_fast=False): self.katrain = katrain - if isinstance(engine, KataGoEngine): + if not isinstance(engine, Dict): engine = {"B": engine, "W": engine} self.engines = engine self.config = config diff --git a/core/main.py b/core/main.py deleted file mode 100644 index e69de29..0000000 diff --git a/core/sgf_parser.py b/core/sgf_parser.py index 66c54b8..a9f6e14 100644 --- a/core/sgf_parser.py +++ b/core/sgf_parser.py @@ -88,19 +88,23 @@ class SGFNode: def sgf(self, **xargs) -> str: """Generates an SGF, calling sgf_properties on each node with the given xargs, so it can filter relevant properties if needed.""" - if self.is_root: - import sys - bszx, bszy = self.board_size - sys.setrecursionlimit(max(sys.getrecursionlimit(), 4 * bszx * bszy)) - sgf_str = "".join([prop + "".join(f"[{v}]" for v in values) for prop, values in self.sgf_properties(**xargs).items() if values]) - if self.children: - children = [c.sgf(**xargs) for c in self.order_children(self.children)] - if len(children) == 1: - sgf_str += ";" + children[0] + def node_sgf_str(node): + return ";" + "".join([prop + "".join(f"[{v}]" for v in values) for prop, values in node.sgf_properties(**xargs).items() if values]) + + stack = [")", self, "("] + sgf_str = "" + while stack: + item = stack.pop() + if isinstance(item, str): + sgf_str += item else: - sgf_str += "(;" + ")(;".join(children) + ")" - return f"(;{sgf_str})" if self.is_root else sgf_str + sgf_str += node_sgf_str(item) + if len(item.children) == 1: + stack.append(item.children[0]) + elif item.children: + stack += sum([[")", c, "("] for c in self.order_children(item.children)[::-1]], []) + return sgf_str def add_list_property(self, property: str, values: List): """Add some values to the property list.""" diff --git a/gui/badukpan.py b/gui/badukpan.py index c865f1e..ccd5ab0 100644 --- a/gui/badukpan.py +++ b/gui/badukpan.py @@ -370,7 +370,6 @@ class BadukPanWidget(Widget): else: board_coords = (self.gridpos_x[coords[0]], self.gridpos_y[coords[1]]) - draw_circle(board_coords, self.stone_size, stone_color[move_player]) Color(*stone_color[opp_player]) draw_text(pos=board_coords, text=str(i + 1), font_size=self.grid_size / 1.45) @@ -380,7 +379,7 @@ class BadukPanWidget(Widget): self.animating_pv = (pv, node, time.time(), self.last_mouse_pos) def show_pv_from_comments(self, pv_str): - self.set_animating_pv(pv_str[1:].split(" "), self.katrain.controls.active_comment_node) + self.set_animating_pv(pv_str[1:].split(" "), self.katrain.controls.active_comment_node.parent) class BadukPanControls(BoxLayout): diff --git a/img/anim_analyze.gif b/img/anim_analyze.gif new file mode 100644 index 0000000..6580d01 Binary files /dev/null and b/img/anim_analyze.gif differ diff --git a/img/anim_pvtop.gif b/img/anim_pvtop.gif new file mode 100644 index 0000000..db3e6d1 Binary files /dev/null and b/img/anim_pvtop.gif differ diff --git a/img/anim_teach.gif b/img/anim_teach.gif new file mode 100644 index 0000000..ac0c535 Binary files /dev/null and b/img/anim_teach.gif differ diff --git a/spec/KaTrain.spec b/spec/KaTrain.spec index 77aa90e..4be2782 100644 --- a/spec/KaTrain.spec +++ b/spec/KaTrain.spec @@ -34,8 +34,8 @@ exe = EXE(pyz, upx=True, console=True) # , icon='..\\icon.png' -EXCLUDE_SUFFIX = ['katago','b10-1.3.txt.gz','screenshot_analyze.png','screenshot_play.png'] -EXCLUDE = ['KataGoData'] +EXCLUDE_SUFFIX = ['katago','b10-1.3.txt.gz'] +EXCLUDE = ['KataGoData','anim_','screenshot_'] a.datas = [(ff,ft,tp) for ff,ft,tp in a.datas if not any(ff.endswith(suffix) for suffix in EXCLUDE_SUFFIX) and not any(kw in ff for kw in EXCLUDE)] coll = COLLECT(exe, a.binaries, diff --git a/tests/test_board.py b/tests/test_board.py index b64c919..5dff3ee 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -18,7 +18,7 @@ class TestBoard: return [c for c in b.chains if c] def test_merge(self): - b = Game(MockKaTrain(), MockEngine(), {}, board_size=9) + b = Game(MockKaTrain(), MockEngine(), config={"init_size": 9}) b.play(Move.from_gtp("B9", player="B")) b.play(Move.from_gtp("A3", player="B")) b.play(Move.from_gtp("A9", player="B")) @@ -27,7 +27,7 @@ class TestBoard: assert 0 == len(b.prisoners) def test_collide(self): - b = Game(MockKaTrain(), MockEngine(), {}, board_size=9) + b = Game(MockKaTrain(), MockEngine(), config={"init_size": 9}) b.play(Move.from_gtp("B9", player="B")) with pytest.raises(IllegalMoveException): b.play(Move.from_gtp("B9", player="W")) @@ -36,7 +36,7 @@ class TestBoard: assert 0 == len(b.prisoners) def test_capture(self): - b = Game(MockKaTrain(), MockEngine(), {}, board_size=9) + b = Game(MockKaTrain(), MockEngine(), config={"init_size": 9}) b.play(Move.from_gtp("A2", player="B")) b.play(Move.from_gtp("B1", player="W")) b.play(Move.from_gtp("A1", player="W")) @@ -57,7 +57,7 @@ class TestBoard: assert 2 == len(b.prisoners) def test_snapback(self): - b = Game(MockKaTrain(), MockEngine(), {}, board_size=9) + b = Game(MockKaTrain(), MockEngine(), config={"init_size": 9}) for move in ["C1", "D1", "E1", "C2", "D3", "E4", "F2", "F3", "F4"]: b.play(Move.from_gtp(move, player="B")) for move in ["D2", "E2", "C3", "D4", "C4"]: @@ -75,7 +75,7 @@ class TestBoard: assert 4 == len(b.prisoners) def test_ko(self): - b = Game(MockKaTrain(), MockEngine(), {}, board_size=9) + b = Game(MockKaTrain(), MockEngine(), config={"init_size": 9}) for move in ["A2", "B1"]: b.play(Move.from_gtp(move, player="B")) diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..2a424f5 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,53 @@ +import os + +from core.sgf_parser import SGF + + +def test_simple(): + input_sgf = "(;GM[1]FF[4]SZ[19]DT[2020-04-12]AB[dd][dj];B[dp];W[pp];B[pj])" + root = SGF.parse(input_sgf) + assert "4" == root.get_property("FF") + assert root.get_property("XYZ") is None + assert "dp" == root.children[0].get_property("B") + assert input_sgf == root.sgf() + + +def test_branch(): + input_sgf = "(;GM[1]FF[4]CA[UTF-8]AP[Sabaki:0.43.3]KM[6.5]SZ[19]DT[2020-04-12]AB[dd][dj](;B[dp];W[pp](;B[pj])(;PL[B]AW[jp]C[sdfdsfdsf]))(;B[pd]))" + root = SGF.parse(input_sgf) + assert input_sgf == root.sgf() + + +def test_weird_escape(): + input_sgf = """(;GM[1]FF[4]CA[UTF-8]AP[Sabaki:0.43.3]KM[6.5]SZ[19]DT[2020-04-12]C[how does it escape +[ +or \\] +])""" + root = SGF.parse(input_sgf) + assert input_sgf == root.sgf() + + +def test_alphago(): + file = os.path.join(os.path.dirname(__file__), "data/LS vs AG - G4 - English.sgf") + SGF.parse_file(file) + + +def test_pandanet(): + file = os.path.join(os.path.dirname(__file__), "data/panda1.sgf") + root = SGF.parse_file(file) + root_props = {"GM", "EV", "US", "CoPyright", "GN", "RE", "PW", "WR", "NW", "PB", "BR", "NB", "PC", "DT", "SZ", "TM", "KM", "LT", "RR", "HA", "AB", "C"} + assert root_props == root.properties.keys() + + move = root + while move.children: + move = move.children[0] + assert 94 == len(move.get_list_property("TW")) + assert "Trilan" == move.get_property("OS") + while move.parent: + move = move.parent + assert move is root + + +def test_ogs(): + file = os.path.join(os.path.dirname(__file__), "data/ogs.sgf") + tree = SGF.parse_file(file)