Merge pull request #34 from sanderland/v1.0.5

V1.0.5
This commit is contained in:
sanderland authored and GitHub committed 2020-05-13 21:57:30 +02:00
commit e0d07fa880
74 files changed
+1659 -572

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
name: test
on:
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: [3.6, 3.7]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip3 install -e .
pip3 install pytest wheel
- name: Run tests
run: pytest tests
- name: Build
run: |
python3 setup.py sdist
python3 setup.py bdist_wheel
+3 -2
View File
@@ -1,12 +1,13 @@
# additions
KataGoData
KataGo/KataGoData
katrain/KataGo/KataGoData
experiments
.idea
.buildozer
bin
gtp.log
*.zip
log.txt
*.sgf
sgfout
sgf_selfplay
sgf_ogs
+30 -42
View File
@@ -1,55 +1,43 @@
# Installation from source for Windows users
* Download the repository by clicking the green *Clone or download* on this page and *Download zip*. Extract the contents.
* Make sure you have a python installation, I will assume Anaconda (Python 3.7), available [here](https://www.anaconda.com/products/individual#download-section).
* Open 'Anaconda prompt' from the start menu and navigate to where you extracted the zip file using the `cd <folder>` command.
* Execute the command `pip install kivy_deps.glew kivy_deps.sdl2 kivy_deps.gstreamer kivy`
* Start the app by running `python katrain.py` in the directory where you downloaded the scripts.
* Note that the program can be slow to initialize the first time, due to KataGo's gpu tuning.
# Installation for Linux users
* This assumed you have a working Python 3.6/3.7 installation as a default. If your default is python 2, use pip3/python3.
Kivy currently does not have a release for Python 3.8.
* Open a terminal.
* Run the command `git clone https://github.com/sanderland/katrain.git` to download the repository.
* Run the command `pip install kivy`.
* A binary for KataGo is included, but if you have compiled your own, point the 'engine/katago' setting to the relevant KataGo v1.4+ binary.
* Start the app by changing directory using `cd katrain` and running `python katrain.py`.
* Note that the program can be slow to initialize the first time, due to KataGo's GPU tuning.
# Installation for MacOS users
## Installation pre-requisites
## <a name="MacPrereq"></a>Installation pre-requisites
* Download and install [Python 3.7.5](https://www.python.org/downloads/release/python-375/)
* Install [Homebrew](https://brew.sh) by running the following command in terminal:
* ```
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"
```
* Run the command `pip3 install kivy` in the terminal.
* Install Katago using [Homebrew](https://brew.sh/)
* Note that the version required for KaTrain is currently too new so we need to update the Homebrew script.
* Run the command `brew edit katago` and replace lines 4-5 with
* ```
url "https://github.com/lightvector/KataGo/archive/v1.4.1.tar.gz"
sha256 "b408086c7c973ddc6144e16156907556ae5f42921b9f29dc13e6909a9e9a4787"
```
* You can also follow instructions [here](https://github.com/lightvector/KataGo) to compile KataGo yourself.
* Install Katago using [Homebrew](https://brew.sh/) by executing `brew install katago`
* You can also follow instructions [here](https://github.com/lightvector/KataGo) to compile KataGo yourself.
## Installation and running KaTrain
* Now that the dependencies are installed its time to Git clone or download the KaTrain repository
* Run the command `git clone https://github.com/sanderland/katrain.git` this will clone KaTrain to your home folder.
* To run Katrain you need to first access the KaTrain folder.
* If you used the 'git clone' command to download the repository then its located in your home folder.
You can access it by typing `cd katrain` in the terminal.
* If you've moved the folder to another location the easiest way to navigate to it in terminal is to type `cd` and drag
the KaTrain folder from the finder window into terminal. This will copy its full path to the command line.
* Now that we're in the KaTrain folder run the following command. `python3 katrain.py`
* The first time you run KaTrain you will see an error about initializing KataGo.
## Installation and running KaTrain from PyPi
* Run `pip3 install katrain`
* Run the program by executing `katrain` in a terminal.
* If you see an error about initializing KataGo:
* Open the settings dialog by clicking on the gear icon at the bottom right of the window and change the path of the 'katago'
setting to `/usr/local/bin/katago` (or the path where you compiled KataGo) then click 'Apply and Save'.
setting under 'engine' to `/usr/local/bin/katago`, or the path where you compiled KataGo.
## Installation from sources
* This is largely the same as for linux, see [here](#LinuxSources).
# Installation from sources for Windows users
* Download the repository by clicking the green *Clone or download* on this page and *Download zip*. Extract the contents.
* Make sure you have a python installation, I will assume Anaconda (Python 3.7), available [here](https://www.anaconda.com/products/individual#download-section).
* Open 'Anaconda prompt' from the start menu and navigate to where you extracted the zip file using the `cd <folder>` command.
* Execute the command `python setup.py install`
* Start the app by running `katrain` in the directory where you downloaded the scripts.
# <a name="LinuxSources"></a>Installation from sources for Linux users
* This assumed you have a working Python 3.6/3.7 installation as a default. If your default is python 2, use pip3/python3.
Kivy currently does not have a release for Python 3.8.
* Open a terminal.
* Run the command `git clone https://github.com/sanderland/katrain.git` to download the repository.
* Changing directory using `cd katrain`.
* Run the command `python3 setup.py install`.
* A binary for KataGo is included, but if you have compiled your own, point the 'engine/katago' setting to the relevant KataGo v1.4+ binary.
* Run the program by typing `katrain` in the terminal.
# Configuring the GPU(s) KataGo uses
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
pytest = "*"
[packages]
kivy = ">=1.11"
[requires]
python_version = "3.7"
Generated
+178
View File
@@ -0,0 +1,178 @@
{
"_meta": {
"hash": {
"sha256": "63ca72eff43ccb8467fc921871c0438382996aa6fc1642e955cf1432c90ce520"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.7"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"certifi": {
"hashes": [
"sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304",
"sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"
],
"version": "==2020.4.5.1"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"docutils": {
"hashes": [
"sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af",
"sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"
],
"version": "==0.16"
},
"idna": {
"hashes": [
"sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",
"sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"
],
"version": "==2.9"
},
"kivy": {
"hashes": [
"sha256:090d3ded9835a17477cd93fbdaf0a7c42ff2218981cf198ded5ad8795bc74391",
"sha256:11e85eaf6efbfa2362a3334ffdad179a1b0ca8d255cca79eaa6a2765560d4982",
"sha256:1a1ff32f8a95f1e175198cbab81fcd2596783b180d4eafe63e87d171aa7fdb5e",
"sha256:1d28b198a64c30db8d94a0488e85f3037af60d514ab0d7ad5ab45add3ab77090",
"sha256:4a5480cbf837d3780c77a4f61b32b56d22ae9f03845e7a89dd3eaef1ae5fd037",
"sha256:4d0e596f74271e901b551f77661dde238df4765484fce9f5d1c72e8022984e84",
"sha256:5c3d0f2749522d62e9cce09cd54b2d823bf1b6b644ff1f627be49de6f3e3cba0",
"sha256:815a5c0b3b72fcd81ca7b2aa0744087163ed03e4cf9ab4e7c9733cea99fc1571",
"sha256:8819a27a09871af451760cb69486ced52e830c8a0a37480f22ef5e692f12c05b",
"sha256:a687602d90c4629dd036f577ca39acb76ba581370f9d915f3cab99be818ba8ad",
"sha256:b7ef6aad43a86d8df3fb865db864e354f2155a748019f8517f69f65c1a29cb64",
"sha256:b85ccf165050cbf2ee8447671eebbc222b369b40f0e0038dd9547d49a5e37373",
"sha256:c36652caa7f6c327dee834cfc699d5962d346b7a53e54bd81abc17c314226d89",
"sha256:ece170514db3f49844a41e4c910ad9ce9bc46da6f47a49158e11266bdcc6e479",
"sha256:f3bea6e4a21991827885d04127fc6d09a0e974ecfa12da7bf5faae93562ea102",
"sha256:f835462dd9aa491272552ef079b948a088598e2e95d68bb1d885d2c3f3d4e2c3"
],
"index": "pypi",
"version": "==1.11.1"
},
"kivy-garden": {
"hashes": [
"sha256:c256f42788421273a08fbb0a228f0fb0e80dd86b629fb8c0920507f645be6c72"
],
"version": "==0.1.4"
},
"pygments": {
"hashes": [
"sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44",
"sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"
],
"version": "==2.6.1"
},
"requests": {
"hashes": [
"sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
"sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
],
"version": "==2.23.0"
},
"urllib3": {
"hashes": [
"sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527",
"sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"
],
"version": "==1.25.9"
}
},
"develop": {
"attrs": {
"hashes": [
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
],
"version": "==19.3.0"
},
"importlib-metadata": {
"hashes": [
"sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f",
"sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e"
],
"markers": "python_version < '3.8'",
"version": "==1.6.0"
},
"more-itertools": {
"hashes": [
"sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c",
"sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"
],
"version": "==8.2.0"
},
"packaging": {
"hashes": [
"sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3",
"sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"
],
"version": "==20.3"
},
"pluggy": {
"hashes": [
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
],
"version": "==0.13.1"
},
"py": {
"hashes": [
"sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa",
"sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"
],
"version": "==1.8.1"
},
"pyparsing": {
"hashes": [
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
],
"version": "==2.4.7"
},
"pytest": {
"hashes": [
"sha256:95c710d0a72d91c13fae35dce195633c929c3792f54125919847fdcdf7caa0d3",
"sha256:eb2b5e935f6a019317e455b6da83dd8650ac9ffd2ee73a7b657a30873d67a698"
],
"index": "pypi",
"version": "==5.4.2"
},
"six": {
"hashes": [
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
],
"version": "==1.14.0"
},
"wcwidth": {
"hashes": [
"sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1",
"sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"
],
"version": "==0.1.9"
},
"zipp": {
"hashes": [
"sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b",
"sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"
],
"version": "==3.1.0"
}
}
}
+21 -15
View File
@@ -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](katrain/img/anim_analyze.gif) | ![screenshot](katrain/img/anim_teach.gif) |
## Quickstart
@@ -26,7 +26,9 @@ but has since grown to include a wide range of features, including:
## Installation
* See the [releases tab](https://github.com/sanderland/katrain/releases) for pre-built installers for windows.
* See [here](INSTALL.md) for detailed instructions for running from source files on Window, Linux and MacOS,
* Alternatively use `pip3 install -U katrain` to install the latest version from PyPI on any OS.
* Note that on MacOS you will need to set up KataGo using brew, as described [here](INSTALL.md).
* See [here](INSTALL.md#MacPrereq) for detailed instructions for running from source files on Window, Linux and MacOS,
as well as setting up KataGo to use multiple GPUs.
## Manual
@@ -48,30 +50,34 @@ 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
Available AIs, with strength indicating an estimate for the default settings, are:
Available AIs, with strength indicating an estimate for the default settings based on their current OGS rankings, are:
* **[9p+]** **Default** is full KataGo, above professional level.
* **[~1k?]** **ScoreLoss** is KataGo making moves with probability `~ e^(-strength * points lost)`, playing a varied style with small mistakes.
* **[~5k]** **ScoreLoss** is KataGo making moves with probability `~ e^(-strength * points lost)`, playing a varied style with small mistakes.
* **Balance** is KataGo occasionally making weaker moves, attempting to win by ~2 points.
* **Jigo** is KataGo aggressively making weaker moves, attempting to win by 0.5 points.
* **[~4d]** **Policy** uses the top move from the policy network (it's 'shape sense' without reading), should be around high dan level depending on the model used. There is a setting to increase variety in the opening, but otherwise it plays deterministically.
* **[~2k]**: **P:Weighted** picks a random move weighted by the policy, as long as it's above `lower_bound`. `weaken_fac` uses `policy^(1/weaken_fac)`, increasing the chance for weaker moves.
* **[~5k]**: **P:Pick** picks `pick_n + pick_frac * <number of legal moves>` moves at random, and play the best move among them.
* **[~3k]**: **P:Weighted** picks a random move weighted by the policy, as long as it's above `lower_bound`. `weaken_fac` uses `policy^(1/weaken_fac)`, increasing the chance for weaker moves.
* **[~7k]**: **P:Pick** picks `pick_n + pick_frac * <number of legal moves>` moves at random, and play the best move among them.
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.
* **[~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.
* **[~3k]**: **P:Local** will pick such moves biased towards the last move with probability related to `local_stddev`.
* **[~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.
* **[~6k]**: **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.
* **[~8k]**: **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
@@ -101,7 +107,7 @@ In addition to shortcuts mentioned above, there are:
* **[arrow down]** or **[x]**: Redo move. Hold shift for 10 moves at a time, or ctrl to skip to the start.
* **[scroll up]**: Undo move. Only works when hovering the cursor over the board.
* **[scroll down]**: Redo move. Only works when hovering the cursor over the board.
* **[click on a move]**: See detailed statistics for a previous move.
* **[click on a move]**: See detailed statistics for a previous move, along with expected variation that was best instead of this move.
* **[double-click on a move]**: Navigate directly to that point in the game.
* **[Ctrl-v]**: Load SGF from clipboard and do a 'fast' analysis of the game (with a high priority normal analysis for the last move).
* **[Ctrl-c]**: Save SGF to clipboard.
@@ -156,5 +162,5 @@ If you ever need to reset to the original settings, simply re-download the `conf
* Feedback and pull requests are both very welcome. I would also be happy to host translations of this manual into languages where English fluency is typically lower.
* For suggestions and planned improvements, see the 'issues' tab on github.
* You can also contact me on discord (Sander#3278), [KakaoTalk](https://open.kakao.com/o/gTsMJCac) or [Reddit](http://reddit.com/u/sanderbaduk) to give feedback, or simply show your appreciation.
* You can also contact me on [discord](https://discord.gg/AjTPFpN) (Sander#3278), [KakaoTalk](https://open.kakao.com/o/gTsMJCac) or [Reddit](http://reddit.com/u/sanderbaduk) to give feedback, or simply show your appreciation.
* Some people have also asked me how to donate. Something go-related such as a book or teaching time is highly appreciated.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -4,12 +4,12 @@ import sys
import time
import random
from core.ai import ai_move
from core.common import OUTPUT_ERROR, OUTPUT_INFO
from katrain.core.ai import ai_move
from katrain.core.common import OUTPUT_ERROR, OUTPUT_INFO
from bots.settings import bot_strategy_names
from core.engine import EngineDiedException, KataGoEngine
from core.game import Game
from core.sgf_parser import Move
from katrain.core.engine import EngineDiedException, KataGoEngine
from katrain.core.game import Game
from katrain.core.sgf_parser import Move
if len(sys.argv) < 2:
bot = "dev"
@@ -42,7 +42,7 @@ ENGINE_SETTINGS = {
engine = KataGoEngine(logger, ENGINE_SETTINGS)
with open("config.json") as f:
with open("katrain/config.json") as f:
settings = json.load(f)
all_ai_settings = settings["ai"]
Binary file not shown.
+3 -3
View File
@@ -6,8 +6,8 @@ import sys
import threading
import traceback
from core.common import OUTPUT_INFO
from core.engine import KataGoEngine
from katrain.core.common import OUTPUT_INFO
from katrain.core.engine import KataGoEngine
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8587
@@ -18,7 +18,7 @@ ENGINE_SETTINGS = {
"config": "KataGo/analysis_config.cfg",
"max_visits": 50,
"max_time": 1.0,
"enable_ownership": False,
"_enable_ownership": False,
"threads": 32,
}
+193
View File
@@ -0,0 +1,193 @@
# Example config for C++ (non-python) gtp bot
# SEE NOTES ABOUT PERFORMANCE AND MEMORY USAGE IN gtp_example.cfg
# Logs------------------------------------------------------------------------------------
# Where to output log?
logFile = gtp.log
# Controls the number of moves after the first move in a variation.
# analysisPVLen = 15
# Report winrates for analysis as (BLACK|WHITE|SIDETOMOVE).
reportAnalysisWinratesAs = BLACK
# Bot behavior---------------------------------------------------------------------------------------
# Handicap -------------
# Assume that if black makes many moves in a row right at the start of the game, then the game is a handicap game.
# This is necessary on some servers and for some GUIs and also when initializing from many SGF files, which may
# set up a handicap games using repeated GTP "play" commands for black rather than GTP "place_free_handicap" commands.
# However, it may also lead to incorrect undersanding of komi if whiteBonusPerHandicapStone = 1 and a server does NOT
# have such a practice.
# Defaults to true! Uncomment and set to false to disable this behavior.
# assumeMultipleStartingBlackMovesAreHandicap = true
# Passing and cleanup -------------
# Make the bot never assume that its pass will end the game, even if passing would end and "win" under Tromp-Taylor rules.
# Usually this is a good idea when using it for analysis or playing on servers where scoring may be implemented non-tromp-taylorly.
# Defaults to true! Uncomment and set to false to disable this.
conservativePass = true
# When using territory scoring, self-play games continue beyond two passes with special cleanup
# rules that may be confusing for human players. This option prevents the special cleanup phases from being
# reachable when using the bot for GTP play.
# Defaults to true! Uncomment and set to false if you want KataGo to be able to enter special cleanup.
# For example, if you are testing it against itself, or against another bot that has precisely implemented the rules
# documented at https://lightvector.github.io/KataGo/rules.html
# preventCleanupPhase = true
# Search limits-----------------------------------------------------------------------------------
# By default, if NOT specified in an individual request, limit maximum number of root visits per search to this much
maxVisits = 500
# If provided, cap search time at this many seconds
# maxTime = 60
# Number of threads to use in each search in parallel for any SINGLE position.
# NOTE: Analysis engine can specify number of POSITIONS to be able to search in parallel via command line argument
# so this number does not necessarily need to be larger than 1, although you can still set it larger if you prefer
# to analyze fewer positions in parallel but spend more threads on each position.
# Generally, having more threads on a single position will worsen the quality of search slightly, holding fixed the
# number of visits, and thread contention will reduce efficiency, so cross-position parallelization is preferable
# to numSearchThreads, but numSearchThreads is preferable if you want to reduce latency, and have individual
# searches complete faster by doing fewer of them at a time.
numSearchThreads = 2
# GPU Settings-------------------------------------------------------------------------------
# Maximum number of positions to send to GPU at once.
nnMaxBatchSize = 32
# Cache up to 2 ** this many neural net evaluations in case of transpositions in the tree.
nnCacheSizePowerOfTwo = 14
# Size of mutex pool for nnCache is 2 ** this
nnMutexPoolSizePowerOfTwo = 14
# Randomize board orientation when running neural net evals?
nnRandomize = true
# TO USE MULTIPLE GPUS:
# Set this to the number of GPUs you have and/or would like to use...
# AND if it is more than 1, uncomment the appropriate CUDA or OpenCL section below.
# numNNServerThreadsPerModel = 1
# CUDA GPU settings--------------------------------------
# These only apply when using the CUDA version of KataGo.
# IF USING ONE GPU: optionally uncomment and change this if the GPU you want to use turns out to be not device 0
# cudaDeviceToUse = 0
# IF USING TWO GPUS: Uncomment these two lines (AND set numNNServerThreadsPerModel above):
# cudaDeviceToUseThread0 = 0 # change this if the first GPU you want to use turns out to be not device 0
# cudaDeviceToUseThread1 = 1 # change this if the second GPU you want to use turns out to be not device 1
# IF USING THREE GPUS: Uncomment these three lines (AND set numNNServerThreadsPerModel above):
# cudaDeviceToUseThread0 = 0 # change this if the first GPU you want to use turns out to be not device 0
# cudaDeviceToUseThread1 = 1 # change this if the second GPU you want to use turns out to be not device 1
# cudaDeviceToUseThread2 = 2 # change this if the third GPU you want to use turns out to be not device 2
# You can probably guess the pattern if you have four, five, etc. GPUs.
# KataGo will automatically use FP16 or not based on the compute capability of your NVIDIA GPU. If you
# want to try to force a particular behavior though you can uncomment these lines and change them
# to "true" or "false". E.g. it's using FP16 but on your card that's giving an error, or it's not using
# FP16 but you think it should.
# cudaUseFP16 = auto
# cudaUseNHWC = auto
# OpenCL GPU settings--------------------------------------
# These only apply when using the OpenCL version of KataGo.
# Uncomment to tune OpenCL for every board size separately, rather than only the largest possible size
# openclReTunePerBoardSize = true
# IF USING ONE GPU: optionally uncomment and change this if the best device to use is guessed incorrectly.
# The default behavior tries to guess the 'best' GPU or device on your system to use, usually it will be a good guess.
# openclDeviceToUse = 0
# IF USING TWO GPUS: Uncomment these two lines and replace X and Y with the device ids of the devices you want to use.
# It might NOT be 0 and 1, some computers will have many OpenCL devices. You can see what the devices are when
# KataGo starts up - it should print or log all the devices it finds.
# (AND also set numNNServerThreadsPerModel above)
# openclDeviceToUseThread0 = X
# openclDeviceToUseThread1 = Y
# IF USING THREE GPUS: Uncomment these three lines and replace X and Y and Z with the device ids of the devices you want to use.
# It might NOT be 0 and 1 and 2, some computers will have many OpenCL devices. You can see what the devices are when
# KataGo starts up - it should print or log all the devices it finds.
# (AND also set numNNServerThreadsPerModel above)
# openclDeviceToUseThread0 = X
# openclDeviceToUseThread1 = Y
# openclDeviceToUseThread2 = Z
# You can probably guess the pattern if you have four, five, etc. GPUs.
# Root move selection and biases------------------------------------------------------------------------------
# Uncomment and edit any of the below values to change them from their default.
# Not all of these parameters are applicable to analysis, some are only used for actual play
# Temperature for the early game, randomize between chosen moves with this temperature
# chosenMoveTemperatureEarly = 0.5
# Decay temperature for the early game by 0.5 every this many moves, scaled with board size.
# chosenMoveTemperatureHalflife = 19
# At the end of search after the early game, randomize between chosen moves with this temperature
# chosenMoveTemperature = 0.10
# Subtract this many visits from each move prior to applying chosenMoveTemperature
# (unless all moves have too few visits) to downweight unlikely moves
# chosenMoveSubtract = 0
# The same as chosenMoveSubtract but only prunes moves that fall below the threshold, does not affect moves above
# chosenMovePrune = 1
# Number of symmetries to sample (WITH replacement) and average at the root
# rootNumSymmetriesToSample = 1
# Using LCB for move selection?
# useLcbForSelection = true
# How many stdevs a move needs to be better than another for LCB selection
# lcbStdevs = 5.0
# Only use LCB override when a move has this proportion of visits as the top move
# minVisitPropForLCB = 0.15
# Internal params------------------------------------------------------------------------------
# Uncomment and edit any of the below values to change them from their default.
# Scales the utility of winning/losing
# winLossUtilityFactor = 1.0
# Scales the utility for trying to maximize score
# staticScoreUtilityFactor = 0.10
# dynamicScoreUtilityFactor = 0.30
# Adjust dynamic score center this proportion of the way towards zero, capped at a reasonable amount.
# dynamicScoreCenterZeroWeight = 0.20
# dynamicScoreCenterScale = 0.75
# The utility of getting a "no result" due to triple ko or other long cycle in non-superko rulesets (-1 to 1)
# noResultUtilityForWhite = 0.0
# The number of wins that a draw counts as, for white. (0 to 1)
# drawEquivalentWinsForWhite = 0.5
# Exploration constant for mcts
# cpuctExploration = 0.9
# cpuctExplorationLog = 0.4
# FPU reduction constant for mcts
# fpuReductionMax = 0.2
# rootFpuReductionMax = 0.1
# Use parent average value for fpu base point instead of point value net estimate
# fpuUseParentAverage = true
# Amount to apply a downweighting of children with very bad values relative to good ones
# valueWeightExponent = 0.5
# Slight incentive for the bot to behave human-like with regard to passing at the end, filling the dame,
# not wasting time playing in its own territory, etc, and not play moves that are equivalent in terms of
# points but a bit more unfriendly to humans.
# rootEndingBonusPoints = 0.5
# Make the bot prune useless moves that are just prolonging the game to avoid losing yet
# rootPruneUselessMoves = true
# How big to make the mutex pool for search synchronization
# mutexPoolSize = 8192
# How many virtual losses to add when a thread descends through a node
# numVirtualLossesPerThread = 1
+30 -21
View File
@@ -7,11 +7,11 @@ import traceback
from collections import defaultdict
from concurrent.futures.thread import ThreadPoolExecutor
from core.ai import ai_move
from core.common import OUTPUT_ERROR, OUTPUT_INFO
from katrain.core.ai import ai_move
from katrain.core.common import OUTPUT_ERROR, OUTPUT_INFO
from elote import EloCompetitor
from core.engine import KataGoEngine
from core.game import Game
from katrain.core.engine import KataGoEngine
from katrain.core.game import Game
import json
DB_FILENAME = "bots/ai_performance.pickle"
@@ -36,7 +36,7 @@ class AI:
DEFAULT_ENGINE_SETTINGS = {
"katago": "KataGo/katago",
"model": "KataGo/models/b15-1.3.2.txt.gz",
"config": "KataGo/analysis_config.cfg",
"config": "bots/lowmem.cfg",
"max_visits": 1,
"max_time": 300.0,
"_enable_ownership": False,
@@ -75,9 +75,14 @@ class AI:
try:
with open(DB_FILENAME, "rb") as f:
ai_database, all_results = pickle.load(f)
for ai in ai_database:
ai.fix_settings() # update as required
ai_database_loaded, all_results = pickle.load(f)
ai_database = []
for ai in ai_database_loaded:
try:
ai.fix_settings() # update as required
ai_database.append(ai)
except:
print("Error loading AI", ai.strategy)
except FileNotFoundError:
ai_database = []
all_results = []
@@ -96,25 +101,26 @@ def retrieve_ais(selected_ais):
test_ais = [
# AI("Jigo", {}, {"max_visits": 100}),
AI("Policy", {}, {"model": "my/model.bin.gz"}),
AI("Policy", {}, {"model": "KataGo/models/b10-1.3.txt.gz"}),
AI("Default", {}, {"model": "bots/6b.bin.gz", "max_visits": 500}),
AI("Default", {}, {"model": "bots/6b104-s22347264.txt.gz", "max_visits": 500}),
AI("Default", {}, {"model": "bots/6b104-s42364928.txt.gz", "max_visits": 500}),
# AI("Default", {}, {"model": "KataGo/models/b10-1.3.txt.gz", "max_visits": 500}),
AI("Policy", {}),
AI("P:Local", {}),
AI("P:Pick", {}),
AI("P:Noise", {}),
AI("P:Tenuki", {}),
AI("P:Local", {}),
AI("P:Influence", {}),
AI("P:Territory", {}),
AI("P:Weighted", {}),
AI("P:Pick", {}),
AI("ScoreLoss", {"max_visits": 500}),
# AI("P:Tenuki", {}),
# AI("P:Local", {}),
AI("P:Influence", {}),
# AI("P:Territory", {}),
]
for ai in test_ais:
add_ai(ai)
N_GAMES = 5
N_GAMES = 1
BOARDSIZE = 19
ais_to_test = retrieve_ais(test_ais)
@@ -131,13 +137,16 @@ def play_games(black: AI, white: AI):
game.root.add_list_property("PW", [white.name])
game.root.add_list_property("PB", [black.name])
start_time = time.time()
while not game.ended:
while not game.ended and game.current_node.depth < 300:
p = game.current_node.next_player
move = ai_move(game, players[p].strategy, players[p].ai_settings)
move, node = ai_move(game, players[p].strategy, players[p].ai_settings)
while not game.current_node.analysis_ready:
time.sleep(0.001)
game.game_id += f"_{game.current_node.format_score()}"
print(f"{tag}\tGame finished in {time.time()-start_time:.1f}s {game.current_node.format_score()} -> {game.write_sgf('sgf_selfplay/')}", file=sys.stderr)
print(
f"{tag}\tGame finished in {time.time()-start_time:.1f}s @ move {game.current_node.depth} {game.current_node.format_score()} -> {game.write_sgf('sgf_selfplay/')}",
file=sys.stderr,
)
score = game.current_node.score
if score > 0.3:
black.elo_comp.beat(white.elo_comp)
+3 -3
View File
@@ -22,7 +22,7 @@ else:
username = f"katrain-{bot}"
with open("config.json") as f:
with open("katrain/config.json") as f:
settings = json.load(f)
all_ai_settings = settings["ai"]
@@ -38,10 +38,10 @@ if bot not in greetings or username not in apikeys:
APIKEY = apikeys[username]
settings_dump = ", ".join(f"{k}={v}" for k, v in ai_settings.items() if not k.startswith("_"))
print(settings_dump)
GREETING = f"Hello, welcome to an experimental version of KaTrain AIs - These are based on weakened policy nets of KataGo. Current mode is: {greetings[bot]}"
GREETING = f"Hello, play with these bots at any time by downloading KaTrain at github.com/sanderland/katrain - Current mode is {bot_strategy_names[bot]} ({greetings[bot]})"
if settings:
GREETING += f" Settings: {settings_dump}."
BYEMSG = "Thank you for playing. If you have any feedback, please message my admin! Play with these bots at any time by downloading KaTrain at github.com/sanderland/katrain"
BYEMSG = "Thank you for playing. If you have any feedback, please message my admin! "
cmd = f'{GTP2OGS} --debug --apikey {APIKEY} --rejectnewfile ~/shutdown_bots --username {username} --greeting "{GREETING}" --farewell "{BYEMSG}" {BOT_SETTINGS} --farewellscore --aichat --noclock --nopause --speeds blitz,live --persist --minrank 25k -- python bots/ai2gtp.py {bot} {port}'
print(f"starting bot {username} using server port {port} --> {cmd}")
-24
View File
@@ -1,24 +0,0 @@
from typing import Any, List, Tuple
OUTPUT_ERROR = -1
OUTPUT_KATAGO_STDERR = -0.5
OUTPUT_INFO = 0
OUTPUT_DEBUG = 1
OUTPUT_EXTRA_DEBUG = 2
def var_to_grid(array_var: List[Any], size: Tuple[int, int]) -> List[List[Any]]:
"""convert ownership/policy to grid format such that grid[y][x] is for move with coords x,y"""
ix = 0
grid = [[]] * size[1]
for y in range(size[1] - 1, -1, -1):
grid[y] = array_var[ix : ix + size[0]]
ix += size[0]
return grid
def evaluation_class(points_lost: float, eval_thresholds: List[float]):
i = 0
while i < len(eval_thresholds) - 1 and points_lost < eval_thresholds[i]:
i += 1
return i
-4
View File
@@ -1,4 +0,0 @@
from gui.badukpan import BadukPanControls, BadukPanWidget
from gui.controls import Controls
from gui.kivyutils import *
from gui.popups import LoadSGFPopup, NewGamePopup, ConfigAIPopup, ConfigTeacherPopup, ConfigPopup
+3 -342
View File
@@ -1,343 +1,4 @@
from kivy.config import Config # isort:skip
# for backward compatibility
from katrain.__main__ import run_app
Config.set("input", "mouse", "mouse,multitouch_on_demand") # isort:skip # no red dots on right click
ICON = "img/icon.png"
Config.set("kivy", "window_icon", ICON) # isort:skip # set icon before Window is imported
import signal
import os
import sys
import threading
import traceback
from queue import Queue
from kivy.app import App
from kivy.core.clipboard import Clipboard
from kivy.storage.jsonstore import JsonStore
from kivy.uix.popup import Popup
from core.ai import ai_move
from core.common import OUTPUT_INFO, OUTPUT_ERROR, OUTPUT_DEBUG, OUTPUT_EXTRA_DEBUG, OUTPUT_KATAGO_STDERR
from core.engine import KataGoEngine
from core.game import Game, IllegalMoveException, KaTrainSGF
from core.sgf_parser import Move, ParseError
from gui import *
class KaTrainGui(BoxLayout):
"""Top level class responsible for tying everything together"""
def __init__(self, **kwargs):
super(KaTrainGui, self).__init__(**kwargs)
self.debug_level = 0
self.engine = None
self.game = None
self.new_game_popup = None
self.fileselect_popup = None
self.config_popup = None
self.logger = lambda message, level=OUTPUT_INFO: self.log(message, level)
self._load_config()
self.debug_level = self.config("debug/level", OUTPUT_INFO)
self.controls.ai_mode_groups["W"].values = self.controls.ai_mode_groups["B"].values = list(self.config("ai").keys())
self.message_queue = Queue()
self._keyboard = Window.request_keyboard(None, self, "")
self._keyboard.bind(on_key_down=self._on_keyboard_down)
def log(self, message, level=OUTPUT_INFO):
if level == OUTPUT_KATAGO_STDERR:
if "starting" in message.lower():
self.controls.set_status(f"KataGo engine starting...")
if message.startswith("Tuning"):
self.controls.set_status(f"KataGo is tuning settings for first startup, please wait." + message)
if "ready" in message.lower():
self.controls.set_status(f"KataGo engine ready.")
print(f"[KG:STDERR]{message.strip()}")
elif level == OUTPUT_ERROR:
self.controls.set_status(f"ERROR: {message}")
print(f"ERROR: {message}")
elif self.debug_level >= level:
print(message)
def _load_config(self):
base_path = getattr(sys, "_MEIPASS", os.getcwd()) # for pyinstaller
config_file = sys.argv[1] if len(sys.argv) > 1 else os.path.join(base_path, "config.json")
try:
self.log(f"Using config file {config_file}", OUTPUT_INFO)
self._config_store = JsonStore(config_file, indent=4)
self._config = dict(self._config_store)
except Exception as e:
self.log(f"Failed to load config {config_file}: {e}", OUTPUT_ERROR)
sys.exit(1)
def save_config(self):
for k, v in self._config.items():
self._config_store.put(k, **v)
def config(self, setting, default=None):
try:
if "/" in setting:
cat, key = setting.split("/")
return self._config[cat].get(key, default)
else:
return self._config[setting]
except KeyError:
self.log(f"Missing configuration option {setting}", OUTPUT_ERROR)
def start(self):
if self.engine:
return
self.board_gui.trainer_config = self.config("trainer")
self.board_gui.ui_config = self.config("board_ui")
self.engine = KataGoEngine(self, self.config("engine"))
threading.Thread(target=self._message_loop_thread, daemon=True).start()
self._do_new_game()
def update_state(self, redraw_board=False): # is called after every message and on receiving analyses and config changes
# AI and Trainer/auto-undo handlers
cn = self.game.current_node
auto_undo = cn.player and "undo" in self.controls.player_mode(cn.player)
if auto_undo and cn.analysis_ready and cn.parent and cn.parent.analysis_ready and not cn.children and not self.game.ended:
self.game.analyze_undo(cn, self.config("trainer")) # not via message loop
if cn.analysis_ready and "ai" in self.controls.player_mode(cn.next_player).lower() and not cn.children and not self.game.ended and not (auto_undo and cn.auto_undo is None):
self._do_ai_move(cn) # cn mismatch stops this if undo fired. avoid message loop here or fires repeatedly.
# Handle prisoners and next player display
prisoners = self.game.prisoner_count
top, bot = self.board_controls.black_prisoners.__self__, self.board_controls.white_prisoners.__self__ # no weakref
if self.game.next_player == "W":
top, bot = bot, top
self.board_controls.mid_circles_container.clear_widgets()
self.board_controls.mid_circles_container.add_widget(bot)
self.board_controls.mid_circles_container.add_widget(top)
self.board_controls.black_prisoners.text = str(prisoners["W"])
self.board_controls.white_prisoners.text = str(prisoners["B"])
# update engine status dot
if not self.engine or not self.engine.katago_process or self.engine.katago_process.poll() is not None:
self.board_controls.engine_status_col = self.config("board_ui/engine_down_col")
elif len(self.engine.queries) >= 4:
self.board_controls.engine_status_col = self.config("board_ui/engine_busy_col")
elif len(self.engine.queries) >= 2:
self.board_controls.engine_status_col = self.config("board_ui/engine_little_busy_col")
elif len(self.engine.queries) == 0:
self.board_controls.engine_status_col = self.config("board_ui/engine_ready_col")
else:
self.board_controls.engine_status_col = self.config("board_ui/engine_almost_done_col")
# redraw
if redraw_board:
Clock.schedule_once(self.board_gui.draw_board, -1)
self.board_gui.redraw_board_contents_trigger()
self.controls.update_evaluation()
def _message_loop_thread(self):
while True:
game, msg, *args = self.message_queue.get()
try:
self.log(f"Message Loop Received {msg}: {args} for Game {game}", OUTPUT_EXTRA_DEBUG)
if game != self.game.game_id:
self.log(f"Message skipped as it is outdated (current game is {self.game.game_id}", OUTPUT_EXTRA_DEBUG)
continue
getattr(self, f"_do_{msg.replace('-','_')}")(*args)
self.update_state()
except Exception as e:
self.log(f"Exception in processing message {msg} {args}: {e}", OUTPUT_ERROR)
traceback.print_exc()
def __call__(self, message, *args):
if self.game:
self.message_queue.put([self.game.game_id, message, *args])
def _do_new_game(self, move_tree=None, analyze_fast=False):
self.engine.on_new_game() # clear queries
self.game = Game(self, self.engine, self.config("game"), move_tree=move_tree, analyze_fast=analyze_fast)
self.controls.select_mode("analyze" if move_tree and len(move_tree.nodes_in_tree) > 1 else "play")
self.controls.graph.initialize_from_game(self.game.root)
self.update_state(redraw_board=True)
def _do_ai_move(self, node=None):
if node is None or self.game.current_node == node:
mode = self.controls.ai_mode(self.game.current_node.next_player)
settings = self.config(f"ai/{mode}")
if settings:
ai_move(self.game, mode, settings)
def _do_undo(self, n_times=1):
self.game.undo(n_times)
def _do_redo(self, n_times=1):
self.game.redo(n_times)
def _do_switch_branch(self, direction):
self.game.switch_branch(direction)
def _do_play(self, coords):
try:
self.game.play(Move(coords, player=self.game.next_player))
except IllegalMoveException as e:
self.controls.set_status(f"Illegal Move: {str(e)}")
def _do_analyze_extra(self, mode):
self.game.analyze_extra(mode)
def _do_analyze_sgf_popup(self):
if not self.fileselect_popup:
self.fileselect_popup = Popup(title="Double Click SGF file to analyze", size_hint=(0.8, 0.8)).__self__
popup_contents = LoadSGFPopup()
self.fileselect_popup.add_widget(popup_contents)
popup_contents.filesel.path = os.path.abspath(os.path.expanduser(self.config("sgf/sgf_load")))
def readfile(files, _mouse):
self.fileselect_popup.dismiss()
try:
move_tree = KaTrainSGF.parse_file(files[0])
except ParseError as e:
self.log(f"Failed to load SGF. Parse Error: {e}", OUTPUT_ERROR)
return
self._do_new_game(move_tree=move_tree, analyze_fast=popup_contents.fast.active)
if not popup_contents.rewind.active:
self.game.redo(999)
popup_contents.filesel.on_submit = readfile
self.fileselect_popup.open()
def _do_new_game_popup(self):
if not self.new_game_popup:
self.new_game_popup = Popup(title="New Game", size_hint=(0.5, 0.6)).__self__
popup_contents = NewGamePopup(self, self.new_game_popup, {k: v[0] for k, v in self.game.root.properties.items() if len(v) == 1})
self.new_game_popup.add_widget(popup_contents)
self.new_game_popup.open()
def _do_config_popup(self):
if not self.config_popup:
self.config_popup = Popup(title="Edit Settings", size_hint=(0.9, 0.9)).__self__
popup_contents = ConfigPopup(self, self.config_popup, dict(self._config), ignore_cats=("trainer", "ai"))
self.config_popup.add_widget(popup_contents)
self.config_popup.open()
def _do_output_sgf(self):
for pl in Move.PLAYERS:
if not self.game.root.get_property(f"P{pl}"):
_, model_file = os.path.split(self.engine.config["model"])
self.game.root.set_property(
f"P{pl}", f"AI {self.controls.ai_mode(pl)} (KataGo { os.path.splitext(model_file)[0]})" if "ai" in self.controls.player_mode(pl) else "Player"
)
msg = self.game.write_sgf(
self.config("sgf/sgf_save"),
trainer_config=self.config("trainer"),
save_feedback=self.config("sgf/save_feedback"),
eval_thresholds=self.config("trainer/eval_thresholds"),
)
self.log(msg, OUTPUT_INFO)
self.controls.set_status(msg)
def load_sgf_from_clipboard(self):
clipboard = Clipboard.paste()
if not clipboard:
self.controls.set_status(f"Ctrl-V pressed but clipboard is empty.")
return
try:
move_tree = KaTrainSGF.parse(clipboard)
except Exception as e:
self.controls.set_status(f"Failed to imported game from clipboard: {e}\nClipboard contents: {clipboard[:50]}...")
return
move_tree.nodes_in_tree[-1].analyze(self.engine, analyze_fast=False) # speed up result for looking at end of game
self._do_new_game(move_tree=move_tree, analyze_fast=True)
self("redo", 999)
self.log("Imported game from clipboard.", OUTPUT_INFO)
def on_touch_up(self, touch):
if self.board_gui.collide_point(*touch.pos) or self.board_controls.collide_point(*touch.pos):
if touch.button == "scrollup":
self("redo")
elif touch.button == "scrolldown":
self("undo")
return super().on_touch_up(touch)
def _on_keyboard_down(self, _keyboard, keycode, _text, modifiers):
if isinstance(App.get_running_app().root_window.children[0], Popup):
return # if in new game or load, don't allow keyboard shortcuts
shortcuts = {
"q": self.controls.show_children,
"w": self.controls.eval,
"e": self.controls.hints,
"r": self.controls.ownership,
"t": self.controls.policy,
"enter": ("ai-move",),
"a": self.controls.analyze_extra,
"s": self.controls.analyze_equalize,
"d": self.controls.analyze_sweep,
"right": ("switch-branch", 1),
"left": ("switch-branch", -1),
}
if keycode[1] in shortcuts.keys():
shortcut = shortcuts[keycode[1]]
if isinstance(shortcut, Widget):
shortcut.trigger_action(duration=0)
else:
self(*shortcut)
elif keycode[1] == "tab":
self.controls.switch_mode()
elif keycode[1] == "spacebar":
self("play", None) # pass
elif keycode[1] in ["`", "~", "p"]:
self.controls_box.hidden = not self.controls_box.hidden
elif keycode[1] in ["up", "z"]:
self("undo", 1 + ("shift" in modifiers) * 9 + ("ctrl" in modifiers) * 999)
elif keycode[1] in ["down", "x"]:
self("redo", 1 + ("shift" in modifiers) * 9 + ("ctrl" in modifiers) * 999)
elif keycode[1] == "n" and "ctrl" in modifiers:
self("new-game-popup")
elif keycode[1] == "l" and "ctrl" in modifiers:
self("analyze-sgf-popup")
elif keycode[1] == "s" and "ctrl" in modifiers:
self("output-sgf")
elif keycode[1] == "c" and "ctrl" in modifiers:
Clipboard.copy(self.game.root.sgf())
self.controls.set_status("Copied SGF to clipboard.")
elif keycode[1] == "v" and "ctrl" in modifiers:
self.load_sgf_from_clipboard()
return True
class KaTrainApp(App):
gui = ObjectProperty(None)
def build(self):
self.icon = ICON # how you're supposed to set an icon
self.gui = KaTrainGui()
print(self.get_application_icon())
Window.bind(on_request_close=self.on_request_close)
return self.gui
def on_start(self):
self.gui.start()
def on_request_close(self, *args):
if getattr(self, "gui", None) and self.gui.engine:
self.gui.engine.shutdown()
def signal_handler(self, *args):
if self.gui.debug_level >= OUTPUT_DEBUG:
print("TRACEBACKS")
for threadId, stack in sys._current_frames().items():
print(f"\n# ThreadID: {threadId}")
for filename, lineno, name, line in traceback.extract_stack(stack):
print(f"\tFile: {filename}, line {lineno}, in {name}")
if line:
print(f"\t\t{line.strip()}")
self.on_request_close()
sys.exit(0)
if __name__ == "__main__":
app = KaTrainApp()
signal.signal(signal.SIGINT, app.signal_handler)
try:
app.run()
except Exception:
app.on_request_close()
raise
run_app()
File renamed without changes.
File renamed without changes.
@@ -62,9 +62,9 @@ numSearchThreads = 6
# Maximum number of positions to send to GPU at once.
nnMaxBatchSize = 64
# Cache up to 2 ** this many neural net evaluations in case of transpositions in the tree.
nnCacheSizePowerOfTwo = 21
nnCacheSizePowerOfTwo = 19
# Size of mutex pool for nnCache is 2 ** this
nnMutexPoolSizePowerOfTwo = 17
nnMutexPoolSizePowerOfTwo = 16
# Randomize board orientation when running neural net evals?
nnRandomize = true
Binary file not shown.
File renamed without changes.
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+363
View File
@@ -0,0 +1,363 @@
from kivy.config import Config # isort:skip
from kivy.lang import Builder
from kivy.resources import resource_add_path
Config.set("input", "mouse", "mouse,multitouch_on_demand") # isort:skip # no red dots on right click
ICON = "img/icon.png"
Config.set("kivy", "window_icon", ICON) # isort:skip # set icon before Window is imported
import signal
import os
import sys
import threading
import traceback
from queue import Queue
from kivy.app import App
from kivy.core.clipboard import Clipboard
from kivy.storage.jsonstore import JsonStore
from kivy.uix.popup import Popup
from katrain.core.ai import ai_move
from katrain.core.common import OUTPUT_INFO, OUTPUT_ERROR, OUTPUT_DEBUG, OUTPUT_EXTRA_DEBUG, OUTPUT_KATAGO_STDERR, find_package_resource, clean_temp
from katrain.core.engine import KataGoEngine
from katrain.core.game import Game, IllegalMoveException, KaTrainSGF
from katrain.core.sgf_parser import Move, ParseError
from katrain.gui.kivyutils import *
from katrain.gui.badukpan import BadukPanWidget
from katrain.gui.controls import Controls
from katrain.gui.popups import NewGamePopup, ConfigPopup, LoadSGFPopup
__version__ = "1.0.5"
class KaTrainGui(BoxLayout):
"""Top level class responsible for tying everything together"""
def __init__(self, **kwargs):
super(KaTrainGui, self).__init__(**kwargs)
self.debug_level = 0
self.engine = None
self.game = None
self.new_game_popup = None
self.fileselect_popup = None
self.config_popup = None
self.logger = lambda message, level=OUTPUT_INFO: self.log(message, level)
self.config_file = self._load_config()
self.debug_level = self.config("debug/level", OUTPUT_INFO)
self.controls.ai_mode_groups["W"].values = self.controls.ai_mode_groups["B"].values = list(self.config("ai").keys())
self.message_queue = Queue()
self._keyboard = Window.request_keyboard(None, self, "")
self._keyboard.bind(on_key_down=self._on_keyboard_down)
def log(self, message, level=OUTPUT_INFO):
if level == OUTPUT_KATAGO_STDERR and "ERROR" not in self.controls.status_label.text:
if "starting" in message.lower():
self.controls.set_status(f"KataGo engine starting...")
if message.startswith("Tuning"):
self.controls.set_status(f"KataGo is tuning settings for first startup, please wait." + message)
return
if "ready" in message.lower():
self.controls.set_status(f"KataGo engine ready.")
if "ready" in message.lower():
self.controls.set_status(f"KataGo engine ready.")
if level == OUTPUT_ERROR or (level == OUTPUT_KATAGO_STDERR and "error" in message.lower()):
self.controls.set_status(f"ERROR: {message}")
print(f"ERROR: {message}")
elif self.debug_level >= level:
print(message)
def _load_config(self):
config_file = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else find_package_resource("katrain/config.json"))
try:
self.log(f"Using config file {config_file}", OUTPUT_INFO)
self._config_store = JsonStore(config_file, indent=4)
self._config = dict(self._config_store)
return config_file
except Exception as e:
self.log(f"Failed to load config {config_file}: {e}", OUTPUT_ERROR)
sys.exit(1)
def save_config(self):
for k, v in self._config.items():
self._config_store.put(k, **v)
def config(self, setting, default=None):
try:
if "/" in setting:
cat, key = setting.split("/")
return self._config[cat].get(key, default)
else:
return self._config[setting]
except KeyError:
self.log(f"Missing configuration option {setting}", OUTPUT_ERROR)
def start(self):
if self.engine:
return
self.board_gui.trainer_config = self.config("trainer")
self.board_gui.ui_config = self.config("board_ui")
self.engine = KataGoEngine(self, self.config("engine"))
threading.Thread(target=self._message_loop_thread, daemon=True).start()
self._do_new_game()
def update_state(self, redraw_board=False): # is called after every message and on receiving analyses and config changes
# AI and Trainer/auto-undo handlers
cn = self.game.current_node
auto_undo = cn.player and "undo" in self.controls.player_mode(cn.player)
if auto_undo and cn.analysis_ready and cn.parent and cn.parent.analysis_ready and not cn.children and not self.game.ended:
self.game.analyze_undo(cn, self.config("trainer")) # not via message loop
if cn.analysis_ready and "ai" in self.controls.player_mode(cn.next_player).lower() and not cn.children and not self.game.ended and not (auto_undo and cn.auto_undo is None):
self._do_ai_move(cn) # cn mismatch stops this if undo fired. avoid message loop here or fires repeatedly.
# Handle prisoners and next player display
prisoners = self.game.prisoner_count
top, bot = self.board_controls.black_prisoners.__self__, self.board_controls.white_prisoners.__self__ # no weakref
if self.game.next_player == "W":
top, bot = bot, top
self.board_controls.mid_circles_container.clear_widgets()
self.board_controls.mid_circles_container.add_widget(bot)
self.board_controls.mid_circles_container.add_widget(top)
self.board_controls.black_prisoners.text = str(prisoners["W"])
self.board_controls.white_prisoners.text = str(prisoners["B"])
# update engine status dot
if not self.engine or not self.engine.katago_process or self.engine.katago_process.poll() is not None:
self.board_controls.engine_status_col = self.config("board_ui/engine_down_col")
elif len(self.engine.queries) >= 4:
self.board_controls.engine_status_col = self.config("board_ui/engine_busy_col")
elif len(self.engine.queries) >= 2:
self.board_controls.engine_status_col = self.config("board_ui/engine_little_busy_col")
elif len(self.engine.queries) == 0:
self.board_controls.engine_status_col = self.config("board_ui/engine_ready_col")
else:
self.board_controls.engine_status_col = self.config("board_ui/engine_almost_done_col")
# redraw
if redraw_board:
Clock.schedule_once(self.board_gui.draw_board, -1)
self.board_gui.redraw_board_contents_trigger()
self.controls.update_evaluation()
def _message_loop_thread(self):
while True:
game, msg, *args = self.message_queue.get()
try:
self.log(f"Message Loop Received {msg}: {args} for Game {game}", OUTPUT_EXTRA_DEBUG)
if game != self.game.game_id:
self.log(f"Message skipped as it is outdated (current game is {self.game.game_id}", OUTPUT_EXTRA_DEBUG)
continue
getattr(self, f"_do_{msg.replace('-','_')}")(*args)
self.update_state()
except Exception as e:
self.log(f"Exception in processing message {msg} {args}: {e}", OUTPUT_ERROR)
traceback.print_exc()
def __call__(self, message, *args):
if self.game:
self.message_queue.put([self.game.game_id, message, *args])
def _do_new_game(self, move_tree=None, analyze_fast=False):
self.board_gui.animating_pv = None
self.engine.on_new_game() # clear queries
self.game = Game(self, self.engine, self.config("game"), move_tree=move_tree, analyze_fast=analyze_fast)
self.controls.select_mode("analyze" if move_tree and len(move_tree.nodes_in_tree) > 1 else "play")
self.controls.graph.initialize_from_game(self.game.root)
self.update_state(redraw_board=True)
def _do_ai_move(self, node=None):
if node is None or self.game.current_node == node:
mode = self.controls.ai_mode(self.game.current_node.next_player)
settings = self.config(f"ai/{mode}")
if settings:
ai_move(self.game, mode, settings)
def _do_undo(self, n_times=1):
self.board_gui.animating_pv = None
self.game.undo(n_times)
def _do_redo(self, n_times=1):
self.board_gui.animating_pv = None
self.game.redo(n_times)
def _do_switch_branch(self, direction):
self.game.switch_branch(direction)
def _do_play(self, coords):
self.board_gui.animating_pv = None
try:
self.game.play(Move(coords, player=self.game.next_player))
except IllegalMoveException as e:
self.controls.set_status(f"Illegal Move: {str(e)}")
def _do_analyze_extra(self, mode):
self.game.analyze_extra(mode)
def _do_analyze_sgf_popup(self):
if not self.fileselect_popup:
self.fileselect_popup = Popup(title="Double Click SGF file to analyze", size_hint=(0.8, 0.8)).__self__
popup_contents = LoadSGFPopup()
self.fileselect_popup.add_widget(popup_contents)
popup_contents.filesel.path = os.path.abspath(os.path.expanduser(self.config("sgf/sgf_load")))
def readfile(files, _mouse):
self.fileselect_popup.dismiss()
try:
move_tree = KaTrainSGF.parse_file(files[0])
except ParseError as e:
self.log(f"Failed to load SGF. Parse Error: {e}", OUTPUT_ERROR)
return
self._do_new_game(move_tree=move_tree, analyze_fast=popup_contents.fast.active)
if not popup_contents.rewind.active:
self.game.redo(999)
popup_contents.filesel.on_submit = readfile
self.fileselect_popup.open()
def _do_new_game_popup(self):
if not self.new_game_popup:
self.new_game_popup = Popup(title="New Game", size_hint=(0.5, 0.6)).__self__
popup_contents = NewGamePopup(self, self.new_game_popup, {k: v[0] for k, v in self.game.root.properties.items() if len(v) == 1})
self.new_game_popup.add_widget(popup_contents)
self.new_game_popup.open()
def _do_config_popup(self):
if not self.config_popup:
self.config_popup = Popup(title=f"Edit Settings - {self.config_file}", size_hint=(0.9, 0.9)).__self__
popup_contents = ConfigPopup(self, self.config_popup, dict(self._config), ignore_cats=("trainer", "ai"))
self.config_popup.add_widget(popup_contents)
self.config_popup.open()
def _do_output_sgf(self):
for pl in Move.PLAYERS:
if not self.game.root.get_property(f"P{pl}"):
_, model_file = os.path.split(self.engine.config["model"])
self.game.root.set_property(
f"P{pl}", f"AI {self.controls.ai_mode(pl)} (KataGo { os.path.splitext(model_file)[0]})" if "ai" in self.controls.player_mode(pl) else "Player"
)
msg = self.game.write_sgf(
self.config("sgf/sgf_save"),
trainer_config=self.config("trainer"),
save_feedback=self.config("sgf/save_feedback"),
eval_thresholds=self.config("trainer/eval_thresholds"),
)
self.log(msg, OUTPUT_INFO)
self.controls.set_status(msg)
def load_sgf_from_clipboard(self):
clipboard = Clipboard.paste()
if not clipboard:
self.controls.set_status(f"Ctrl-V pressed but clipboard is empty.")
return
try:
move_tree = KaTrainSGF.parse(clipboard)
except Exception as e:
self.controls.set_status(f"Failed to imported game from clipboard: {e}\nClipboard contents: {clipboard[:50]}...")
return
move_tree.nodes_in_tree[-1].analyze(self.engine, analyze_fast=False) # speed up result for looking at end of game
self._do_new_game(move_tree=move_tree, analyze_fast=True)
self("redo", 999)
self.log("Imported game from clipboard.", OUTPUT_INFO)
def on_touch_up(self, touch):
if self.board_gui.collide_point(*touch.pos) or self.board_controls.collide_point(*touch.pos):
if touch.button == "scrollup":
self("redo")
elif touch.button == "scrolldown":
self("undo")
return super().on_touch_up(touch)
def _on_keyboard_down(self, _keyboard, keycode, _text, modifiers):
if isinstance(App.get_running_app().root_window.children[0], Popup):
return # if in new game or load, don't allow keyboard shortcuts
shortcuts = {
"q": self.controls.show_children,
"w": self.controls.eval,
"e": self.controls.hints,
"r": self.controls.ownership,
"t": self.controls.policy,
"enter": ("ai-move",),
"a": self.controls.analyze_extra,
"s": self.controls.analyze_equalize,
"d": self.controls.analyze_sweep,
"right": ("switch-branch", 1),
"left": ("switch-branch", -1),
}
if keycode[1] in shortcuts.keys():
shortcut = shortcuts[keycode[1]]
if isinstance(shortcut, Widget):
shortcut.trigger_action(duration=0)
else:
self(*shortcut)
elif keycode[1] == "tab":
self.controls.switch_mode()
elif keycode[1] == "spacebar":
self("play", None) # pass
elif keycode[1] in ["`", "~", "p"]:
self.controls_box.hidden = not self.controls_box.hidden
elif keycode[1] in ["up", "z"]:
self("undo", 1 + ("shift" in modifiers) * 9 + ("ctrl" in modifiers) * 999)
elif keycode[1] in ["down", "x"]:
self("redo", 1 + ("shift" in modifiers) * 9 + ("ctrl" in modifiers) * 999)
elif keycode[1] == "n" and "ctrl" in modifiers:
self("new-game-popup")
elif keycode[1] == "l" and "ctrl" in modifiers:
self("analyze-sgf-popup")
elif keycode[1] == "s" and "ctrl" in modifiers:
self("output-sgf")
elif keycode[1] == "c" and "ctrl" in modifiers:
Clipboard.copy(self.game.root.sgf())
self.controls.set_status("Copied SGF to clipboard.")
elif keycode[1] == "v" and "ctrl" in modifiers:
self.load_sgf_from_clipboard()
return True
class KaTrainApp(App):
gui = ObjectProperty(None)
def build(self):
self.icon = ICON # how you're supposed to set an icon
self.gui = KaTrainGui()
self.title = f"KaTrain v{__version__}"
Window.bind(on_request_close=self.on_request_close)
return self.gui
def on_start(self):
self.gui.start()
def on_request_close(self, *args):
if getattr(self, "gui", None) and self.gui.engine:
self.gui.engine.shutdown()
clean_temp()
def signal_handler(self, *args):
if self.gui.debug_level >= OUTPUT_DEBUG:
print("TRACEBACKS")
for threadId, stack in sys._current_frames().items():
print(f"\n# ThreadID: {threadId}")
for filename, lineno, name, line in traceback.extract_stack(stack):
print(f"\tFile: {filename}, line {lineno}, in {name}")
if line:
print(f"\t\t{line.strip()}")
self.on_request_close()
sys.exit(0)
def run_app():
kv_file = find_package_resource("katrain/gui.kv")
resource_add_path(os.path.split(kv_file)[0])
Builder.load_file(kv_file)
app = KaTrainApp()
signal.signal(signal.SIGINT, app.signal_handler)
try:
app.run()
except Exception:
app.on_request_close()
raise
if __name__ == "__main__":
run_app()
+4 -3
View File
@@ -1,8 +1,8 @@
{
"engine": {
"katago": "KataGo/katago",
"model": "KataGo/models/b15-1.3.2.txt.gz",
"config": "KataGo/analysis_config.cfg",
"katago": "",
"model": "katrain/models/b15-1.3.2.txt.gz",
"config": "katrain/KataGo/analysis_config.cfg",
"threads": 16,
"max_visits": 500,
"fast_visits": 50,
@@ -128,6 +128,7 @@
}
},
"board_ui": {
"anim_pv_time": 0.5,
"engine_down_col": [
0.8,
0,
View File
Whitespace-only changes.
+3 -3
View File
@@ -4,9 +4,9 @@ import random
import time
from typing import Dict, List, Tuple
from core.common import OUTPUT_DEBUG, OUTPUT_ERROR, OUTPUT_INFO, var_to_grid
from core.engine import EngineDiedException
from core.game import Game, GameNode, IllegalMoveException, Move
from katrain.core.common import OUTPUT_DEBUG, OUTPUT_INFO, var_to_grid
from katrain.core.engine import EngineDiedException
from katrain.core.game import Game, GameNode, Move
def weighted_selection_without_replacement(items: List[Tuple], pick_n: int) -> List[Tuple]:
+52
View File
@@ -0,0 +1,52 @@
from typing import Any, List, Tuple
import os
try:
import importlib.resources as pkg_resources
except:
import importlib_resources as pkg_resources
OUTPUT_ERROR = -1
OUTPUT_KATAGO_STDERR = -0.5
OUTPUT_INFO = 0
OUTPUT_DEBUG = 1
OUTPUT_EXTRA_DEBUG = 2
def var_to_grid(array_var: List[Any], size: Tuple[int, int]) -> List[List[Any]]:
"""convert ownership/policy to grid format such that grid[y][x] is for move with coords x,y"""
ix = 0
grid = [[]] * size[1]
for y in range(size[1] - 1, -1, -1):
grid[y] = array_var[ix : ix + size[0]]
ix += size[0]
return grid
def evaluation_class(points_lost: float, eval_thresholds: List[float]):
i = 0
while i < len(eval_thresholds) - 1 and points_lost < eval_thresholds[i]:
i += 1
return i
resource_scopes = []
def clean_temp():
for s in resource_scopes:
s.__exit__(None, None, None)
def find_package_resource(path):
if path.startswith("katrain"):
parts = path.replace("\\", "/").split("/")
try:
path_obj = pkg_resources.path(".".join(parts[:-1]), parts[-1]).__enter__()
resource_scopes.append(path_obj)
return str(path_obj)
except (ModuleNotFoundError, FileNotFoundError) as e:
print(f"File {path} not found, installation possibly broken")
return f"FILENOTFOUND::{path}"
else:
return path # absolute path
+39 -13
View File
@@ -6,8 +6,8 @@ import threading
import time
from typing import Callable, Optional
from core.common import OUTPUT_DEBUG, OUTPUT_ERROR, OUTPUT_EXTRA_DEBUG, OUTPUT_KATAGO_STDERR
from core.game_node import GameNode
from katrain.core.common import OUTPUT_DEBUG, OUTPUT_ERROR, OUTPUT_EXTRA_DEBUG, OUTPUT_KATAGO_STDERR, find_package_resource
from katrain.core.game_node import GameNode
class EngineDiedException(Exception):
@@ -27,7 +27,17 @@ class KataGoEngine:
def __init__(self, katrain, config):
self.katrain = katrain
self.command = f"{config['katago']} analysis -model {config['model']} -config {config['config']} -analysis-threads {config['threads']}"
executable = config["katago"].strip()
if not executable:
if sys.platform.startswith("win"):
executable = "katrain/KataGo/katago.exe"
elif sys.platform.startswith("linux"):
executable = "katrain/KataGo/katago"
else: # e.g. MacOS after brewing
executable = "katago"
modelfile, configfile = find_package_resource(config["model"]), find_package_resource(config["config"])
self.command = f'{find_package_resource(executable)} analysis -model "{modelfile}" -config "{configfile}" -analysis-threads {config["threads"]}'
if not sys.platform.startswith("win"):
self.command = shlex.split(self.command)
self.queries = {} # outstanding query id -> start time and callback
@@ -37,19 +47,27 @@ class KataGoEngine:
self.base_priority = 0
self.override_settings = {} # mainly for bot scripts to hook into
self._lock = threading.Lock()
self.analysis_thread = None
self.stderr_thread = None
self.start()
self.analysis_thread = threading.Thread(target=self._analysis_read_thread, daemon=True).start()
self.stderr_thread = threading.Thread(target=self._read_stderr_thread, daemon=True).start()
def start(self):
try:
self.katrain.log(f"Starting KataGo with {self.command}", OUTPUT_DEBUG)
self.katago_process = subprocess.Popen(self.command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except FileNotFoundError as e:
self.katrain.log(
f"Starting kata with command '{self.command}' failed with error {e}. Please make sure the 'katago' value under 'engine' in settings points to the correct KataGo executable.",
OUTPUT_ERROR,
)
except (FileNotFoundError, PermissionError, OSError) as e:
if self.config["katago"].strip():
self.katrain.log(
f"Starting kata with command '{self.command}' failed with error {e}. If on MacOS, see the manual on how to use brew to install katago first, and add it to your path or 'engine/katago' setting.",
OUTPUT_ERROR,
)
else:
self.katrain.log(
f"Starting kata with command '{self.command}' failed with error {e}. Please make sure the 'katago' value under 'engine' in settings points to the correct KataGo executable.",
OUTPUT_ERROR,
)
self.analysis_thread = threading.Thread(target=self._analysis_read_thread, daemon=True).start()
self.stderr_thread = threading.Thread(target=self._read_stderr_thread, daemon=True).start()
def on_new_game(self):
self.base_priority += 1
@@ -68,6 +86,10 @@ class KataGoEngine:
if process:
process.terminate()
self.katago_process = None
if self.stderr_thread:
self.stderr_thread.join()
if self.analysis_thread:
self.analysis_thread.join()
def is_idle(self):
return not self.queries
@@ -77,7 +99,7 @@ class KataGoEngine:
try:
line = self.katago_process.stderr.readline()
if line:
self.katrain.log(line.decode(), OUTPUT_KATAGO_STDERR)
self.katrain.log(line.decode().strip(), OUTPUT_KATAGO_STDERR)
except:
return
@@ -89,6 +111,7 @@ class KataGoEngine:
raise EngineDiedException(f"Engine died unexpectedly without sending output, possibly due to out of memory: {e}")
if b"Uncaught exception" in line:
self.katrain.log(f"KataGo Engine Failed: {line.decode()}", OUTPUT_ERROR)
return
if not line:
continue
analysis = json.loads(line)
@@ -97,13 +120,16 @@ class KataGoEngine:
continue
query_id = analysis["id"]
callback, error_callback, start_time, next_move = self.queries[query_id]
del self.queries[query_id]
if "error" in analysis:
del self.queries[query_id]
if error_callback:
error_callback(analysis)
elif not (next_move and "Illegal move" in analysis["error"]): # sweep
self.katrain.log(f"{analysis} received from KataGo", OUTPUT_ERROR)
elif "warning" in analysis:
self.katrain.log(f"{analysis} received from KataGo", OUTPUT_DEBUG)
else:
del self.queries[query_id]
time_taken = time.time() - start_time
self.katrain.log(f"[{time_taken:.1f}][{analysis['id']}] KataGo Analysis Received: {analysis.keys()}", OUTPUT_DEBUG)
self.katrain.log(line, OUTPUT_EXTRA_DEBUG)
@@ -127,7 +153,7 @@ class KataGoEngine:
self.katago_process.stdin.flush()
except OSError as e:
self.katrain.log(f"Engine died unexpectedly, possibly due to out of memory: {e}", OUTPUT_ERROR)
return # do not raise, since
return # do not raise, since there's nothing to catch it
def request_analysis(
self,
+11 -10
View File
@@ -5,10 +5,10 @@ import threading
from datetime import datetime
from typing import Dict, List, Union
from core.common import var_to_grid, OUTPUT_INFO, OUTPUT_DEBUG
from core.engine import KataGoEngine
from core.game_node import GameNode
from core.sgf_parser import SGF, Move
from katrain.core.common import var_to_grid, OUTPUT_INFO, OUTPUT_DEBUG
from katrain.core.engine import KataGoEngine
from katrain.core.game_node import GameNode
from katrain.core.sgf_parser import SGF, Move
class IllegalMoveException(Exception):
@@ -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
@@ -136,6 +136,7 @@ class Game:
cn = self.current_node # avoid race conditions
for _ in range(n_times):
if not cn.is_root:
cn.parent.set_favourite_child(cn)
cn = cn.parent
self.set_current_node(cn)
@@ -143,7 +144,7 @@ class Game:
cn = self.current_node # avoid race conditions
for _ in range(n_times):
if cn.children:
cn = cn.children[-1]
cn = cn.favourite_child
self.set_current_node(cn)
def switch_branch(self, direction):
@@ -296,16 +297,16 @@ class Game:
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
xmsg = ". Please try again."
xmsg = ". Please try once more"
if num_undos == 0:
undo = False
elif num_undos < 1: # probability
undo = int(node.undo_threshold < num_undos) and len(node.parent.children) == 1
xmsg = f" (with {num_undos:.0%} probability at this level of mistake)" + xmsg
else:
undo = len(node.parent.children) <= num_undos
if len(node.parent.children) == num_undos:
xmsg = xmsg[:-1] + ", but note that this is your last try at this level of mistake."
if len(node.parent.children) < num_undos:
xmsg = ". Please try again (multiple tries remaining)."
node.auto_undo = undo
if undo:
self.undo(1)
@@ -2,8 +2,8 @@ import copy
import random
from typing import Dict, List, Optional, Tuple
from core.common import evaluation_class, var_to_grid
from core.sgf_parser import Move, SGFNode
from katrain.core.common import evaluation_class, var_to_grid
from katrain.core.sgf_parser import Move, SGFNode
class GameNode(SGFNode):
@@ -18,6 +18,7 @@ class GameNode(SGFNode):
self.ai_thoughts = ""
self.move_number = 0
self.undo_threshold = random.random() # for fractional undos, store the random threshold in the move itself for consistency
self._favourite_child = None
def sgf_properties(self, save_comments_player=None, save_comments_class=None, eval_thresholds=None):
properties = copy.copy(super().sgf_properties())
@@ -33,7 +34,7 @@ class GameNode(SGFNode):
properties["SQ"] = best_sq
if top_x and "MA" not in properties:
properties["MA"] = [top_x]
comment = self.comment(sgf=True)
comment = self.comment(sgf=True, interactive=False)
if comment:
properties["C"] = [properties.get("C", "") + comment]
if self.is_root:
@@ -48,6 +49,16 @@ class GameNode(SGFNode):
def order_children(children):
return sorted(children, key=lambda c: 0.5 if c.auto_undo is None else int(c.auto_undo)) # analyzed/not undone main, non-teach second, undone last
def set_favourite_child(self, c):
self._favourite_child = c
@property
def favourite_child(self) -> Optional["GameNode"]:
if self._favourite_child:
return self._favourite_child
elif self.children:
return self.children[0]
# various analysis functions
def analyze(self, engine, priority=0, visits=None, time_limit=True, refine_move=None, analyze_fast=False):
engine.request_analysis(
@@ -88,11 +99,11 @@ class GameNode(SGFNode):
def make_pv(self, player, pv, interactive):
pvtext = f"{player}{' '.join(pv)}"
# if interactive:
# pvtext = f"[u][ref={pvtext}][color=#334466]{pvtext}[/color][/ref][/u]"
if interactive:
pvtext = f"[u][ref={pvtext}][color=#334466]{pvtext}[/color][/ref][/u]"
return pvtext
def comment(self, sgf=False, teach=False, hints=False, interactive=False):
def comment(self, sgf=False, teach=False, hints=False, interactive=True):
single_move = self.move
if not self.parent or not single_move: # root
return ""
@@ -113,9 +124,8 @@ class GameNode(SGFNode):
text += f"Predicted top move was {previous_top_move['move']} ({self.format_score(previous_top_move['scoreLead'])}).\n"
else:
text += f"Move was predicted best move.\n"
if sgf:
if previous_top_move.get("pv") and (sgf or hints):
text += f"PV: {self.make_pv(single_move.player,previous_top_move['pv'],interactive)}\n"
if previous_top_move.get("pv") and (sgf or hints):
text += f"PV: {self.make_pv(single_move.player,previous_top_move['pv'],interactive)}\n"
if sgf or hints or teach:
policy_ranking = self.parent.policy_ranking
@@ -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."""
@@ -210,7 +214,13 @@ class SGFNode:
@property
def nodes_in_tree(self) -> List:
"""Returns all nodes in the tree rooted at this node"""
return [self] + sum([c.nodes_in_tree for c in self.children], [])
stack = [self]
nodes = []
while stack:
item = stack.pop(0)
nodes.append(item)
stack += item.children
return nodes
@property
def nodes_from_root(self) -> List:
+2 -2
View File
@@ -620,8 +620,8 @@
height: 0.00001 if not self.text else min(0.66*self.parent.height,self.label.texture_size[1])
ScrollableLabel:
id: info
# markup: True
# on_ref_press: root.katrain.board_gui.show_pv_from_label(args[1])
markup: True
on_ref_press: root.katrain.board_gui.show_pv_from_comments(args[1])
size_hint: 1, None
height: self.parent.height - status_label.height - 1
BoxLayout:
View File
Whitespace-only changes.
+75 -38
View File
@@ -1,5 +1,6 @@
import copy
import math
import time
from kivy.clock import Clock
from kivy.graphics.context_instructions import Color
@@ -7,10 +8,10 @@ from kivy.graphics.vertex_instructions import Ellipse, Line, Rectangle
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.widget import Widget
from core.common import OUTPUT_DEBUG, evaluation_class
from core.game import Move
from gui.kivyutils import draw_circle, draw_text
from core.common import var_to_grid
from katrain.core.common import OUTPUT_DEBUG, evaluation_class
from katrain.core.game import Move
from katrain.gui.kivyutils import draw_circle, draw_text
from katrain.core.common import var_to_grid
from kivy.core.window import Window
@@ -24,20 +25,18 @@ class BadukPanWidget(Widget):
self.gridpos_y = []
self.grid_size = 0
self.stone_size = 0
self.last_eval = 0
self.active_pv_moves = []
self.show_pv_for = None
self.animating_pv = None
self.redraw_board_contents_trigger = Clock.create_trigger(self.draw_board_contents)
self.last_mouse_pos = (0, 0)
Window.bind(mouse_pos=self.on_mouse_pos)
Clock.schedule_interval(self.animate_pv, 0.1)
# stone placement functions
def _find_closest(self, pos, gridpos):
return sorted([(abs(p - pos), i) for i, p in enumerate(gridpos)])[0]
def on_touch_down(self, touch):
if touch.button != "left":
return
def check_next_move_ghost(self, touch):
if not self.gridpos_x:
return
xd, xp = self._find_closest(touch.x, self.gridpos_x)
@@ -50,26 +49,38 @@ class BadukPanWidget(Widget):
if prev_ghost != self.ghost_stone:
self.draw_hover_contents()
def on_touch_move(self, touch): # on_motion on_touch_move
return self.on_touch_down(touch)
def on_touch_down(self, touch):
self.animating_pv = None # any click kills PV from label/move
self.draw_hover_contents()
if touch.button != "left":
return
self.check_next_move_ghost(touch)
def on_touch_move(self, touch):
return self.check_next_move_ghost(touch)
def on_mouse_pos(self, *args): # https://gist.github.com/opqopq/15c707dc4cffc2b6455f
last_show_pv = self.show_pv_for
self.show_pv_for = None
if self.get_root_window(): # do proceed if I'm not displayed <=> If have no parent
if self.get_root_window(): # don't proceed if I'm not displayed <=> If have no parent
pos = args[1]
rel_pos = self.to_widget(*pos) # compensate for relative layout
inside = self.collide_point(*rel_pos)
if inside and self.active_pv_moves:
near_move = [
move
for move in self.active_pv_moves
(pv, node)
for move, pv, node in self.active_pv_moves
if abs(rel_pos[0] - self.gridpos_x[move[0]]) < self.grid_size / 2 and abs(rel_pos[1] - self.gridpos_y[move[1]]) < self.grid_size / 2
]
if near_move:
self.show_pv_for = near_move[0]
if self.show_pv_for != last_show_pv:
self.draw_hover_contents()
self.set_animating_pv(near_move[0][0], near_move[0][1])
else:
self.animating_pv = None
self.draw_hover_contents()
if inside and self.animating_pv is not None:
d_sq = (pos[0] - self.animating_pv[3][0]) ** 2 + (pos[1] - self.animating_pv[3][1])
if d_sq > 2 * self.stone_size ** 2: # move too far from where it was activated
self.animating_pv = None
self.draw_hover_contents()
self.last_mouse_pos = pos
def on_touch_up(self, touch):
if touch.button != "left" or not self.gridpos_x:
@@ -86,10 +97,13 @@ class BadukPanWidget(Widget):
if touch.is_double_tap: # navigate to move
katrain.game.set_current_node(nodes_here[-1])
katrain.update_state()
else: # load comments
else: # load comments & pv
katrain.log(f"\nAnalysis:\n{nodes_here[-1].analysis}", OUTPUT_DEBUG)
katrain.log(f"\nParent Analysis:\n{nodes_here[-1].parent.analysis}", OUTPUT_DEBUG)
katrain.controls.info.text = nodes_here[-1].comment(sgf=True, interactive=True)
katrain.controls.info.text = nodes_here[-1].comment(sgf=True)
katrain.controls.active_comment_node = nodes_here[-1].parent
if nodes_here[-1].parent.analysis_ready:
self.set_animating_pv(nodes_here[-1].parent.candidate_moves[0]["pv"], nodes_here[-1].parent)
self.ghost_stone = None
self.draw_hover_contents() # remove ghost
@@ -297,12 +311,10 @@ class BadukPanWidget(Widget):
else:
evalcol = copy.copy(self.eval_color(points_lost))
evalcol[3] = alpha
self.active_pv_moves.append(move.coords)
if teaching and move.coords == self.show_pv_for and child_node.analysis_ready:
self.draw_pv(katrain, [move.gtp()] + child_node.candidate_moves[0].get("pv", []), [next_player, player])
else:
scale = self.ui_config["child_scale"]
self.draw_stone(move.coords[0], move.coords[1], (*stone_color[move.player][:3], alpha), None, None, evalcol, evalscale=scale, scale=scale)
if teaching and child_node.auto_undo and child_node.analysis_ready:
self.active_pv_moves.append((move.coords, child_node.candidate_moves[0]["pv"], child_node))
scale = self.ui_config["child_scale"]
self.draw_stone(move.coords[0], move.coords[1], (*stone_color[move.player][:3], alpha), None, None, evalcol, evalscale=scale, scale=scale)
# hints or PV
if katrain.controls.hints.active and not game_ended and not lock_ai:
@@ -315,20 +327,38 @@ class BadukPanWidget(Widget):
alpha += self.ui_config["top_move_x_alpha"]
elif move_dict["visits"] < self.ui_config["visit_frac_small"] * hint_moves[0]["visits"]:
scale = 0.8
self.active_pv_moves.append(move.coords)
if move.coords == self.show_pv_for:
self.draw_pv(katrain, move_dict.get("pv", [move.gtp()]), [next_player, player]) # if empty, show current move at least
elif not self.show_pv_for:
self.draw_stone(move.coords[0], move.coords[1], [*self.eval_color(move_dict["pointsLost"])[:3], alpha], scale=scale)
self.active_pv_moves.append((move.coords, move_dict["pv"], current_node))
self.draw_stone(move.coords[0], move.coords[1], [*self.eval_color(move_dict["pointsLost"])[:3], alpha], scale=scale)
# hover next move ghost stone
if self.ghost_stone:
self.draw_stone(*self.ghost_stone, (*stone_color[next_player], ghost_alpha))
def draw_pv(self, katrain, pv, next_last_player):
# TODO: overlapping moves
animating_pv = self.animating_pv
if animating_pv:
pv, node, start_time, _ = animating_pv
delay = self.ui_config.get("anim_pv_time", 0.5)
up_to_move = (time.time() - start_time) / delay
self.draw_pv(pv, node, up_to_move)
def animate_pv(self, _dt):
if self.animating_pv:
self.draw_hover_contents()
def draw_pv(self, pv, node, up_to_move):
katrain = self.katrain
next_last_player = [node.next_player, node.player]
stone_color = self.ui_config["stones"]
cn = katrain.game.current_node
if node != cn and node.parent != cn:
hide_node = cn
while hide_node and hide_node.move and hide_node != node:
if not hide_node.move.is_pass:
self.draw_stone(*hide_node.move.coords, [0.85, 0.68, 0.40, 0.8]) # board coloured dot
hide_node = hide_node.parent
for i, gtpmove in enumerate(pv):
if i > up_to_move:
return
move_player = next_last_player[i % 2]
opp_player = next_last_player[1 - i % 2]
coords = Move.from_gtp(gtpmove).coords
@@ -340,10 +370,17 @@ class BadukPanWidget(Widget):
]
else:
board_coords = (self.gridpos_x[coords[0]], self.gridpos_y[coords[1]])
sizefac = 0.95
draw_circle(board_coords, self.stone_size * sizefac, stone_color[move_player])
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=sizefac * self.grid_size / 1.45)
draw_text(pos=board_coords, text=str(i + 1), font_size=self.grid_size / 1.45)
def set_animating_pv(self, pv, node):
if node is not None and (not self.animating_pv or not (self.animating_pv[0] == pv and self.animating_pv[1] == node)):
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.parent)
class BadukPanControls(BoxLayout):
+4 -1
View File
@@ -1,7 +1,7 @@
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.popup import Popup
from gui.popups import ConfigAIPopup, ConfigTeacherPopup
from katrain.gui.popups import ConfigAIPopup, ConfigTeacherPopup
class Controls(BoxLayout):
@@ -11,6 +11,7 @@ class Controls(BoxLayout):
self.status_node = None
self.ai_settings_popup = None
self.teacher_settings_popup = None
self.active_comment_node = None
def set_status(self, msg, at_node=None):
self.status = msg
@@ -60,8 +61,10 @@ class Controls(BoxLayout):
current_player_is_ai_playing_human = current_node.player and "ai" in self.player_mode(current_node.player) and "ai" not in self.player_mode(current_node.next_player)
if next_player_is_human_or_both_robots and not current_node.is_root and move:
info += current_node.comment(teach="undo" in self.player_mode(current_node.player), hints=self.hints.active)
self.active_comment_node = current_node
elif current_player_is_ai_playing_human and current_node.parent:
info += current_node.parent.comment(teach="undo" in self.player_mode(current_node.next_player), hints=self.hints.active)
self.active_comment_node = current_node.parent
if current_node.analysis_ready:
self.score.text = current_node.format_score()
@@ -265,7 +265,7 @@ class ScoreGraph(Label):
line_points = ListProperty([])
dot_pos = ListProperty([0, 0])
highlighted_index = NumericProperty(None)
y_scale = NumericProperty(4)
y_scale = NumericProperty(5)
marginx = NumericProperty(0.015)
marginy = NumericProperty(0.01)
highlight_size = NumericProperty(5)
@@ -278,7 +278,7 @@ class ScoreGraph(Label):
self.nodes = [root]
node = root
while node.children:
node = node.children[0]
node = node.favourite_child
self.nodes.append(node)
self.highlighted_index = 0
@@ -289,7 +289,7 @@ class ScoreGraph(Label):
nn_values = [n.score for n in nodes if n and n.score]
val_range = min(nn_values or [0]), max(nn_values or [0])
self.y_scale = math.ceil(max(4, max(-val_range[0], val_range[1])) / 2) * 2
self.y_scale = math.ceil(max(5, max(-val_range[0], val_range[1])) / 5) * 5
xscale = self.width * (1 - 2 * self.marginx) / max(len(values) - 1, 15)
available_height = self.height * (1 - 2 * self.marginy)
+4 -4
View File
@@ -7,10 +7,10 @@ from kivy.uix.gridlayout import GridLayout
from kivy.uix.label import Label
from kivy.uix.popup import Popup
from core.common import OUTPUT_DEBUG, OUTPUT_ERROR
from core.engine import KataGoEngine
from core.game import Game, GameNode
from gui.kivyutils import (
from katrain.core.common import OUTPUT_DEBUG, OUTPUT_ERROR
from katrain.core.engine import KataGoEngine
from katrain.core.game import Game, GameNode
from katrain.gui.kivyutils import (
BackgroundLabel,
LabelledCheckBox,
LabelledFloatInput,
Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
Whitespace-only changes.
File renamed without changes.
+51
View File
@@ -0,0 +1,51 @@
import re
import os
from setuptools import find_packages, setup
package_data = {"": ["*.json", "*.kv"], "katrain": [], "tests": []}
packages = find_packages(exclude=["bots", "tests"])
version = re.search('^__version__\s*=\s*"(.*)"', open("katrain/__main__.py").read(), re.M).group(1)
def include_data_files(directory):
for root, subfolders, files in os.walk(directory):
for fn in files:
filename = os.path.join(root.replace("/", os.path.sep), fn)
parts = filename.split(os.path.sep)
package_data[parts[0]].append(os.path.join(*parts[1:]))
include_data_files("katrain/KataGo")
include_data_files("katrain/models")
include_data_files("katrain/img/")
include_data_files("katrain/img/flaticon")
print(packages, package_data)
with open("README.md", "r") as fh:
long_description = fh.read()
setup(
name="KaTrain",
version=version,
description="Go/Baduk/Weiqi playing and teaching app with a variety of AIs",
long_description=long_description,
long_description_content_type="text/markdown",
author="Sander Land",
author_email="sander.land@gmail.com",
url="https://github.com/sanderland/katrain",
license="MIT",
install_requires=[
"kivy",
"kivy_deps.glew;platform_system=='Windows'",
"kivy_deps.sdl2;platform_system=='Windows'",
"kivy_deps.gstreamer;platform_system=='Windows'",
"importlib_resources ;python_version<'3.7'",
],
python_requires=">=3.6, <3.8",
entry_points={"console_scripts": ["katrain=katrain.__main__:run_app"]},
classifiers=["Development Status :: 4 - Beta", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3"],
packages=packages,
package_data=package_data,
)
+6 -4
View File
@@ -6,10 +6,12 @@ block_cipher = None
# pyinstaller spec/katrain.spec --noconfirm
# --upx-dir my
a = Analysis(['..\\katrain.py'],
a = Analysis(['..\\katrain\\__main__.py'],
pathex=['C:\\Users\\sande\\Desktop\\katrain\\spec'],
binaries=[],
datas=[('..\\katrain.kv','.'),('..\\config.json','.'),('..\\KataGo','KataGo'),('..\\img','img')],
datas=[('..\\katrain\\gui.kv','katrain'),('..\\katrain\\config.json','katrain'),
('..\\katrain\\KataGo','katrain\KataGo'),('..\\katrain\img','katrain\img'),
('..\\katrain\\models','katrain\models')],
hiddenimports=["win32file","win32timezone"], # FileChooser in kivy loads this conditionally
hookspath=[],
runtime_hooks=[],
@@ -34,8 +36,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']
EXCLUDE = ['KataGoData','anim_','screenshot_','__pycache__']
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,
+48
View File
@@ -0,0 +1,48 @@
(;GM[1]FF[4]CA[UTF-8]AP[Sabaki:0.43.3]ST[2]RU[Chinese]SZ[19]KM[7.50]TM[7200]OT[3x60 byo-yomi]PW[Lee Sedol]PB[AlphaGo]WR[9p]DT[2016-03-13]C[Game 4 - Endurance
Commentary by Fan Hui 2p
Expert Go analysis by Gu Li 9p and Zhou Ruiyang 9p
Translated by Lucas Baker, Thomas Hubert, and Thore Graepel
When I arrived in the playing room on the morning of the fourth game, everyone appeared more relaxed than before. Whatever happened today, the match had already been decided. Nonetheless, there were still two games to play, and a true professional like Lee must give his all each time he sits down at the board.
When Lee arrived in the playing room, he looked serene, the burden of expectation lifted. Perhaps he would finally recover his composure, forget his surroundings, and simply play his best Go. One thing was certain: it was not in Lee's nature to surrender.
There were many fewer reporters in the press room this time. It seemed the media thought the interesting part was finished, and the match was headed for a final score of 5-0. But in a game of Go, although the information is open for all to see, often the results defy our expectations. Until the final stone is played, anything is possible.]RE[W+Resign];B[pd];W[dp];B[cd];W[qp];B[op];W[oq];B[nq];W[pq];B[cn];W[fq];B[mp]C[For the fourth game, Lee took White. Up to move 11, the opening was the same as the second game. AlphaGo is an extremely consistent player, and once it thinks a move is good, its opinion will not change.
];W[po]LB[ed:B][qn:A]C[During the commentary for game 2, I mentioned that AlphaGo prefers to tenuki with 12 and approach at B. Previously, Lee chose to finish the joseki normally at A. White 12, however, is an interesting alternative! Perhaps it was a test: would AlphaGo still tenuki as in game 2?];B[iq]C[This time, AlphaGo chose the ordinary extension. After this move, AlphaGo's win rate was 50.5%. Both players had 1 hour and 52 minutes apiece.];W[ec]C[White played the usual corner approach.];B[hd];W[cg]C[Against Black's pincer, Lee counter-pincered to emphasize the left side. However, AlphaGo preferred the press as shown in the variation.];B[ed];W[cj];B[dc];W[bp];B[nc]C[At this point, Lee had 1 hour and 40 minutes left, AlphaGo 1 hour and 47 minutes.];W[qi]C[The opening had been a balanced one, and AlphaGo's win rate stood at 53%.];B[ep]C[The better people got to know AlphaGo, the more started calling it by friendly nicknames, such as "Master A" in China and "Master Al" in Korea. Although such names mean little on their own, they represented a growing acceptance of AlphaGo as a partner and teacher, together with whom we could advance our understanding.
During this competition, "Master A" never ceased to surprise, playing at least one extraordinary new move in every game. This move was the first such display in game 4.
Theoretically, Black 23 is quite vulgar, as it induces White to strengthen the corner. However, since White's lower side is already solid, if this move helps Black on the outside, is it really as crude as it looks? See the variation.
One of the major ideas in Chinese philosophy, from kung fu to Taoism, is the notion that "formlessness defeats form." This is not to suggest that one should do nothing, but rather that one should be ready to make use of any resource at any moment. In other words, complete flexibility is the surest path to success. When one is not committed to any style, there are no weak points at which to aim. Naturally, reaching this state demands a strong foundation and a formidable repertoire. AlphaGo seemed to have attained this "formless" style already: simple, easy to understand, and totally unexploitable.];W[eo];B[dk]C[Lee chose the outside hane, and just as I was reading out the cut, AlphaGo changed course with the shoulder hit at 25. Lee cracked a smile, as if he were looking at a naughty child. AlphaGo was being mischievous indeed!];W[fp]C[Coolly, Lee protected the corner with 26. Most players found this move too slow, and even AlphaGo thought White should respond with the crawl as shown in the variation. Nonetheless, I saw this move as a sign that Lee had finally found the confidence to play his own game, regardless of anyone's approval. This was the Lee Sedol I knew: the wolf that, starving in the winter winds, still waited for his prey to come closer, biding his time for the moment his intuition knew would come.];B[ck]C[When Black blocked here, AlphaGo’s win rate was 55%.];W[dj];B[ej];W[ei];B[fi]C[Perhaps because it was playing Black, AlphaGo played very aggressively during this game. With the double hane at 29 and 31, it seemed as if Black was attempting to completely overwhelm White.];W[eh];B[fh];W[bj]C[Even more shocking is that Lee submitted to it! Meek as a lamb, he let AlphaGo blockade the center and seal in White's group on the left side.
Sometimes to endure is difficult, sometimes it is irrational, and sometimes it is futile. Whatever it meant, Lee endured, and perhaps this was way of showing us his unwavering faith and unwillingness to surrender.
However, White did not need to suffer quite so badly. See the variation.];B[fk];W[fg];B[gg];W[ff];B[gf]C[At this point, AlphaGo's win rate reached 60%.];W[mc]C[After Black had constructed such a thick wall, it became urgent for White to invade the top. The attachment at 40 was a classic technique to do just that.];B[md]C[At this point, Lee Sedol had 1 hour and 15 minutes left, AlphaGo 1 hour and 35 minutes.];W[lc];B[nb];W[id]C[AlphaGo thought this move was problematic. In view of Black's thickness, White's most attractive option would have been to live on the spot, as shown in the variation.];B[hc];W[jg];B[pj]C[Compared to the previous games, Lee appeared much more relaxed and focused. Gone were the sighs, and the shaking of the head. Instead, he wore a look of intense concentration, as if waiting for something to arrive.
White gracefully leapt out into the center, but Black initiated a magnificent leaning attack!];W[pi];B[oj];W[oi];B[ni]C[The greater the pressure became on White's top group, the more time Lee took to ponder each move. When Black played 51, Lee hesitated even longer. If Black dared to play so boldly, choosing the hane with only two stones to White's three, I could not imagine any move for White except the cut! Even AlphaGo thought cutting was the only move.];W[nh]C[But Lee continued to endure! Against Black's hane, he haned in return.];B[mh];W[ng]C[Against the double hane, he extended!
"I can feel Lee's conviction," I wrote in my notebook, "waiting for the critical moment. But will that moment ever come?" As the game progressed, it seemed to everyone that Lee was once again on the verge of defeat.
At this point, Lee's clock had 51 minutes, AlphaGo's 1 hour and 28 minutes.];B[mg];W[mi];B[nj];W[mf];B[li]C[When Black ataried at 59, White had the option of linking up the group on the top. See the variation.];W[ne];B[nd];W[mj]C[Instead, however, Lee chose to pull out his center stone. With White's group now isolated and in grave peril, Lee's heart must have been overwhelmed with emotions. But perhaps he also sensed the long-awaited moment at hand.];B[lf]C[Lee now had 42 minutes, AlphaGo 1 hour and 22 minutes.];W[mk];B[me];W[nf];B[lh];W[qj]C[At this point, people suddenly began to congregate near the playing room. The rumor was spreading that the game was about to end, with AlphaGo victorious as expected. But Lee looked cool-headed as ever as he played the turn at 68. Was he really not afraid of dying?];B[kk]C[AlphaGo continued striding forward with the knight's move, aiming to swallow the white group whole. Lee pondered deeply over his next move. If White was to have any chance, he would have to seize it now.
At this point, Lee had 34 minutes, AlphaGo 1 hour and 19 minutes.];W[ik];B[ji]C[When Black closed off the center with 71, Lee had 27 minutes, AlphaGo 1 hour and 17 minutes.];W[gh];B[hj]C[After ten minutes of thought, Lee played the cut. Right on schedule, one minute later, AlphaGo enclosed White's cutting stone. Lee sighed, and kept thinking.];W[ge];B[he];W[fd];B[fc]C[At move 77, Lee had only 11 minutes left. Moreover, AlphaGo’s win rate had climbed over 70%. It seemed the game was over.
In fact, Lee had just completed the last of the preparations for his final charge!];W[ki]C[At last, Lee Sedol launched his attack. Like an earthquake, the wedge at 78 tore apart the cracks in Black's fortress! None of us had anticipated this. When Gu Li saw White 78 from his broadcasting studio in China, he shouted: "The divine move!" All of Lee's painstaking preparations were finally about to bear fruit.
Actually, Lee spent very little time on this move itself. Later, during the press conference, he told the assembled reporters that he had not spent much time calculating. He had simply played what felt right.
Without a doubt, this move is a spectacular flash of insight - but does it really work? See the variation for an explanation of White's plan and Black's best response.
Regardless, this move cast AlphaGo into complete confusion.];B[jj];W[lj]C[Move 78 might not really work, but AlphaGo was at a complete loss to deal with it. When Black pulled back, White blocked at 80, and Black could no longer kill the white stones unconditionally.];B[kh];W[jh]C[Everybody still thought AlphaGo was aiming for the ko shown in the variation, but this was not the case.];B[ml];W[nk];B[ol]C[Inexplicably, AlphaGo began trying to extract the dead stones on the right side!];W[ok];B[pk];W[pl];B[qk];W[nl];B[kj];W[ii]C[When White haned here, it was already difficult for Black to contain the white stones.
At this point, AlphaGo’s win rate was in free fall. When White played 92, it dropped all the way to 55%, a full 15 points lower than before! What was going on?!];B[rk];W[om]LB[rk:A][om:B]C[AlphaGo seemed to have gone crazy, and began thrashing around wildly. The exchange of A for B reinforced White's center with no compensation.];B[pg];W[ql];B[cp]LB[ki:A]C[This wedge was completely beyond understanding! Aja Huang, who had been the picture of calmness since the beginning of the match, now looked at me as if to ask, "What's happening!?" I answered with a look that said, "I don't know."
Even now, we still do not know why AlphaGo lost its mind, playing senseless blunders one after another. Only one thing is certain: the original cause was the wedge at A, Lee Sedol's mystical "divine move." White 78 was incontrovertible proof of his determination to fight on, and his perseverance was rewarded with victory.];W[co];B[oe];W[rl];B[sk];W[rj];B[hg]C[Little by little, AlphaGo recovered its sanity, but too late to save the game. When Black played 103, AlphaGo’s win rate had fallen to 30%, the first time such low numbers had appeared since the match began.
At this point, Lee entered byo-yomi, but for the first time, he was seeing the light of victory.
The press room had been half-empty for some time, but now people began to pour in. Many reporters had already left, but when they heard that Lee Sedol might win, they turned around and came rushing back!];W[ij];B[km];W[gi];B[fj];W[jl];B[kl];W[gl];B[fl];W[gm];B[ch];W[ee];B[eb];W[bg];B[dg];W[eg];B[en];W[fo];B[df];W[dh];B[im];W[hk];B[bn];W[if];B[gd];W[fe];B[hf];W[ih];B[bh];W[ci];B[ho];W[go];B[or];W[rg]C[Everyone was anxiously awaiting the news of Lee's victory. As the professional commentators grew more and more certain of the conclusion, the game continued to progress onscreen. Playing in byo-yomi, Lee continued to answer AlphaGo's every move with utmost caution.];B[dn];W[cq];B[pr];W[qr];B[rf];W[qg];B[qf];W[jc];B[gr];W[sf];B[se];W[sg];B[rd];W[bl];B[bk];W[ak];B[cl];W[hn];B[in];W[hp];B[fr];W[er];B[es];W[ds];B[ah];W[ai];B[kd];W[ie];B[kc];W[kb];B[gk];W[ib];B[qh];W[rh];B[qs];W[rs];B[oh];W[sl];B[of];W[sj];B[ni];W[nj];B[oo];W[jp]C[At move 180, AlphaGo finally resigned.
Lee Sedol entered the press room to thunderous applause. At long last, he could smile. It was a simple, modest smile, nothing world-changing - but then, aren't little moments like this the real source of joy in life?])
+131
View File
@@ -0,0 +1,131 @@
(;FF[4]
CA[UTF-8]
GM[1]
DT[2020-03-31]
PC[OGS: https://online-go.com/game/1234]
GN[blabla]
PB[bbb]
PW[aaa]
BR[4k]
WR[5k]
TM[259200]OT[86400 fischer]
RE[?]
SZ[19]
KM[6.5]
RU[Japanese]
C[Game chats will be available in SGF downloads once the game has finished.
]
;B[qd]
(;W[dp]
(;B[pp]
(;W[dc]
(;B[oc]
(;W[ce]
(;B[qj]
(;W[nq]
(;B[lp]
(;W[qq]
(;B[qp]
(;W[pq]
(;B[op]
(;W[rq]
(;B[np]
(;W[mq]
(;B[rp]
(;W[lq]
(;B[nj]
(;W[nd]
(;B[od]
(;W[jd]
(;B[ne]
(;W[kp]
(;B[ck]
(;W[cm]
(;B[ch]
(;W[em]
(;B[fp]
(;W[hp]
(;B[go]
(;W[ho]
(;B[gn]
(;W[ek]
(;B[hm]
(;W[hn]
(;B[gm]
(;W[im]
(;B[cq]
(;W[dq]
(;B[cp]
(;W[co]
(;B[bo]
(;W[bn]
(;B[do]
(;W[cn]
(;B[dr]
(;W[eo]
(;B[eq]
(;W[ep]
(;B[fr]
(;W[eh]
(;B[dg]
(;W[ef]
(;B[df]
(;W[de]
(;B[eg]
(;W[fg]
(;B[ff]
(;W[ee]
(;B[gg]
(;W[fh]
(;B[ge]
(;W[dh]
(;B[bg]
(;W[bf]
(;B[cg]
(;W[ci]
(;B[bi]
(;W[cj]
(;B[bj]
(;W[ag]
(;B[ah]
(;W[af]
(;B[dk]
(;W[ej]
(;B[el]
(;W[fl]
(;B[dl]
(;W[fm]
(;B[fk]
(;W[fj]
(;B[gk]
(;W[gj]
(;B[dm]
(;W[dn]
(;B[fn]
(;W[en]
(;B[hk]
(;W[ii]
(;B[bl]
(;W[bp]
(;B[bq]
(;W[ao]
(;B[am]
(;W[bm]
(;B[al]
(;W[cl]
(;B[bk]
(;W[aj]
(;B[an]
(;W[aq]
(;B[ar]
(;W[br]
(;B[ap]
(;W[bo]
(;B[cr]
(;W[aq]
(;B[bs]
(;W[cf]
(;B[fo]
(;W[bh]
(;B[gl]
)))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))
+243
View File
@@ -0,0 +1,243 @@
(;
GM[1]EV[Internet Go Server game: player1 vs player2]
US[Brought to you by IGS PANDANET]
CoPyright[
Copyright (c) PANDANET Inc. 2020
Permission to reproduce this game is given, provided proper credit is given.
No warrantee, implied or explicit, is understood.
Use of this game is an understanding and agreement of this notice.
]
GN[player1-player2(B) IGS]RE[W+34.5]
PW[player1]WR[11k+]NW[18]
PB[player2]BR[12k ]NB[17]
PC[IGS: igs.joyjoy.net 6969]DT[2020-04-10]
SZ[19]TM[60]KM[0.500000]LT[]
RR[Normal]
HA[2]AB[pd][dp]C[
player2 12k : gg
player1 11k+: Hi!
player2 12k : Hi!
]
;W[pp]WL[45]
;B[dc]BL[56]
;W[ce]WL[40]
;B[cd]BL[52]
;W[de]WL[38]
;B[bd]BL[49]
;W[ec]WL[35]
;B[dd]BL[44]
;W[ed]WL[31]
;B[be]BL[40]
;W[cg]WL[28]
;B[ci]BL[30]
;W[eg]WL[13]
;B[ej]BL[18]
;W[fq]WL[9]
;B[eq]BL[15]
;W[fp]WL[7]
;B[er]BL[12]
;W[cn]WL[2]
;B[bo]BL[8]
;W[ck]WL[596]
;B[cj]BL[5]
;W[en]WL[593]
;B[dk]BL[1]
;W[cl]WL[589]
;B[dl]BL[590]
;W[dm]WL[586]
;B[fl]BL[588]
;W[gn]WL[577]
;B[np]BL[583]
;W[kq]WL[569]
;B[or]BL[578]
;W[pr]WL[562]
;B[oq]BL[575]
;W[pq]WL[559]
;B[lp]BL[554]
;W[kp]WL[556]
;B[lq]BL[552]
;W[kr]WL[551]
;B[lr]BL[550]
;W[qm]WL[544]
;B[qk]BL[540]
;W[qf]WL[539]
;B[qh]BL[537]
;W[qc]WL[535]
;B[pc]BL[534]
;W[qd]WL[531]
;B[pb]BL[526]
;W[qb]WL[528]
;B[lc]BL[524]
;W[jc]WL[524]
;B[kc]BL[514]
;W[jd]WL[521]
;B[kd]BL[512]
;W[je]WL[519]
;B[ke]BL[509]
;W[jg]WL[517]
;B[qg]BL[496]
;W[of]WL[511]
;B[oe]BL[493]
;W[pf]WL[507]
;B[oh]BL[488]
;W[ol]WL[501]
;B[oj]BL[483]
;W[mf]WL[491]
;B[me]BL[480]
;W[ne]WL[489]
;B[nd]BL[478]
;W[nf]WL[484]
;B[le]BL[467]
;W[fr]WL[592]
;B[es]BL[464]
;W[lo]WL[588]
;B[mo]BL[461]
;W[ln]WL[578]
;B[mn]BL[598]
;W[lm]WL[572]
;B[hl]BL[592]
;W[im]WL[566]
;B[fh]BL[587]
;W[fg]WL[562]
;B[gh]BL[584]
;W[gg]WL[560]
;B[li]BL[581]
;W[ii]WL[556]
;B[hi]BL[578]
;W[hh]WL[554]
;B[hg]BL[575]
;W[ih]WL[551]
;B[hj]BL[573]
;W[hf]WL[548]
;B[eh]BL[555]
;W[dh]WL[545]
;B[di]BL[552]
;W[bn]WL[537]
;B[co]BL[549]
;W[rl]WL[495]
;B[rk]BL[545]
;W[sk]WL[493]
;B[sj]BL[542]
;W[sl]WL[491]
;B[rj]BL[540]
;W[eb]WL[449]
;B[db]BL[537]
;W[da]WL[447]
;B[ca]BL[534]
;W[ea]WL[446]
;B[bb]BL[532]
;W[ao]WL[442]
;B[ap]BL[529]
;W[an]WL[440]
;B[bp]BL[527]
;W[pe]WL[405]
;B[od]BL[524]
;W[fs]WL[386]
;B[ks]BL[520]
;W[js]WL[383]
;B[ls]BL[517]
;W[jr]WL[380]
;B[ps]BL[515]
;W[qs]WL[590]
;B[os]BL[512]
;W[qr]WL[588]
;B[ql]BL[509]
;W[rm]WL[581]
;B[pl]BL[598]
;W[pm]WL[579]
;B[on]BL[596]
;W[pn]WL[576]
;B[ok]BL[582]
;W[ml]WL[573]
;B[mk]BL[571]
;W[lk]WL[568]
;B[mj]BL[569]
;W[rf]WL[560]
;B[rg]BL[566]
;W[pa]WL[557]
;B[oa]BL[563]
;W[qa]WL[555]
;B[nb]BL[561]
;W[jb]WL[550]
;B[kb]BL[558]
;W[ep]WL[532]
;B[eo]BL[552]
;W[fo]WL[524]
;B[do]BL[550]
;W[em]WL[518]
;B[el]BL[544]
;W[bj]WL[488]
;B[bi]BL[541]
;W[aj]WL[463]
;B[ai]BL[536]
;W[bk]WL[460]
;B[bg]BL[534]
;W[ch]WL[447]
;B[bh]BL[529]
;W[cf]WL[440]
;B[bf]BL[521]
;W[ij]WL[419]
;B[hk]BL[515]
;W[gm]WL[414]
;B[gl]BL[512]
;W[mh]WL[411]
;B[mi]BL[505]
;W[nh]WL[408]
;B[ni]BL[502]
;W[sg]WL[396]
;B[sh]BL[498]
;W[sf]WL[394]
;B[nl]BL[488]
;W[om]WL[597]
;B[mm]BL[481]
;W[ll]WL[592]
;B[nm]BL[477]
;W[oo]WL[590]
;B[nn]BL[595]
;W[op]WL[581]
;B[cq]BL[588]
;W[no]WL[578]
;B[nq]BL[580]
;W[ja]WL[569]
;B[ka]BL[576]
;W[il]WL[566]
;B[lh]BL[566]
;W[og]WL[549]
;B[ph]BL[560]
;W[ik]WL[544]
;B[mg]BL[548]
;W[ng]WL[541]
;B[lg]BL[545]
;W[kj]WL[535]
;B[ki]BL[536]
;W[ji]WL[532]
;B[kf]BL[531]
;W[jf]WL[530]
;B[kh]BL[526]
;W[hm]WL[513]
;B[fm]BL[512]
;W[fn]WL[510]
;B[tt]BL[492]
;W[lj]WL[489]
;B[tt]BL[482]
;W[tt]WL[484]
;B[tt]BL[482]C[
player1 11k+: player1 dead @ H13
player2 done
];
TW[am][al][ak][bm][bl][cm][dg][df][ef][ee][ff][fe][fd][fc][fb][fa][gs][gr]
[gq][gp][go][gf][ge][gd][gc][gb][ga][hs][hr][hq][hp][ho][hn][hg][he][hd]
[hc][hb][ha][is][ir][iq][ip][io][in][ig][if][ie][id][ic][ib][ia][jq][jp]
[jo][jn][jm][jl][jk][jj][ko][kn][km][kl][kk][po][qq][qp][qo][qn][qe][rs]
[rr][rq][rp][ro][rn][re][rd][rc][rb][ra][ss][sr][sq][sp][so][sn][sm][se]
[sd][sc][sb][sa]
TB[as][ar][aq][ah][ag][af][ae][ad][ac][ab][aa][bs][br][bq][bc][ba][cs][cr]
[cp][cc][cb][ds][dr][dq][dj][ek][ei][fk][fj][fi][gk][gj][gi][ld][lb][la]
[ms][mr][mq][mp][md][mc][mb][ma][ns][nr][nk][nj][nc][na][oi][oc][ob][pk]
[pj][pi][qj][qi][ri][rh][si]
OS[Trilan]
)
+6 -6
View File
@@ -1,6 +1,6 @@
import pytest
from core.game import Game, IllegalMoveException, Move
from katrain.core.game import Game, IllegalMoveException, Move
class MockKaTrain:
@@ -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"))
+53
View File
@@ -0,0 +1,53 @@
import os
from katrain.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)