puco21 commited on
Commit
b3ef89d
·
verified ·
1 Parent(s): 20d417a

Upload 211 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +44 -0
  2. katrain/CONTRIBUTIONS.md +79 -0
  3. katrain/ENGINE.md +74 -0
  4. katrain/INSTALL.md +177 -0
  5. katrain/LICENSE +40 -0
  6. katrain/README.md +253 -0
  7. katrain/THEMES.md +86 -0
  8. katrain/__main__.spec +38 -0
  9. katrain/__pycache__/board_ai.cpython-310.pyc +0 -0
  10. katrain/__pycache__/engine_ai.cpython-310.pyc +0 -0
  11. katrain/__pycache__/hongik_ai.cpython-310.pyc +0 -0
  12. katrain/fonts/Roboto-Black.ttf +3 -0
  13. katrain/fonts/Roboto-BlackItalic.ttf +3 -0
  14. katrain/fonts/Roboto-Bold.ttf +3 -0
  15. katrain/fonts/Roboto-BoldItalic.ttf +3 -0
  16. katrain/fonts/Roboto-Italic.ttf +3 -0
  17. katrain/fonts/Roboto-Light.ttf +3 -0
  18. katrain/fonts/Roboto-LightItalic.ttf +3 -0
  19. katrain/fonts/Roboto-Medium.ttf +3 -0
  20. katrain/fonts/Roboto-MediumItalic.ttf +3 -0
  21. katrain/fonts/Roboto-Regular.ttf +3 -0
  22. katrain/fonts/Roboto-Thin.ttf +3 -0
  23. katrain/fonts/Roboto-ThinItalic.ttf +3 -0
  24. katrain/i18n.py +125 -0
  25. katrain/katrain.py +4 -0
  26. katrain/katrain/__init__.py +0 -0
  27. katrain/katrain/__main__.py +455 -0
  28. katrain/katrain/__main__.spec +38 -0
  29. katrain/katrain/__pycache__/__init__.cpython-310.pyc +0 -0
  30. katrain/katrain/config.json +235 -0
  31. katrain/katrain/core/__init__.py +0 -0
  32. katrain/katrain/core/__pycache__/__init__.cpython-310.pyc +0 -0
  33. katrain/katrain/core/__pycache__/ai.cpython-310.pyc +0 -0
  34. katrain/katrain/core/__pycache__/base_katrain.cpython-310.pyc +0 -0
  35. katrain/katrain/core/__pycache__/constants.cpython-310.pyc +0 -0
  36. katrain/katrain/core/__pycache__/game.cpython-310.pyc +0 -0
  37. katrain/katrain/core/__pycache__/game_node.cpython-310.pyc +0 -0
  38. katrain/katrain/core/__pycache__/lang.cpython-310.pyc +0 -0
  39. katrain/katrain/core/__pycache__/sgf_parser.cpython-310.pyc +0 -0
  40. katrain/katrain/core/__pycache__/utils.cpython-310.pyc +0 -0
  41. katrain/katrain/core/ai.py +516 -0
  42. katrain/katrain/core/base_katrain.py +96 -0
  43. katrain/katrain/core/constants.py +272 -0
  44. katrain/katrain/core/contribute_engine.py +302 -0
  45. katrain/katrain/core/game.py +818 -0
  46. katrain/katrain/core/game_node.py +466 -0
  47. katrain/katrain/core/lang.py +89 -0
  48. katrain/katrain/core/sgf_parser.py +714 -0
  49. katrain/katrain/core/tsumego_frame.py +289 -0
  50. katrain/katrain/core/utils.py +99 -0
.gitattributes CHANGED
@@ -33,3 +33,47 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ katrain/fonts/Roboto-Black.ttf filter=lfs diff=lfs merge=lfs -text
37
+ katrain/fonts/Roboto-BlackItalic.ttf filter=lfs diff=lfs merge=lfs -text
38
+ katrain/fonts/Roboto-Bold.ttf filter=lfs diff=lfs merge=lfs -text
39
+ katrain/fonts/Roboto-BoldItalic.ttf filter=lfs diff=lfs merge=lfs -text
40
+ katrain/fonts/Roboto-Italic.ttf filter=lfs diff=lfs merge=lfs -text
41
+ katrain/fonts/Roboto-Light.ttf filter=lfs diff=lfs merge=lfs -text
42
+ katrain/fonts/Roboto-LightItalic.ttf filter=lfs diff=lfs merge=lfs -text
43
+ katrain/fonts/Roboto-Medium.ttf filter=lfs diff=lfs merge=lfs -text
44
+ katrain/fonts/Roboto-MediumItalic.ttf filter=lfs diff=lfs merge=lfs -text
45
+ katrain/fonts/Roboto-Regular.ttf filter=lfs diff=lfs merge=lfs -text
46
+ katrain/fonts/Roboto-Thin.ttf filter=lfs diff=lfs merge=lfs -text
47
+ katrain/fonts/Roboto-ThinItalic.ttf filter=lfs diff=lfs merge=lfs -text
48
+ katrain/katrain/fonts/materialdesignicons-webfont.ttf filter=lfs diff=lfs merge=lfs -text
49
+ katrain/katrain/fonts/NotoSans-Regular.ttf filter=lfs diff=lfs merge=lfs -text
50
+ katrain/katrain/fonts/NotoSansCJKsc-Regular.otf filter=lfs diff=lfs merge=lfs -text
51
+ katrain/katrain/fonts/NotoSansJP-Regular.otf filter=lfs diff=lfs merge=lfs -text
52
+ katrain/katrain/fonts/Roboto-Black.ttf filter=lfs diff=lfs merge=lfs -text
53
+ katrain/katrain/fonts/Roboto-BlackItalic.ttf filter=lfs diff=lfs merge=lfs -text
54
+ katrain/katrain/fonts/Roboto-Bold.ttf filter=lfs diff=lfs merge=lfs -text
55
+ katrain/katrain/fonts/Roboto-BoldItalic.ttf filter=lfs diff=lfs merge=lfs -text
56
+ katrain/katrain/fonts/Roboto-Italic.ttf filter=lfs diff=lfs merge=lfs -text
57
+ katrain/katrain/fonts/Roboto-Light.ttf filter=lfs diff=lfs merge=lfs -text
58
+ katrain/katrain/fonts/Roboto-LightItalic.ttf filter=lfs diff=lfs merge=lfs -text
59
+ katrain/katrain/fonts/Roboto-Medium.ttf filter=lfs diff=lfs merge=lfs -text
60
+ katrain/katrain/fonts/Roboto-MediumItalic.ttf filter=lfs diff=lfs merge=lfs -text
61
+ katrain/katrain/fonts/Roboto-Regular.ttf filter=lfs diff=lfs merge=lfs -text
62
+ katrain/katrain/fonts/Roboto-Thin.ttf filter=lfs diff=lfs merge=lfs -text
63
+ katrain/katrain/fonts/Roboto-ThinItalic.ttf filter=lfs diff=lfs merge=lfs -text
64
+ katrain/katrain/img/B_stone.png filter=lfs diff=lfs merge=lfs -text
65
+ katrain/katrain/img/board.png filter=lfs diff=lfs merge=lfs -text
66
+ katrain/katrain/img/icon.ico filter=lfs diff=lfs merge=lfs -text
67
+ katrain/katrain/img/icon.ico__ filter=lfs diff=lfs merge=lfs -text
68
+ katrain/katrain/img/inner.png filter=lfs diff=lfs merge=lfs -text
69
+ katrain/katrain/img/W_stone.png filter=lfs diff=lfs merge=lfs -text
70
+ katrain/katrain/sounds/boing.wav filter=lfs diff=lfs merge=lfs -text
71
+ katrain/katrain/sounds/countdownbeep.wav filter=lfs diff=lfs merge=lfs -text
72
+ katrain/themes/blended-all.png filter=lfs diff=lfs merge=lfs -text
73
+ katrain/themes/blended-weak.png filter=lfs diff=lfs merge=lfs -text
74
+ katrain/themes/blocks-none.png filter=lfs diff=lfs merge=lfs -text
75
+ katrain/themes/eric-lizzie.png filter=lfs diff=lfs merge=lfs -text
76
+ katrain/themes/koast.png filter=lfs diff=lfs merge=lfs -text
77
+ katrain/themes/marks-weak.png filter=lfs diff=lfs merge=lfs -text
78
+ katrain/themes/shaded-all.png filter=lfs diff=lfs merge=lfs -text
79
+ katrain/themes/shaded-no-alpha.png filter=lfs diff=lfs merge=lfs -text
katrain/CONTRIBUTIONS.md ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing
2
+
3
+ If you are a new contributor wanting to make a larger contribution,
4
+ please first discuss the change you wish to make via
5
+ an issue, reddit or discord before making a pull request.
6
+
7
+ ## Python contributions
8
+
9
+ Python code is formatted using [black](https://github.com/psf/black) with the settings `-l 120`.
10
+ This is not enforced, and contributions with incorrect formatting will be accepted, but formatting this way is appreciated.
11
+
12
+ ## Translations
13
+
14
+ ### Contributing to an existing translation
15
+
16
+ * Go [here](https://github.com/sanderland/katrain/blob/master/katrain/i18n/locales/) and locate the `.po` file for your language.
17
+ * Alternatively, find the same file in the branch for the next version.
18
+ * Correct the relevant `msgstr` entries.
19
+
20
+ ### Adding a translation
21
+
22
+ Adding a translation requires making a new `.po` file with entries for that languages.
23
+
24
+ * Copy the [English .po file](https://github.com/sanderland/katrain/blob/master/katrain/i18n/locales/en/LC_MESSAGES/katrain.po)
25
+ * Change all the `msgstr` entries to your target language.
26
+ * Note that anything between `{}` should be left as-is.
27
+ * The information at the top of the file should also not be translated.
28
+
29
+ You can send me the resulting `.po` file, and I will integrate it into the program.
30
+
31
+ # Contributors
32
+
33
+ ## Primary author and project maintainer:
34
+
35
+ [Sander Land](https://github.com/sanderland/)
36
+
37
+ ## Contributors
38
+
39
+ Many thanks to these additional authors:
40
+
41
+ * Matthew Allred ("Kameone") for design of the v1.1 UI, macOS installation instructions, and working on promotion and YouTube videos.
42
+ * "bale-go" for development and continued work on the 'calibrated rank' AI and rank estimation algorithm.
43
+ * "Dontbtme" for detailed feedback and early testing of v1.0+.
44
+ * "nowoowoo" for a fix to the parser for SGF files with extra line breaks.
45
+ * "nimets123" for the timer sound effects and board/stone graphics.
46
+ * Jordan Seaward for the stone sound effects.
47
+ * "fohristiwhirl" for the Gibo and NGF formats parsing code.
48
+ * "kaorahi" for bug fixes, SGF parser improvements, and tsumego frame code.
49
+ * "ajkenny84" for the red-green colourblind theme.
50
+ * Lukasz Wierzbowski for the ability to paste urls for sgfs and helping fix alt-gr issues.
51
+ * Carton He for contributions to sgf parsing and handling.
52
+ * "blamarche" for adding the board coordinates toggle.
53
+ * "pdeblanc" for adding the ancient chinese scoring option, fixing a bug in query termination, and high precision score display.
54
+ * "LiamHz" for adding the 'back to main branch' keyboard shortcut.
55
+ * "xiaoyifang" for adding the reset analysis option, feature to save options on the loading screen, and scrolling through variations.
56
+ * "electricRGB" for help with adding configurable keyboard shortcuts.
57
+ * "milescrawford" for work on restyling the territory estimate.
58
+ * "Funkenschlag1" for capturing stones sound and implementation, and board rotation.
59
+ * "waltheri" for one of the wooden board textures.
60
+ * Jacob Minsky ("jacobm-tech") for various contributions including analysis move range and improvements to territory display.
61
+
62
+ ## Translators
63
+
64
+ Many thanks to the following contributors for translations.
65
+
66
+ * French: "Dontbtme" with contributions from "wonderingabout"
67
+ * Korean: "isty2e"
68
+ * German: "nimets123", "trohde", "Harleqin" and "Sovereign"
69
+ * Spanish: Sergio Villegas ("serpiente") with contributions from the Spanish OGS community
70
+ * Russian: Dmitry Ivankov and Alexander Kiselev
71
+ * Simplified Chinese: Qing Mu with contributions from "Medwin" and Viktor Lin
72
+ * Japanese: "kaorahi"
73
+ * Traditional Chinese: "Tony-Liou" with contributions from Ching-yu Lin
74
+
75
+ ## Additional thanks to
76
+
77
+ * David Wu ("lightvector") for creating KataGo and providing assistance with making the most of KataGo's amazing capabilities.
78
+ * "세븐틴" for including KaTrain in the Baduk Megapack and making explanatory YouTube videos in Korean.
79
+
katrain/ENGINE.md ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # KataGo troubleshooting
2
+
3
+ This page lists common ways in which the provided KataGo fails to work out of the box, and how to resolve these issues.
4
+ If you find your problem is not in here, you can ask on the [Leela Zero & Friends Discord](http://discord.gg/AjTPFpN) (use the #gui channel),
5
+ providing detailed information about your error.
6
+
7
+
8
+ * [General](#General)
9
+ * [GPU vs CPU](#CPU)
10
+ * [Windows specific help](#Windows)
11
+ * [MacOS specific help](#Mac)
12
+ * [Linux specific help](#Linux)
13
+
14
+
15
+
16
+ ## <a name="General"></a> General
17
+
18
+ ### <a name="CPU"></a> GPU vs CPU
19
+
20
+ The standard executables assume you have a compatible graphics card (GPU).
21
+ If you don't, KataGo will fail to start in ways that are difficult for KaTrain to pick up.
22
+
23
+ On Windows and Linux, you should be able to resolve this by:
24
+
25
+ * Going to general and engine settings (F8)
26
+ * Click 'download katago versions' and wait for downloads to finish.
27
+ * Select a CPU based KataGo version (named 'Eigen' after the library it uses).
28
+
29
+ Keep in mind that a CPU based engine can be significantly slower, and you may want to set your maximum number of
30
+ visits to a lower number to compensate for this.
31
+
32
+ ### <a name="Models"></a> KataGo model versions
33
+
34
+ KataGo models have changed over time, and selecting an older executable with a newer model can lead to errors.
35
+ Of the provided binaries, this is typically the case for the 1.6.1 'bigger boards' binary, which should
36
+ only be used with the standard 15/20/30/40 block models, and not the newer distributed training models.
37
+
38
+
39
+ ## <a name="Mac"></a><img src="https://upload.wikimedia.org/wikipedia/commons/8/8a/Apple_Logo.svg" alt="macOs" height="35"/> For macOS users
40
+
41
+ ### Running from source
42
+
43
+ Make sure you `brew install katago` or set the engine path to your own KataGo binary, as there is no executable included.
44
+
45
+ ### New Macs with M1 architecture
46
+
47
+ Make sure you `brew install katago` as the provided executable does not work on rosetta.
48
+
49
+ ### Getting more information about errors
50
+
51
+ On macOS, the .app distributable will not show a console, so you will need install using `pip` to see the console window.
52
+
53
+ ## <a name="Windows"></a><img src="https://upload.wikimedia.org/wikipedia/commons/5/5f/Windows_logo_-_2012.svg" alt="Windows" height="35"/> For Windows users
54
+
55
+ ### Getting more information about errors
56
+
57
+ Run DebugKaTrain.exe, which is released in the .zip file distributable in releases. This will show a console window
58
+ which typically tells you more.
59
+
60
+
61
+ ## <a name="Linux"></a><img src="https://upload.wikimedia.org/wikipedia/commons/a/ab/Linux_Logo_in_Linux_Libertine_Font.svg" alt="Linux" height="35"/> For Linux users
62
+
63
+ ### libzip compatibility
64
+
65
+ The most common KataGo issue relates to incompatible library versions, leading to an "Error 127".
66
+
67
+ * A good alternative is to go [here](https://github.com/lightvector/KataGo) and compile KataGo yourself.
68
+ * Installing dependencies mentioned [here](INSTALL.md#LinuxTrouble) may also resolve certain issues with KataGo or the gui.
69
+
70
+
71
+ ### Getting more information about errors
72
+
73
+ * Check the terminal output around startup time.
74
+ * Start KataGo by itself using `katrain/KataGo/katago` when running from source and check output.
katrain/INSTALL.md ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # KaTrain Installation
2
+
3
+ * [Quick install guide for MacOS](#MacQuick)
4
+ * [Troubleshooting and installation from sources](#MacSources)
5
+ * [Quick install guide for Windows](#WindowsQuick)
6
+ * [Troubleshooting and installation from sources](#WindowsSources)
7
+ * [Quick install guide for Linux](#LinuxQuick)
8
+ * [Troubleshooting and installation from sources](#LinuxSources)
9
+ * [Configuring Multiple GPUS](#GPU)
10
+ * [Troubleshooting KataGo](#KataGo)
11
+
12
+ ## <img src="https://upload.wikimedia.org/wikipedia/commons/8/8a/Apple_Logo.svg" alt="macOs" height="35"/> Installation for macOS users
13
+
14
+ ### <a name="MacQuick"></a>Quick install guide
15
+
16
+ The easiest way to install is probably [brew](https://brew.sh/). Simply run `brew install katrain` and it will download and install the latest pre-built .app, and also install katago if needed.
17
+
18
+ You can also find downloadable .app files for macOS [here](https://github.com/sanderland/katrain/releases).
19
+ Simply download, unzip the file, mount the .dmg and drag the .app file to your application folder, everything is included.
20
+ The first time launching the application you may need to [control-click in finder to give permission for the 'unidentified' app to launch](https://support.apple.com/guide/mac-help/open-a-mac-app-from-an-unidentified-developer-mh40616/mac). This is simply a result of Apple charging $99/year to developers to be 'identified'.
21
+
22
+ Users with the last generation M1 macs with different architecture should then `brew install katago` in addition to this. KaTrain will automatically detect this KataGo binary.
23
+
24
+ ### <a name="MacCommand"></a>Command line install guide
25
+
26
+ [Open a terminal](https://support.apple.com/guide/terminal/open-or-quit-terminal-apd5265185d-f365-44cb-8b09-71a064a42125/mac) and enter the following commands:
27
+ ```bash
28
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"
29
+ brew install python3
30
+ brew install katago
31
+ pip3 install katrain
32
+ ```
33
+
34
+ If you are using a M1 Mac, at the point of writing, the latest stable release of Kivy (2.0) does not support the new architecture, so we have to use a development snapshot and build it from source:
35
+
36
+ ```bash
37
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"
38
+ brew install python3
39
+ brew install katago
40
+
41
+ # install dependencies: https://kivy.org/doc/stable/installation/installation-osx.html#install-source-osx
42
+ brew install pkg-config sdl2 sdl2_image sdl2_ttf sdl2_mixer gstreamer ffmpeg
43
+
44
+ # install Kivy from source: https://kivy.org/doc/stable/gettingstarted/installation.html#kivy-source-install
45
+ pip3 install "kivy[base] @ https://github.com/kivy/kivy/archive/master.zip" --no-binary kivy
46
+
47
+ pip3 install katrain
48
+ ```
49
+
50
+ Now you can start KaTrain by simply typing `katrain` in a terminal.
51
+
52
+ These commands install [Homebrew](https://brew.sh), which simplifies installing packages,
53
+ followed by the programming language Python, the KataGo AI, and KaTrain itself.
54
+
55
+ To upgrade to a newer version, simply run `pip3 install -U katrain`
56
+
57
+ ### <a name="MacSources"></a>Troubleshooting and Installation from sources
58
+
59
+ Installation from sources is essentially the same as for Linux, see [here](#LinuxSources),
60
+ note that you will still need to install your own KataGo, using brew or otherwise.
61
+
62
+ If you encounter SSL errors on downloading model files, you may need to follow [these](https://stackoverflow.com/questions/52805115/certificate-verify-failed-unable-to-get-local-issuer-certificate) instructions to fix your certificates.
63
+
64
+ ## <img src="https://upload.wikimedia.org/wikipedia/commons/5/5f/Windows_logo_-_2012.svg" alt="Windows" height="35"/> Installation for Windows users
65
+
66
+ ### <a name="WindowsQuick"></a>Quick install guide
67
+
68
+ You can find downloadable .exe files for windows [here](https://github.com/sanderland/katrain/releases).
69
+ Simply download and run, everything is included.
70
+
71
+ ### <a name="WindowsSources"></a>Installation from sources
72
+
73
+ * Download the repository by clicking the green *Clone or download* on this page and *Download zip*. Extract the contents.
74
+ * Make sure you have a python installation, I will assume Anaconda (Python 3.7/3.8), available [here](https://www.anaconda.com/products/individual#download-section).
75
+ * Open 'Anaconda prompt' from the start menu and navigate to where you extracted the zip file using the `cd <folder>` command.
76
+ * Execute the command `pip3 install .`
77
+ * Start the app by running `katrain` in the command prompt.
78
+
79
+ ## <img src="https://upload.wikimedia.org/wikipedia/commons/a/ab/Linux_Logo_in_Linux_Libertine_Font.svg" alt="Linux" height="35"/> Installation for Linux users
80
+
81
+ ### <a name="LinuxQuick"></a>Quick install guide
82
+
83
+ If you have a working Python 3.6-3.8 available, you should be able to simply:
84
+
85
+ * Run `pip3 install -U katrain` to install or upgrade.
86
+ * Run the program by executing `katrain` in a terminal.
87
+
88
+ ### <a name="LinuxSources"></a>Installation from sources
89
+
90
+ This section describes how to install KaTrain from sources,
91
+ in case you want to run it in a local directory or have more control over the process.
92
+ It assumes you have a working Python 3.6+ installation.
93
+
94
+ * Open a terminal.
95
+ * Run the command `git clone https://github.com/sanderland/katrain.git` to download the repository and
96
+ change directory using `cd katrain`
97
+ * Run the command `pip3 install .` to install the package globally, or use `--user` to install locally.
98
+ * Run the program by typing `katrain` in the terminal.
99
+ * If you prefer not to install, run without installing using `python3 -m katrain` after installing the
100
+ dependencies from `requirements.txt`.
101
+
102
+ A binary for KataGo is included, but if you have compiled your own, press F8 to open general settings and change the
103
+ KataGo executable path to the relevant KataGo v1.4+ binary.
104
+
105
+ ### <a name="LinuxTrouble"></a>Troubleshooting and advanced installation from sources
106
+
107
+ You can try to manually install dependencies to resolve some issues relating to missing dependencies,
108
+ e.g. the binary 'wheel' is not provided, KataGo is not starting, or sounds are not working.
109
+ You can also follow these instructions if you don't want to install KaTrain, and just run it locally.
110
+
111
+ First install the following packages, which are either required for building Kivy,
112
+ or may help resolve missing dependencies for Kivy or KataGo.
113
+ ```bash
114
+ sudo apt-get install python3-pip build-essential git python3 python3-dev ffmpeg libsdl2-dev libsdl2-image-dev\
115
+ libsdl2-mixer-dev libsdl2-ttf-dev libportmidi-dev libswscale-dev libavformat-dev libavcodec-dev zlib1g-dev\
116
+ libgstreamer1.0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good libpulse\
117
+ pkg-config libgl-dev opencl-headers ocl-icd-opencl-dev libzip-dev
118
+ ```
119
+ Then, try installing python package dependencies using:
120
+ ```bash
121
+ pip3 install -r requirements.txt
122
+ pip3 install screeninfo # Skip on MacOS, not working
123
+ ```
124
+ In case the sound is not working, or there is no available wheel for your OS or Python version, try building kivy locally using:
125
+ ```bash
126
+ pip3 uninstall kivy
127
+ pip3 install kivy --no-binary kivy
128
+ ```
129
+
130
+ You can now start KaTrain by running `python3 -m katrain`
131
+
132
+ In case KataGo does not start, an alternative is to go [here](https://github.com/lightvector/KataGo) and compile KataGo yourself.
133
+
134
+
135
+
136
+ ## <a name="GPU"></a> Configuring the GPU(s) KataGo uses
137
+
138
+ In most cases KataGo detects your configuration correctly, automatically searching for OpenCL devices and select the highest scoring device.
139
+ However, if you have multiple GPUs or want to force a specific device you will need to edit the 'analysis_config.cfg' file in the KataGo folder.
140
+
141
+ To see what devices are available and which one KataGo is using. Look for the following lines in the terminal after starting KaTrain:
142
+ ```
143
+ Found 3 device(s) on platform 0 with type CPU or GPU or Accelerator
144
+ Found OpenCL Device 0: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz (Intel) (score 102)
145
+ Found OpenCL Device 1: Intel(R) UHD Graphics 630 (Intel Inc.) (score 6000102)
146
+ Found OpenCL Device 2: AMD Radeon Pro 5500M Compute Engine (AMD) (score 11000102)
147
+ Using OpenCL Device 2: AMD Radeon Pro 5500M Compute Engine (AMD) OpenCL 1.2
148
+ ```
149
+
150
+ The above devices were found on a 2019 MacBook Pro with both an on-motherboard graphics chip, and a separate AMD Radeon Pro video card.
151
+ As you can see it scores about twice as high as the Intel UHD chip and KataGo has selected
152
+ it as it's sole device. You can configure KataGo to use *both* the AMD and the Intel devices to get the best performance out of the system.
153
+
154
+ * Open the 'analysis_config.cfg' file in the `katrain/KataGo` folder in your python packages, or local sources.
155
+ If you can't find it, turn on `debug_level=1` in general settings and look for the command that is used to start KataGo.
156
+ * Search for `numNNServerThreadsPerModel` (~line 108), uncomment the line by deleting the # and set the value to 2. The line should read `numNNServerThreadsPerModel = 2`.
157
+ * Search for `openclDeviceToUseThread` (~line 164), uncomment by deleting the # and set the values to the device ID numbers identified in the terminal.
158
+ From the example above, we would want to use devices 1 and 2, for the Intel and AMD GPUs, but not device 0 (the CPU). In our case, the lines should read:
159
+ ```
160
+ openclDeviceToUseThread0 = 1
161
+ openclDeviceToUseThread1 = 2
162
+ ```
163
+ * Run `katrain` and confirm that KataGo is now using both devices, by
164
+ checking the output from the terminal, which should indicate two devices being used. For example:
165
+ ```
166
+ Found 3 device(s) on platform 0 with type CPU or GPU or Accelerator
167
+ Found OpenCL Device 0: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz (Intel) (score 102)
168
+ Found OpenCL Device 1: Intel(R) UHD Graphics 630 (Intel Inc.) (score 6000102)
169
+ Found OpenCL Device 2: AMD Radeon Pro 5500M Compute Engine (AMD) (score 11000102)
170
+ Using OpenCL Device 1: Intel(R) UHD Graphics 630 (Intel Inc.) OpenCL 1.2
171
+ Using OpenCL Device 2: AMD Radeon Pro 5500M Compute Engine (AMD) OpenCL 1.2
172
+ ```
173
+
174
+
175
+ ## <a name="KataGo"></a> Troubleshooting and advanced KataGo settings
176
+
177
+ See [here](ENGINE.md) for an overview of how to resolve various issues with KataGo.
katrain/LICENSE ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ This repository includes:
2
+
3
+ 1. Binaries for 'KataGo', which is Copyright David J Wu et al.
4
+ For on related licenses for these binaries and libraries see https://github.com/lightvector/KataGo
5
+
6
+ 2. Icons from www.flaticon.com, used with permission with the following attributions:
7
+ - Equalize icon and Thrash Icon: derived from work by bqlqn from www.flaticon.com
8
+ - Other Menu icons, Finish, Collaboration and Flag icons: derived from work by Freepik from www.flaticon.com
9
+ - Collapse branch icon: derived from work by Kirill Kazachek from www.flaticon.com
10
+ - Prune icon: derived from work by Pixelmeetup from www.flaticon.com
11
+ - Reset icon: derived from work by Pixel Perfect from www.flaticon.com
12
+ - Rotate icon: derived from work by Frey Wazza from www.flaticon.com
13
+
14
+ 3. The True Type Font DIGITAL-7 version 1.02 by Sizenko Alexander, which is free for non-commercial use.
15
+
16
+ 4. The Noto Sans fonts from google which are covered by the SIL open font license v1.1 included in the katrain/fonts directory.
17
+
18
+ -----------------------------------------------------------------------------------------
19
+ Aside from the above, the license for all other content in this repository is as follows:
20
+ -----------------------------------------------------------------------------------------
21
+
22
+ Copyright 2020 Sander Land and/or other authors of the content in this repository.
23
+ (See 'CONTRIBUTIONS.md' file for a list of authors as well as other indirect contributors).
24
+
25
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
26
+ associated documentation files (the "Software"), to deal in the Software without restriction,
27
+ including without limitation the rights to use, copy, modify, merge, publish, distribute,
28
+ sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
29
+ furnished to do so, subject to the following conditions:
30
+
31
+ The above copyright notice and this permission notice shall be included in all copies or
32
+ substantial portions of the Software.
33
+
34
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
35
+ NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
36
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
37
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
38
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
39
+
40
+ -----------------------------------------------------------------------------------------
katrain/README.md ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # <a name="manual"></a> KaTrain
2
+
3
+ [![Latest Release](http://img.shields.io/github/release/sanderland/katrain?label=download)](http://github.com/sanderland/katrain/releases)
4
+ [![License:MIT](http://img.shields.io/pypi/l/katrain)](http://en.wikipedia.org/wiki/MIT_License)
5
+ [![GitHub Downloads](http://img.shields.io/github/downloads/sanderland/katrain/total?color=%23336699&label=github%20downloads)](http://github.com/sanderland/katrain/releases)
6
+ [![PyPI Downloads](http://pepy.tech/badge/katrain)](http://pepy.tech/project/katrain)
7
+ [![Discord](http://img.shields.io/discord/417022162348802048?logo=discord)](http://discord.com/channels/417022162348802048/629446365688365067)
8
+
9
+ KaTrain is a tool for analyzing games and playing go with AI feedback from KataGo:
10
+
11
+ * Review your games to find the moves that were most costly in terms of points lost.
12
+ * Play against AI and get immediate feedback on mistakes with option to retry.
13
+ * Play against a wide range of weakened versions of AI with various styles.
14
+ * Automatically generate focused SGF reviews which show your biggest mistakes.
15
+
16
+ ## Manual
17
+
18
+ <table>
19
+ <td>
20
+
21
+ * [Previews and YouTube tutorials](#preview)
22
+ * [Installation](#install)
23
+ * [Manual](#ai)
24
+ * [Configuring KataGo](#kata)
25
+ * [Play against AI](#ai)
26
+ * [Analyzing your Games](#analysis)
27
+ * [Keyboard shortcuts](#keyboard)
28
+ * [Distributed training](#distributed)
29
+ * [Themes](#themes)
30
+ * [FAQ and Troubleshooting](#faq)
31
+ * [Contributing](#support)
32
+
33
+
34
+ <td>
35
+
36
+ <a href="http://github.com/sanderland/katrain/blob/master/README.md"><img alt="English" src="https://github.com/sanderland/katrain/blob/master/katrain/img/flags/flag-uk.png" width=50></a>
37
+ <a href="http://translate.google.com/translate?sl=en&tl=de&u=https%3A%2F%2Fgithub.com%2Fsanderland%2Fkatrain%2Fblob%2Fmaster%2FREADME.md"><img alt="German" src="https://github.com/sanderland/katrain/blob/master/katrain/img/flags/flag-de.png" width=50></a>
38
+ <a href="http://translate.google.com/translate?sl=en&tl=fr&u=https%3A%2F%2Fgithub.com%2Fsanderland%2Fkatrain%2Fblob%2Fmaster%2FREADME.md"><img alt="French" src="https://github.com/sanderland/katrain/blob/master/katrain/img/flags/flag-fr.png" width=50></a>
39
+ <a href="http://translate.google.com/translate?sl=en&tl=ru&u=https%3A%2F%2Fgithub.com%2Fsanderland%2Fkatrain%2Fblob%2Fmaster%2FREADME.md"><img alt="Russian" src="https://github.com/sanderland/katrain/blob/master/katrain/img/flags/flag-ru.png" width=50></a>
40
+ <a href="http://translate.google.com/translate?sl=en&tl=tr&u=https%3A%2F%2Fgithub.com%2Fsanderland%2Fkatrain%2Fblob%2Fmaster%2FREADME.md"><img alt="Turkish" src="https://github.com/sanderland/katrain/blob/master/katrain/img/flags/flag-tr.png" width=50></a>
41
+ <br/>
42
+
43
+ <a href="http://translate.google.com/translate?sl=en&tl=zh-CN&u=https%3A%2F%2Fgithub.com%2Fsanderland%2Fkatrain%2Fblob%2Fmaster%2FREADME.md"><img alt="Simplified Chinese" src="https://github.com/sanderland/katrain/blob/master/katrain/img/flags/flag-cn.png" width=50></a>
44
+ <a href="http://translate.google.com/translate?sl=en&tl=zh-TW&u=https%3A%2F%2Fgithub.com%2Fsanderland%2Fkatrain%2Fblob%2Fmaster%2FREADME.md"><img alt="Traditional Chinese" src="https://github.com/sanderland/katrain/blob/master/katrain/img/flags/flag-tw.png" width=50></a>
45
+ <a href="http://translate.google.com/translate?sl=en&tl=ko&u=https%3A%2F%2Fgithub.com%2Fsanderland%2Fkatrain%2Fblob%2Fmaster%2FREADME.md"><img alt="Korean" src="https://github.com/sanderland/katrain/blob/master/katrain/img/flags/flag-ko.png" width=50></a>
46
+ <a href="http://translate.google.com/translate?sl=en&tl=ja&u=https%3A%2F%2Fgithub.com%2Fsanderland%2Fkatrain%2Fblob%2Fmaster%2FREADME.md"><img alt="Japanese" src="https://github.com/sanderland/katrain/blob/master/katrain/img/flags/flag-jp.png" width=50></a>
47
+
48
+ </td>
49
+ </table>
50
+
51
+ ## <a name="preview"></a> Preview and Youtube Videos
52
+
53
+ <img alt="screenshot" src="https://raw.githubusercontent.com/sanderland/katrain/master/screenshots/analysis.png" width="550">
54
+
55
+ | **Local Joseki Analysis** | **Analysis Tutorial** | **Teaching Game Tutorial** |
56
+ |:-----------------------------------------------------------------------------------------------------:|:-----------------------------------------------------------------------------------------------------:|:------------------------------------------------------------------------------------------------------------:|
57
+ | [![Local Joseki Analysis Video](http://i.imgur.com/YcpmSBx.png)](https://www.youtube.com/watch?v=tXniX57KtKk) | [![Analysis Tutorial](http://i.imgur.com/3EP4IEr.png)](http://www.youtube.com/watch?v=qjxkcKgrsbU) | [![ Teaching Game Tutorial](http://i.imgur.com/jAdcSL5.png)](http://www.youtube.com/watch?v=wFl4Bab_eGM) |
58
+
59
+
60
+
61
+ ## <a name="install"></a> Installation
62
+ * See the [releases page](http://github.com/sanderland/katrain/releases) for downloadable executables for Windows and macOS.
63
+ * Alternatively use `pip3 install -U katrain` to install the latest version from PyPI on any 64-bit OS.
64
+ * On macOS, you can also use `brew install katrain` to install the app.
65
+ * [This page](https://github.com/sanderland/katrain/blob/master/INSTALL.md) has detailed instructions for Window, Linux and macOS,
66
+ as well as troubleshooting and setting up KataGo to use multiple GPUs.
67
+
68
+ ## <a name="kata"></a> Configuring KataGo
69
+
70
+ KaTrain comes pre-packaged with a working KataGo (OpenCL version) for Windows, Linux, and pre-M1 Mac operating systems, and the rather old 15 block model.
71
+
72
+ To change the model, open 'General and Engine settings' in the application and 'Download models'. You can then select the model you want from the dropdown menu.
73
+
74
+ To change the katago binary, e.g. to the Eigen/CPU version if you don't have a GPU, click 'Download KataGo versions'.
75
+ You can then select the KataGo binary from the dropdown menu.
76
+ There are also CUDA and TensorRT versions available on [the KataGo release site](https://github.com/lightvector/KataGo/releases). Particularly the latter may offer much better performance on NVIDIA GPUs, but will be harder to
77
+ set up: [see here for more details](https://github.com/lightvector/KataGo#opencl-vs-cuda-vs-tensorrt-vs-eigen).
78
+
79
+ Finally, you can override the entire command used to start the analysis engine, which
80
+ can be useful for connecting to a remote server. Do keep in mind that KaTrain uses the *analysis engine*
81
+ of KataGo, and not the GTP engine.
82
+
83
+
84
+ ## <a name="ai"></a> Play against AI
85
+
86
+ * Select the players in the main menu, or under 'New Game'.
87
+ * In a teaching game, KaTrain will analyze your moves and automatically undo those that are sufficiently bad.
88
+ * When playing against AI, note that the "Undo" button will undo both the AI's last move and yours.
89
+
90
+ ### Instant feedback
91
+
92
+ The dots on the move indicate how many points were lost by that move.
93
+
94
+ * The colour indicates the size of the mistake according to KataGo
95
+ * The size indicates if the mistake was actually punished. Going from fully punished at maximal size,
96
+ to no actual effect on the score at minimal size.
97
+
98
+ In short, if you are a weaker player you should mostly focus on large dots that are red or purple,
99
+ while stronger players can pay more attention to smaller mistakes. If you want to hide some colours
100
+ on the board, or not output details for them in SGFs,you can do so under 'Configure Teacher'.
101
+
102
+ ### AIs
103
+
104
+ This section describes the available AIs.
105
+
106
+ In the 'AI settings', settings which have been tested and calibrated are at the top and have a lighter color,
107
+ changing these will show an estimate of rank.
108
+ This estimate should be reasonably accurate as long as you have not changed the other settings.
109
+
110
+ * Recommended options for serious play include:
111
+ * **KataGo** is full KataGo, above professional level. The analysis and feedback given is always based on this full strength KataGo AI.
112
+ * **Calibrated Rank Bot** was calibrated on various bots (e.g. GnuGo and Pachi at different strength settings) to play a balanced
113
+ game from the opening to the endgame without making serious (DDK) blunders. Further discussion can be found
114
+ [here](http://github.com/sanderland/katrain/issues/44) and [here](http://github.com/sanderland/katrain/issues/74).
115
+ * **Simple Style** Prefers moves that solidify both player's territory, leading to relatively simpler moves.
116
+ * Legacy options which were developed earlier include:
117
+ * **ScoreLoss** is KataGo analyzing as usual, but
118
+ choosing from potential moves depending on the expected score loss, leading to a varied style with mostly small mistakes.
119
+ * **Policy** uses the top move from the policy network (it's 'shape sense' without reading).
120
+ * **Policy Weighted** picks a random move weighted by the policy, leading to a varied style with mostly small mistakes, and occasional blunders due to a lack of reading.
121
+ * **Blinded Policy** picks a number of moves at random and play the best move among them, being effectively 'blind' to part of the board each turn. Calibrated rank is based on the same idea, and recommended over this option.
122
+ * Options that are more on the 'fun and experimental' side include:
123
+ * Variants of **Blinded Policy**, which use the same basic strategy, but with a twist:
124
+ * **Local Style** will consider mostly moves close to the last move.
125
+ * **Tenuki Style** will consider mostly moves away from the last move.
126
+ * **Influential Style** will consider mostly 4th+ line moves, leading to a center-oriented style.
127
+ * **Territory Style** is biased in the opposite way, towards 1-3rd line moves.
128
+ * **KataJigo** is KataGo attempting to win by 0.5 points, typically by responding to your mistakes with an immediate mistake of it's own.
129
+ * **KataAntiMirror** is KataGo assuming you are playing mirror go and attempting to break out of it with profit as long as you are.
130
+
131
+ The Engine based AIs (KataGo, ScoreLoss, KataJigo) are affected by both the model and choice of visits and maximum time,
132
+ while the policy net based AIs are affected by the choice of model file, but work identically with 1 visit.
133
+
134
+ Further technical details and discussion on some of these AIs can be found on [this](http://lifein19x19.com/viewtopic.php?f=10&t=17488&sid=b11e42c005bb6f4f48c83771e6a27eff) thread at the life in 19x19 forums.
135
+
136
+ ## <a name="analysis"></a> Analysis
137
+
138
+ Analysis options in KaTrain allow you to explore variations and request more in-depth analysis from the engine at any point in the game.
139
+
140
+ Keyboard shortcuts are shown with **[key]**.
141
+
142
+ * **[Tab]**: Switch between analysis and play modes.
143
+ * AI moves, teaching mode and timers are suspended in analysis mode.
144
+ * The state of the analysis options and right-hand side panels and options is saved independently for 'play' and 'analyze',
145
+ allowing you to quickly switch between a more minimalistic 'play' mode and more complex 'analysis' mode.
146
+
147
+ * The checkboxes at the top of the screen:
148
+ * **[q]**: Child moves are shown. On by default, can turn it off to avoid obscuring other information or when
149
+ wanting to guess the next move.
150
+ * **[w]**: Show all dots: Toggles showing coloured evaluation 'dots' on the last few moves or not.
151
+ * You can configure the thresholds, along with how many of the last moves they are shown for under 'Teaching/Analysis Settings'.
152
+ * **[e]**: Top moves: Show the next moves KataGo considered, colored by their expected point loss.
153
+ Small/faint dots indicate high uncertainty and never show text (lower than your 'fast visits' setting).
154
+ Hover over any of them to see the principal variation.
155
+ * **[r]**: Policy moves: Show KataGo's policy network evaluation, i.e. where it thinks the best next move is purely from the position,
156
+ and in the absence of any 'reading'. This turns off the 'top moves' setting as the overlap is often not useful.
157
+ * **[t]**: Expected territory: Show expected ownership of each intersection.
158
+
159
+ * The analysis options available under the 'Analysis' button are used for deeper evaluation of the position:
160
+ * **[a]**: Deeper analysis: Re-evaluate the position using more visits, usually resulting in a more accurate evaluation.
161
+ * **[s]**: Equalize visits: Re-evaluate all currently shown next moves with the same visits as the current top move. Useful to increase confidence in the suggestions with high uncertainty.
162
+ * **[d]**: Analyze all moves: Evaluate all possible next moves. This can take a bit of time even though 'fast_visits' is used, but can be useful to see how many reasonable next moves are available.
163
+ * **[f]**: Find alternatives: Increases analysis of current candidate moves to at least the 'fast visits' level, and request a new query that excludes all current candidate moves.
164
+ * **[g]**: Select area of interest: set an area and search only for moves in this box.
165
+ Good for solving tsumegos. Note that some results may appear outside the box due to establishing a baseline for the best move,
166
+ and the opponent can tenuki in variations.
167
+ * **[h]**: Reset analysis. This reverts the analysis to what the engine returns after a normal query, removing any additional exploration.
168
+ * **[i]**: Start insertion mode. Allows you to insert moves, to improve analysis when both players ignore an important exchange or life and death situation. Press again to stop inserting and copy the rest of the branch.
169
+ * **[l]**: Play out the game until the end and add as a collapsed branch, to visualize the potential effect of mistakes. This is done in the background, and can be started at several nodes at once when comparing the results at different starting positions.
170
+ * **[spacebar]**: Turn continuous analysis on/off. This will continuously improve analysis of the current position, similar to Lizzie's 'pondering', but only when there are no other queries going on.
171
+ * **[shift+spacebar]**: As above, but does not turn 'top moves' hints on when it is off.
172
+ * **[enter]** AI move. Makes the AI move for the current player regardless of current player selection.
173
+ * **[F2]**: Deeper full game analysis. Analyze the entire game to a higher number of visits.
174
+ * **[F3]**: Performance report. Show an overview of performance statistics for both players.
175
+ * **[F10]**: Tsumego Frame. After placing a life and death problem in a corner/side, use this to fill up the rest of the board to improve AI's ability in solving life and death problems.
176
+
177
+
178
+ ## <a name="keyboard"></a> Keyboard and mouse shortcuts
179
+
180
+ In addition to shortcuts mentioned above and those shown in the main menu:
181
+
182
+ * **[Alt]**: Open the main menu.
183
+ * **[~]** or **[ ` ]** or **[F12]**: Cycles through more minimalistic UI modes.
184
+ * **[k]**: Toggle display of board coordinates.
185
+ * **[p]**: Pass
186
+ * **[pause]**: Pause/Resume timer
187
+ * **[arrow left]** or **[z]**: Undo move. Hold shift for 10 moves at a time, or ctrl to skip to the start.
188
+ * **[arrow right]** or **[x]**: Redo move. Hold shift for 10 moves at a time, or ctrl to skip to the end.
189
+ * **[arrow up/down]** Switch branch, as would be expected from the move tree.
190
+ * **[home/end]** Go to the beginning/end of the game.
191
+ * **[pageup]** Make the currently selected node the main branch
192
+ * **[Ctrl-delete]** Delete current node.
193
+ * **[c]** Collapse/Uncollapse the branch from the current node to the previous branching point.
194
+ * **[b]** Go back to the previous branching point.
195
+ * **[Shift-b]** Go back the main branch.
196
+ * **[n]** As in clicking the forward red arrow, go to one move before the next mistake (orange or worse) by a human player.
197
+ * **[Shift-n]** As in clicking the backward red arrow, go to one move before the previous mistake.
198
+ * **[scroll mouse]**:
199
+ * When hovering the cursor over the right panel: Redo/Undo move.
200
+ * When hovering over a candidate move: Scroll through principal variation.
201
+ * **[middle/scroll wheel click]**: Add principal variation to the move tree. When scrolling, only moves up to the point you are viewing are added.
202
+ * **[click on a move]**: See detailed statistics for a previous move, along with expected variation that was best instead of this move.
203
+ * **[double-click on a move]**: Navigate directly to just before that point in the game.
204
+ * **[Ctrl-V]**: Load SGF from the clipboard and do a 'fast' analysis of the game (with a high priority normal analysis for the last move).
205
+ * **[Ctrl-C]**: Save SGF to clipboard.
206
+ * **[Escape]**: Stop all analysis.
207
+
208
+ ## <a name="distributed"></a> Contributing to distributed training
209
+ Starting in December 2020, KataGo started [distributed training](https://katagotraining.org/).
210
+ This allows people to all help generate self-play games to increase KataGo's strength and train bigger models.
211
+
212
+ KaTrain 1.8.0+ makes it easy to contribute to distributed training: simply select the option from the main menu, register an account, and click run.
213
+ During this mode you can do little more than watch games.
214
+
215
+ Keep in mind that partial games are not uploaded,
216
+ so it is best to plan to keep it running for at least an hour, if not several, for the most effective contribution.
217
+
218
+ A few keyboard shortcuts have special functions in this mode:
219
+
220
+ * **[Spacebar]** Switch between manually navigating the current game, and automatically advancing it.
221
+ * **[Escape]**: This sends the `quit` command to KataGo, which starts a slow shutdown, finishing partial games but not starting new ones. Only works on v1.11+.
222
+ * **[Pause]**: Pauses/resumes contributions via the `pause` and `resume` commands introduced in KataGo v1.11.
223
+
224
+
225
+ ## <a name="themes"></a> Themes
226
+
227
+ See [these instructions](THEMES.md) for how to modify the look of any graphics or colours, and creating or install themes.
228
+
229
+ ## <a name="faq"></a> FAQ
230
+
231
+ * The program is running too slowly. How can I speed it up?
232
+ * Adjust the number of visits or maximum time allowed in the settings.
233
+ * KataGo crashes with "out of memory" errors, how can I prevent this?
234
+ * Try using a lower number for `nnMaxBatchSize` in `KataGo/analysis_config.cfg`, and avoid using versions compiled with large board sizes.
235
+ * If still encountering problems, please start KataGo by itself to check for any errors it gives.
236
+ * Note that if you don't have a GPU, or your GPU does not support OpenCL, you should use the 'eigen' binaries which run on CPU only.
237
+ * The font size is too small
238
+ * On some ultra-high resolution monitors, dialogs and other elements with text can appear too small. Please see [these](https://github.com/sanderland/katrain/issues/359#issuecomment-784096271) instructions to adjust them.
239
+ * The app crashes with an error about "unable to find any valuable cutbuffer provider"
240
+ * Install xclip using `sudo apt-get install xclip`
241
+
242
+
243
+ ## <a name="support"></a> Support / Contribute
244
+
245
+ [![GitHub issues](http://img.shields.io/github/issues/sanderland/katrain)](http://github.com/sanderland/katrain/issues)
246
+ [![Contributors](http://img.shields.io/static/v1?label=contributors&message=<3&color=dcb424)](CONTRIBUTIONS.md)
247
+
248
+ * Ideas, feedback, and contributions to code or translations are all very welcome.
249
+ * For suggestions and planned improvements, see [open issues](http://github.com/sanderland/katrain/issues) on github to check if the functionality is already planned.
250
+ * You can join the [Computer Go Community Discord (formerly Leela Zero & Friends)](http://discord.gg/AjTPFpN) (use the #gui channel) to get help, discuss improvements, or simply show your appreciation. Please do not use github issues to ask for technical help, this is only for bugs, suggestions and discussing contributions.
251
+
252
+
253
+
katrain/THEMES.md ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Themes
2
+
3
+ Version 1.7 brings basic support for themes, and 1.9 extends it to include keyboard shortcuts and support for multiple theme files.
4
+
5
+ ## Creating and editing themes
6
+
7
+ * Look at the `Theme` class in [`katrain/gui/theme.py`](https://github.com/sanderland/katrain/blob/master/katrain/gui/theme.py).
8
+ * Make a `theme<yourthemename>.json` file in your `<home dir>/.katrain` directory and specify any variables from the above class you want to override, e.g.
9
+ ```json
10
+ {
11
+ "BACKGROUND_COLOR": [1,0,0,1],
12
+ "KEY_STOP_ANALYSIS": "f10",
13
+ "MISTAKE_SOUNDS": ["jeff.wav","what.wav"]
14
+ }
15
+ ```
16
+ * All resources (including icons, which can not be renamed for now) will be looked up in `<home dir>/.katrain` first, so files with identical names there can be used to override sounds and images.
17
+ * If variables are specified in multiple theme files, the *latest* alphabetically takes precedence. That is, each later theme file overwrites the settings from any previous one.
18
+
19
+ ## Expected territory options
20
+
21
+ * KaTrain supports different styles of display of expected territory:
22
+ * Blended style colors the board with an intensity proportional to the likelihood of a player controlling that territory at the end of the game.
23
+ * Shaded style behaves the same as Blended, but uses square shades similar to
24
+ the Katago paper.
25
+ * In the Marks style, each point of the board is marked with a square of size which is proportional to ownership likelihood.
26
+ * The Blocks style divides the whole board into black, white, and neutral territory, based on a likelihood threshold. This style is appropriate as a counting aid, but may be misleading before endgame if much of the territory is unsettled.
27
+ * Marks can also appear on stones to indicate the likelihood of these stones living at the end of the game. Three styles are supported:
28
+ * All stones can be marked, with the color of the mark indicating the expected ownership and the size of the mark indicating certainty.
29
+ * Weak stones only - marks will appear only on stones which are over 50% likely to die before the end of the game.
30
+ * No stone marks.
31
+ * Stones can also be made transparent based on their strength.
32
+
33
+ | <img src="./themes/blended-all.png" width="400"/> <br> Blended style, all stones marked| <img src="./themes/shaded-all.png" width="400"/> <br> Shaded style, all stones marked |
34
+ | --- | ---|
35
+ | <img src="./themes/blocks-none.png" width="400"/> <br> Territory blocks, no stones marked | <img src="./themes/blended-weak.png" width="400"/> <br> Blended territory, weak stones marked |
36
+ | <img src="./themes/marks-weak.png" width="400"/> <br> Marks on intersections, weak stones marked | <img src="./themes/shaded-no-alpha.png" width="400"/> <br> Shaded, no stone alpha |
37
+
38
+
39
+ <sup>The game used in the screenshots is [Albert Yen vs. Eric Yoder](https://www.usgo.org/news/2022/03/members-edition-midwest-open-round-2-the-broken-ladder-game).</sup>
40
+
41
+ The stone marks, transparency, and territory style are independent; the table above presents a collection of possible variants.
42
+ The relevant variables are:
43
+ ```
44
+ TERRITORY_DISPLAY = "blended" | "shaded" | "marks" | "blocks"
45
+ STONE_MARKS = "all" | "weak" | "none"
46
+ OWNERSHIP_COLORS = {"B": [0.0, 0.0, 0.10, 0.75], "W": [0.92, 0.92, 1.0, 0.800]}
47
+ BLOCKS_THRESHOLD = 0.6
48
+ MARK_SIZE = 0.42 # as fraction of stone size
49
+ STONE_MIN_ALPHA = 0.5
50
+ ```
51
+
52
+ The colors are specified as RGB values and a maximum alpha transparency.
53
+
54
+ ## Installation
55
+
56
+ * To install a theme, simply unzip the theme.zip to your .katrain folder.
57
+ * On Windows you can find it in C:\Users\you\\.katrain and on linux in ~/.katrain.
58
+ * When in doubt, the general settings dialog will also show the location.
59
+ * To uninstall a theme, remove theme.json and all relevant images from that folder.
60
+
61
+ ## Available themes
62
+
63
+ ### Alternate board/stones theme by "koast"
64
+
65
+ [Download](https://github.com/sanderland/katrain/blob/master/themes/koast-theme.zip)
66
+
67
+ ![Preview](https://raw.githubusercontent.com/sanderland/katrain/master/themes/koast.png)
68
+
69
+ ### Lizzie-like theme
70
+
71
+ * Theme created by Eric W, includes modified board, stones
72
+ * Images taken from [Lizzie](https://github.com/featurecat/lizzie/) by featurecat and contributors.
73
+ * Hides hints for low visit/uncertain moves instead of showing small dots.
74
+
75
+ [Download](https://github.com/sanderland/katrain/blob/master/themes/eric-lizzie-look.zip)
76
+
77
+ ![Preview](https://raw.githubusercontent.com/sanderland/katrain/master/themes/eric-lizzie.png)
78
+
79
+
80
+ ### Jeff sounds
81
+
82
+ * This theme makes Jeff comment `Ahhh?` and `What?!` when you make mistakes.
83
+ * Sounds provided by Mikkgo.
84
+
85
+ [Download](https://github.com/sanderland/katrain/blob/master/themes/jeff-sounds.zip)
86
+
katrain/__main__.spec ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- mode: python ; coding: utf-8 -*-
2
+
3
+
4
+ a = Analysis(
5
+ ['katrain\\__main__.py'],
6
+ pathex=[],
7
+ binaries=[],
8
+ datas=[],
9
+ hiddenimports=[],
10
+ hookspath=[],
11
+ hooksconfig={},
12
+ runtime_hooks=[],
13
+ excludes=[],
14
+ noarchive=False,
15
+ optimize=0,
16
+ )
17
+ pyz = PYZ(a.pure)
18
+
19
+ exe = EXE(
20
+ pyz,
21
+ a.scripts,
22
+ a.binaries,
23
+ a.datas,
24
+ [],
25
+ name='__main__',
26
+ debug=False,
27
+ bootloader_ignore_signals=False,
28
+ strip=False,
29
+ upx=True,
30
+ upx_exclude=[],
31
+ runtime_tmpdir=None,
32
+ console=False,
33
+ disable_windowed_traceback=False,
34
+ argv_emulation=False,
35
+ target_arch=None,
36
+ codesign_identity=None,
37
+ entitlements_file=None,
38
+ )
katrain/__pycache__/board_ai.cpython-310.pyc ADDED
Binary file (9.52 kB). View file
 
katrain/__pycache__/engine_ai.cpython-310.pyc ADDED
Binary file (23.9 kB). View file
 
katrain/__pycache__/hongik_ai.cpython-310.pyc ADDED
Binary file (13.9 kB). View file
 
katrain/fonts/Roboto-Black.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b6a38ddfb6b7d92a644da3a175cab3858438b3c791486aeeca2094a611430f27
3
+ size 142472
katrain/fonts/Roboto-BlackItalic.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e57070129b2845c7684675491c305fc9cd75d801a2812deb154f1077016cea54
3
+ size 149644
katrain/fonts/Roboto-Bold.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9287925cae90ac480804094ff0876832065e2db116470da1f524d79ed9c18b70
3
+ size 135820
katrain/fonts/Roboto-BoldItalic.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2d998c92d5478dafabe3902ec6521b7ca6a2d7dca9251607553962538ec22947
3
+ size 144700
katrain/fonts/Roboto-Italic.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d24529b2a332a23bc226a43a15f8c185c5af52502cca5e9dee7f9896bf7cd383
3
+ size 148540
katrain/fonts/Roboto-Light.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b17667ce7e13581db105777f986e141168231e88a8ef16d13e581c7c1525f14b
3
+ size 140276
katrain/fonts/Roboto-LightItalic.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4cadcfdd708e1aee7625c1e66cb80d2e44ba61e2e54d76bc60935fcfc1e5ed88
3
+ size 145932
katrain/fonts/Roboto-Medium.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d0c8f44a774b8490ceee29889cdabc72381fa35fb621619a78fd28211d90241c
3
+ size 137308
katrain/fonts/Roboto-MediumItalic.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fd3c714c2e39b1a5dbff6eb24157adfa3f277fa5293cafbf1a0074ad54b094d4
3
+ size 147876
katrain/fonts/Roboto-Regular.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dbd285b518e398832f6f4a736109c355ce25a49546bfce41bab256c9ef7e56eb
3
+ size 146004
katrain/fonts/Roboto-Thin.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:11dbd8bf4f8c61d665f4f3157027b9643db2454d5d84daffbe6385d70e8bf131
3
+ size 130044
katrain/fonts/Roboto-ThinItalic.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e7f436499c79fa18381468afe4b80690a59c0bd635e72f63190023d11bf17a1d
3
+ size 132376
katrain/i18n.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import copy
2
+ import glob
3
+ import os
4
+ import re
5
+ import sys
6
+ from collections import defaultdict
7
+
8
+ import polib
9
+
10
+ localedir = "katrain/i18n/locales"
11
+ locales = set(os.listdir(localedir))
12
+ print("locales found:", locales)
13
+
14
+ strings_to_langs = defaultdict(dict)
15
+ strings_to_keys = defaultdict(dict)
16
+ lang_to_strings = defaultdict(set)
17
+
18
+ DEFAULT_LANG = "en"
19
+ INACTIVE_LANGS = ["es"]
20
+ errors = False
21
+
22
+ po = {}
23
+ pofile = {}
24
+ todos = defaultdict(list)
25
+
26
+ for lang in locales:
27
+ if lang in INACTIVE_LANGS:
28
+ continue
29
+ pofile[lang] = os.path.join(localedir, lang, "LC_MESSAGES", "katrain.po")
30
+ po[lang] = polib.pofile(pofile[lang])
31
+ for entry in po[lang].translated_entries():
32
+ if "TODO" in entry.comment and "DEPRECATED" not in entry.comment:
33
+ todos[lang].append(entry)
34
+ strings_to_langs[entry.msgid][lang] = entry
35
+ strings_to_keys[entry.msgid][lang] = set(re.findall("{.*?}", entry.msgstr))
36
+ if entry.msgid in lang_to_strings[lang]:
37
+ print("duplicate", entry.msgid, "in", lang, "--> deleting", entry.msgstr)
38
+ errors = True
39
+ po[lang].remove(entry)
40
+ else:
41
+ lang_to_strings[lang].add(entry.msgid)
42
+ if todos[lang] and any("todo" in a for a in sys.argv):
43
+ print(f"========== {lang} has {len(todos[lang])} TODO entries ========== ")
44
+ for item in todos[lang]:
45
+ print(item)
46
+
47
+
48
+ for lang in locales:
49
+ if lang in INACTIVE_LANGS:
50
+ continue
51
+ if lang != DEFAULT_LANG:
52
+ for msgid in lang_to_strings[lang]:
53
+ if (
54
+ DEFAULT_LANG in strings_to_keys[msgid]
55
+ and strings_to_keys[msgid][lang] != strings_to_keys[msgid][DEFAULT_LANG]
56
+ ):
57
+ print(
58
+ f"{msgid} has inconstent formatting keys for {lang}: ",
59
+ strings_to_keys[msgid][lang],
60
+ "is different from default",
61
+ strings_to_keys[msgid][DEFAULT_LANG],
62
+ )
63
+ errors = True
64
+
65
+ for msgid in strings_to_langs.keys() - lang_to_strings[lang]:
66
+ if lang == DEFAULT_LANG:
67
+ print("Message id", msgid, "found as ", strings_to_langs[msgid], "but missing in default", DEFAULT_LANG)
68
+ errors = True
69
+ elif DEFAULT_LANG in strings_to_langs[msgid]:
70
+ copied_entry = copy.copy(strings_to_langs[msgid][DEFAULT_LANG])
71
+ print("Message id", msgid, "missing in ", lang, "-> Adding it from", DEFAULT_LANG)
72
+ if copied_entry.comment:
73
+ copied_entry.comment = f"TODO - {copied_entry.comment}"
74
+ else:
75
+ copied_entry.comment = "TODO"
76
+ po[lang].append(copied_entry)
77
+ errors = True
78
+ else:
79
+ print(f"MISSING IN DEFAULT AND {lang}", msgid)
80
+ errors = True
81
+
82
+ for msgid, lang_entries in strings_to_langs.items():
83
+ if lang in lang_entries and "TODO" in lang_entries[lang].comment:
84
+ if any(e.msgstr == lang_entries[lang].msgstr for ll, e in lang_entries.items() if ll != lang):
85
+ if lang_entries.get(DEFAULT_LANG):
86
+ todo_comment = (
87
+ f"TODO - {lang_entries[DEFAULT_LANG].comment}" if lang_entries[DEFAULT_LANG].comment else "TODO"
88
+ ) # update todo
89
+ if (
90
+ lang_entries[lang].msgstr != lang_entries[DEFAULT_LANG].msgstr
91
+ or lang_entries[lang].comment.replace("\n", " ") != todo_comment
92
+ ):
93
+ print(
94
+ [
95
+ lang_entries[lang].msgstr,
96
+ lang_entries[DEFAULT_LANG].msgstr,
97
+ lang_entries[lang].comment,
98
+ todo_comment,
99
+ ]
100
+ )
101
+ lang_entries[lang].msgstr = lang_entries[DEFAULT_LANG].msgstr # update
102
+ lang_entries[lang].comment = todo_comment
103
+ print(f"{lang}/{msgid} todo entry updated")
104
+
105
+ po[lang].save(pofile[lang])
106
+ mofile = pofile[lang].replace(".po", ".mo")
107
+ po[lang].save_as_mofile(mofile)
108
+ print("Fixed", pofile[lang], "and converted ->", mofile)
109
+
110
+
111
+ for ext in ["py", "kv"]:
112
+ lc = 0
113
+ for file in glob.glob(f"katrain/*.{ext}") + glob.glob(f"katrain/**/*.{ext}"):
114
+ with open(file, "r") as f:
115
+ for i, line in enumerate(f.readlines()):
116
+ if line.strip():
117
+ lc += 1
118
+ matches = [m.strip() for m in re.findall(r"i18n._\((.*?)\)", line)]
119
+ for msgid in matches:
120
+ stripped_msgid = msgid.strip("\"'")
121
+ if stripped_msgid and msgid[0] in ['"', "'"] and stripped_msgid not in strings_to_langs: # not code
122
+ print(f"Missing {msgid} used in code at \t{file}:{i} \t'{line.strip()}'")
123
+ errors += 1
124
+ print(f"Checked {lc} lines of {ext} code for missing i18n entries.")
125
+ sys.exit(int(errors))
katrain/katrain.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # for backward compatibility
2
+ from katrain.__main__ import run_app
3
+
4
+ run_app()
katrain/katrain/__init__.py ADDED
File without changes
katrain/katrain/__main__.py ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Main entry point for the HongikAI-KaTrain application.
2
+ # It initializes the Kivy/KaTrain GUI and integrates the custom HongikAIEngine.
3
+ #
4
+ # Author: Gemini 2.5 Pro, Gemini 2.5 Flash
5
+
6
+ import os
7
+ import sys
8
+ import signal
9
+ import threading
10
+ import time
11
+ import traceback
12
+ import random
13
+ from queue import Queue
14
+ import json
15
+
16
+ project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
17
+
18
+ if project_root not in sys.path:
19
+ sys.path.insert(0, project_root)
20
+
21
+ os.environ["KCFG_KIVY_LOG_LEVEL"] = os.environ.get("KCFG_KIVY_LOG_LEVEL", "warning")
22
+ import kivy
23
+ kivy.require("2.0.0")
24
+ from kivy.app import App
25
+ from kivy.base import ExceptionHandler, ExceptionManager
26
+ from kivy.lang import Builder
27
+ from kivy.resources import resource_add_path
28
+ from kivy.uix.screenmanager import Screen
29
+ from kivy.core.window import Window
30
+ from kivy.properties import ObjectProperty, StringProperty, NumericProperty, BooleanProperty
31
+ from kivy.clock import Clock
32
+ from kivy.config import Config
33
+ from kivymd.app import MDApp
34
+
35
+ from katrain.core.utils import find_package_resource, PATHS
36
+ from katrain.core.base_katrain import KaTrainBase
37
+ from katrain.core.lang import DEFAULT_LANGUAGE, i18n
38
+ from katrain.core.constants import *
39
+ from katrain.core.game import Game, KaTrainSGF, IllegalMoveException
40
+ from katrain.core.sgf_parser import Move, ParseError
41
+ from katrain.gui.theme import Theme
42
+
43
+ import pygame
44
+
45
+ from hongik.board_ai import Board
46
+ from hongik.engine_ai import HongikAIEngine
47
+
48
+ from katrain.gui.kivyutils import *
49
+ from katrain.gui.widgets import MoveTree
50
+ from katrain.gui.badukpan import BadukPanWidget
51
+ from katrain.gui.controlspanel import ControlsPanel
52
+
53
+ if 'USER' not in PATHS:
54
+ USER_DATA_PATH = os.path.expanduser(os.path.join("~", ".katrain"))
55
+ os.makedirs(USER_DATA_PATH, exist_ok=True)
56
+ PATHS['USER'] = USER_DATA_PATH
57
+
58
+ ICON = find_package_resource("katrain/img/icon.ico")
59
+ Config.set("kivy", "window_icon", ICON)
60
+ Config.set("input", "mouse", "mouse,multitouch_on_demand")
61
+ SOUNDS_DIR = find_package_resource("katrain/sounds")
62
+
63
+ class KaTrainGui(Screen, KaTrainBase):
64
+ """
65
+ The main GUI class for the application. It inherits from Kivy's Screen and
66
+ KaTrainBase, managing all visual components and user interactions.
67
+ """
68
+ zen = NumericProperty(0)
69
+ controls = ObjectProperty(None); engine = ObjectProperty(None); game = ObjectProperty(None)
70
+ board_gui = ObjectProperty(None); board_controls = ObjectProperty(None); play_mode = ObjectProperty(None)
71
+ show_move_numbers = BooleanProperty(False)
72
+ analysis_controls = ObjectProperty(None)
73
+
74
+ @property
75
+ def play_analyze_mode(self):
76
+ return self.play_mode.mode
77
+
78
+ def __init__(self, **kwargs):
79
+ """Initializes the GUI, linking it to the main app and setting up necessary variables."""
80
+ self.katrain_app = kwargs.get('katrain_app')
81
+ self.engine, self.message_queue, self.pondering = None, Queue(), False
82
+ self.contributing, self.animate_contributing = False, False
83
+ super().__init__(**kwargs)
84
+
85
+ def config_set(self, section, option, value):
86
+ """Sets a configuration value and writes it to the config file."""
87
+ self.katrain_app.config.set(section, option, value)
88
+ self.katrain_app.config.write()
89
+
90
+ def save_config(self, sections=None):
91
+ """Writes the current configuration to disk."""
92
+ self.katrain_app.config.write()
93
+
94
+ def play_sound(self):
95
+ """Randomly plays a stone placement sound from the sounds directory."""
96
+ try:
97
+ sound_files = [f for f in os.listdir(SOUNDS_DIR) if f.startswith('stone') and f.endswith(('.wav', '.ogg'))]
98
+ if sound_files:
99
+ sound_to_play = random.choice(sound_files)
100
+ pygame.mixer.Sound(os.path.join(SOUNDS_DIR, sound_to_play)).play()
101
+ except pygame.error as e:
102
+ print(f"Pygame sound playback error: {e}")
103
+
104
+ def start(self):
105
+ """
106
+ Starts the main application logic, initializes the AI engine, starts the
107
+ message loop, and creates a new game.
108
+ """
109
+ if self.engine: return
110
+ self.board_gui.trainer_config = self.config("trainer")
111
+ self.engine = HongikAIEngine(self, self.config("engine"))
112
+ threading.Thread(target=self._message_loop_thread, daemon=True).start()
113
+ self._do_new_game()
114
+ Clock.schedule_interval(self.handle_animations, 0.1)
115
+ Window.request_keyboard(None, self, "").bind(on_key_down=self._on_keyboard_down)
116
+
117
+ def update_player(self, bw, **kwargs):
118
+ """Updates the information and type for a given player (Black or White)."""
119
+ player_type = kwargs.get('player_type')
120
+ if player_type == PLAYER_AI:
121
+ self.players_info[bw].player_type, self.players_info[bw].player_subtype = PLAYER_AI, "홍익 AI"
122
+ self.players_info[bw].sgf_rank = ""
123
+ self.players_info[bw].calculated_rank = ""
124
+ elif player_type == PLAYER_HUMAN:
125
+ self.players_info[bw].player_type, self.players_info[bw].player_subtype = PLAYER_HUMAN, "Human"
126
+
127
+ self.players_info[bw].periods_used = 0
128
+ self.players_info[bw].being_taught = False
129
+ self.players_info[bw].player = bw
130
+ if self.game: self.players_info[bw].name = self.game.root.get_property("P" + bw)
131
+ if self.controls: self.controls.update_players(); self.update_state()
132
+
133
+ def update_gui(self, cn, redraw_board=False):
134
+ """Updates all GUI elements with the latest game state information."""
135
+ if not self.game: return
136
+ prisoners = self.game.prisoner_count
137
+ self.controls.players["B"].captures, self.controls.players["W"].captures = prisoners.get("W", 0), prisoners.get("B", 0)
138
+ if not self.engine or not self.engine.is_idle(): self.board_controls.engine_status_col = Theme.ENGINE_BUSY_COLOR
139
+ else: self.board_controls.engine_status_col = Theme.ENGINE_READY_COLOR
140
+ if redraw_board: self.board_gui.draw_board()
141
+ self.board_gui.redraw_board_contents_trigger()
142
+ self.controls.update_evaluation(); self.controls.move_tree.current_node = self.game.current_node
143
+
144
+ def update_state(self, redraw_board=False):
145
+ """A shortcut to send an 'update-state' message to the message queue."""
146
+ self("update-state", redraw_board=redraw_board)
147
+
148
+ def _message_loop_thread(self):
149
+ """
150
+ The main message loop that runs in a separate thread, processing commands
151
+ from the message queue to avoid blocking the GUI.
152
+ """
153
+ while True:
154
+ game_id, msg, args, kwargs = self.message_queue.get()
155
+ try:
156
+ if self.game and game_id != self.game.game_id: continue
157
+ fn = getattr(self, f"_do_{msg.replace('-', '_')}")
158
+ fn(*args, **kwargs)
159
+ if msg != "update_state": self._do_update_state()
160
+ except Exception as exc:
161
+ self.log(f"Message loop exception: {exc}", OUTPUT_ERROR); traceback.print_exc()
162
+
163
+ def __call__(self, message, *args, **kwargs):
164
+ """Adds a message to the thread-safe message queue for processing."""
165
+ if message.endswith("popup"): Clock.schedule_once(lambda _dt: getattr(self, f"_do_{message.replace('-', '_')}")(*args, **kwargs), -1)
166
+ else: self.message_queue.put([self.game.game_id if self.game else None, message, args, kwargs])
167
+
168
+ def _do_update_state(self, redraw_board=False):
169
+ """
170
+ Handles the 'update-state' message, refreshing the GUI to reflect the
171
+ current game state, player turn, and engine status.
172
+ """
173
+ if not self.game or not self.game.current_node: return
174
+
175
+ if self.controls:
176
+ self.controls.update_players()
177
+ next_player_is = self.game.current_node.next_player
178
+ self.controls.active_player = self.game.current_node.next_player
179
+
180
+ self.controls.players['B'].active = (next_player_is == 'B')
181
+ self.controls.players['W'].active = (next_player_is == 'W')
182
+
183
+ is_game_active = self.game and not self.game.end_result
184
+ is_game_over = not is_game_active
185
+
186
+ if self.board_gui.game_is_over != is_game_over:
187
+ self.board_gui.game_is_over = is_game_over
188
+ if is_game_over:
189
+ self.board_gui.game_over_message = "Game Over"
190
+
191
+ is_ai_vs_ai = (self.players_info['B'].player_type == PLAYER_AI and self.players_info['W'].player_type == PLAYER_AI)
192
+
193
+ if self.controls and self.controls.ids.get('undo_button'):
194
+ self.controls.ids.undo_button.disabled = not is_game_active or is_ai_vs_ai
195
+ self.controls.ids.resign_button.disabled = not is_game_active or is_ai_vs_ai
196
+
197
+ if self.engine and self.pondering:
198
+ self.game.analyze_extra("ponder")
199
+ else:
200
+ self.engine.stop_pondering()
201
+
202
+ Clock.schedule_once(lambda _dt: self.update_gui(self.game.current_node, redraw_board), -1)
203
+ self.engine._game_turn()
204
+
205
+ def _do_play(self, coords):
206
+ """Handles a 'play' event, creating a Move object and playing it on the board."""
207
+ try:
208
+ move = Move(coords, player=self.game.current_node.next_player)
209
+ self.game.play(move)
210
+ self.update_state()
211
+ if not move.is_pass: self.play_sound()
212
+ except IllegalMoveException as e:
213
+ self.controls.set_status(f"Illegal move: {str(e)}", STATUS_ERROR)
214
+
215
+ def _do_new_game(self, player_types=(PLAYER_HUMAN, PLAYER_HUMAN), move_tree=None, sgf_filename=None):
216
+ """Handles a 'new-game' event, setting up a new game with specified players."""
217
+ self.pondering = False
218
+ self.engine.sound_index = False
219
+ if self.engine: self.engine.stop_self_play_loop(); self.engine.on_new_game()
220
+ self.game = Game(self, self.engine, move_tree=move_tree, sgf_filename=sgf_filename)
221
+
222
+ self.board_controls.ids.game_mode_reset_btn.state = 'down'
223
+ self.update_player('B', player_type=player_types[0])
224
+ self.update_player('W', player_type=player_types[1])
225
+ if self.controls and self.controls.graph:
226
+ self.controls.graph.initialize_from_game(self.game.root)
227
+ self.update_state(redraw_board=True)
228
+
229
+ try:
230
+ self.analysis_controls.hamburger.disabled = False
231
+ self.analysis_controls.show_children.disabled =False
232
+ self.analysis_controls.hints.disabled =False
233
+ self.analysis_controls.policy.disabled =False
234
+ self.controls.ids.undo.disabled = False
235
+ self.board_controls.ids.pass_btn.disabled = False
236
+ self.controls.ids.timer.ids.pause.disabled = False
237
+ except Exception as e:
238
+ self.log(f"Error enabling button: {e}", OUTPUT_ERROR)
239
+
240
+
241
+ def _do_start_hongik_selfplay(self):
242
+ """Starts a new self-play game between two Hongik AI instances."""
243
+ self._do_new_game(player_types=(PLAYER_AI, PLAYER_AI))
244
+ self.engine.start_self_play_loop()
245
+
246
+ try:
247
+ self.analysis_controls.hamburger.disabled = True
248
+ self.analysis_controls.show_children.checkbox.active = False
249
+ self.analysis_controls.show_children.disabled =True
250
+ self.analysis_controls.hints.checkbox.active = False
251
+ self.analysis_controls.hints.disabled =True
252
+ self.analysis_controls.policy.checkbox.active = False
253
+ self.analysis_controls.policy.disabled =True
254
+ self.controls.ids.undo.disabled = True
255
+ self.board_controls.ids.pass_btn.disabled = True
256
+ self.controls.ids.timer.ids.pause.disabled = True
257
+ except Exception as e:
258
+ self.log(f"Error enabling hamburger button: {e}", OUTPUT_ERROR)
259
+
260
+ def _do_start_hongik_vshuman(self):
261
+ """Starts a new game between a human player and Hongik AI."""
262
+ self._do_new_game(player_types=(PLAYER_HUMAN, PLAYER_AI))
263
+ try:
264
+ hamburger_button = self.analysis_controls.ids.get('hamburger')
265
+ if hamburger_button:
266
+ hamburger_button.disabled = True
267
+ except Exception as e:
268
+ self.log(f"Error enabling hamburger button: {e}", OUTPUT_ERROR)
269
+
270
+ def _do_undo(self, n_times=1):
271
+ """Handles an 'undo' event, going back a specified number of moves."""
272
+ try: n_times = int(n_times)
273
+ except (ValueError, TypeError): n_times = 1
274
+ self.game.undo(n_times); self.update_state()
275
+
276
+ def _do_resign(self):
277
+ """Handles a 'resign' event, ending the game and resetting the GUI."""
278
+ if self.game:
279
+ winner = 'W' if self.game.current_node.next_player == 'B' else 'B'
280
+ self.game.root.set_property("RE", f"{winner}+Resign")
281
+ try:
282
+ self_play_button = self.board_controls.ids.hongik_selfplay_btn
283
+ vs_human_button = self.board_controls.ids.hongik_vs_human_btn
284
+ self_play_button.state = 'normal'
285
+ vs_human_button.state = 'normal'
286
+ except Exception as e:
287
+ self.log(f"Failed to change button state: {e}", OUTPUT_ERROR)
288
+ self.game = Game(self, self.engine)
289
+ self._do_new_game()
290
+
291
+ def load_sgf_file(self, file_path):
292
+ """Initiates loading of an SGF file in a separate thread."""
293
+ self.controls.set_status(f"Loading SGF file: {os.path.basename(file_path)}", STATUS_INFO)
294
+ threading.Thread(target=self._load_sgf_thread_target, args=(file_path,), daemon=True).start()
295
+
296
+ def _load_sgf_thread_target(self, file_path):
297
+ """The target function for the SGF loading thread."""
298
+ try:
299
+ move_tree = KaTrainSGF.parse_file(os.path.abspath(file_path))
300
+ Clock.schedule_once(lambda dt: self._do_new_game(move_tree=move_tree, sgf_filename=file_path))
301
+ except Exception as e:
302
+ self.log(f"SGF file loading failed: {e}", OUTPUT_ERROR)
303
+ Clock.schedule_once(lambda dt: self.controls.set_status(f"SGF loading failed", STATUS_ERROR))
304
+
305
+ def handle_animations(self, *_args): pass
306
+
307
+ def _on_keyboard_down(self, keyboard, keycode, text, modifiers):
308
+ """Handles keyboard shortcuts, such as toggling move numbers."""
309
+ if not self.game: return True
310
+ key = keycode[1]
311
+ if key == 'n':
312
+ self.show_move_numbers = not self.show_move_numbers
313
+ self.board_gui.redraw_board_contents_trigger()
314
+ return True
315
+ return False
316
+
317
+ def _do_score(self, *_args):
318
+ """Handles a 'score' event, requesting the engine to score the current position."""
319
+ self.board_gui.game_over_message = "Scoring..."
320
+ self.controls.set_status("Scoring...", STATUS_INFO)
321
+ def score_callback(score_details):
322
+ if score_details:
323
+ winner = score_details['winner']
324
+ score = score_details['score']
325
+ self.game.set_result(f"{winner}+{abs(score)}")
326
+ self.update_state()
327
+ else:
328
+ self.controls.set_status("Failed to score the game.", STATUS_ERROR)
329
+ self.engine.request_score(self.game.current_node, score_callback)
330
+
331
+ def _bind_widgets(self, dt):
332
+ """위젯이 모두 생성된 후, 이벤트를 파이썬에서 직접 바인딩합니다."""
333
+ if self.analysis_controls and self.analysis_controls.show_children:
334
+ self.analysis_controls.show_children.checkbox.bind(active=self._handle_show_children_toggle)
335
+
336
+ if self.nav_drawer_contents and 'player_type_spinner_W' in self.nav_drawer_contents.ids:
337
+ self.nav_drawer_contents.ids.player_type_spinner_W.disabled = True
338
+ print("W player type spinner successfully disabled via Python.") # 확인용 로그
339
+
340
+ def on_nav_drawer_close(self):
341
+ """Handles the closing of the navigation drawer, forcing a redraw."""
342
+ self.update_state()
343
+ if self.board_gui:
344
+ self.board_gui.draw_board()
345
+ self.board_gui.redraw_board_contents_trigger()
346
+ self.canvas.ask_update()
347
+
348
+ def _do_contribute_popup(self,*_args):pass
349
+ def _do_config_popup(self, *_args):pass
350
+ def _do_new_game_popup(self,*_args):pass
351
+ def _do_save_game(self,*_args):pass
352
+ def _do_save_game_as_popup(self,*_args):pass
353
+ def _do_analyze_sgf_popup(self,*_args):pass
354
+ def _do_teacher_popup(self,*_args):pass
355
+ def _do_ai_popup(self,*_args):pass
356
+ def _do_timer_popup(self,*_args):pass
357
+
358
+ class KaTrainApp(MDApp):
359
+ """
360
+ The main application class that inherits from KivyMD's MDApp. It builds the
361
+ GUI, manages the configuration, and handles application lifecycle events.
362
+ """
363
+ gui = ObjectProperty(None)
364
+ language = StringProperty(DEFAULT_LANGUAGE, allownone=True)
365
+
366
+ def __init__(self, **kwargs):
367
+ super().__init__(**kwargs)
368
+ self._resize_event = None
369
+
370
+ def build_config(self, config):
371
+ """Sets up the default configuration for the application."""
372
+ if 'SGF' not in PATHS:
373
+ PATHS['SGF'] = os.path.join(PATHS.get('USER', '.'), 'sgf')
374
+ os.makedirs(PATHS['SGF'], exist_ok=True)
375
+ config.setdefaults("general",{"lang": DEFAULT_LANGUAGE, "show_player_rank": True, "last_sgf_directory": PATHS["SGF"],})
376
+ config.setdefaults("engine", {"max_visits": "100"})
377
+
378
+ threshold_str = "-1,0.5,1.5,3,5,7.5,10"
379
+ thresholds_as_floats = [float(v) for v in threshold_str.split(',')]
380
+
381
+ config.setdefaults("trainer", {
382
+ "eval_thresholds": thresholds_as_floats,
383
+ "theme": "theme:normal"
384
+ })
385
+
386
+ config.setdefaults("uistate", {"size": "[1300, 1000]"})
387
+
388
+ def build(self):
389
+ """Builds the application's widget tree and sets up window bindings."""
390
+ pygame.mixer.init()
391
+ self.icon, self.title = ICON, "홍익 AI - KaTrain"
392
+ self.theme_cls.theme_style, self.theme_cls.primary_palette = "Dark", "Gray"
393
+ for p in [os.path.join(PATHS["PACKAGE"], d) for d in ["fonts","sounds","img", "lang"]] + [os.path.abspath(PATHS["USER"])]:
394
+ resource_add_path(p)
395
+ Builder.load_file(find_package_resource("katrain/gui.kv"))
396
+ Builder.load_file(find_package_resource("katrain/popups.kv"))
397
+ Window.bind(on_request_close=self.on_request_close)
398
+ Window.bind(on_dropfile=lambda win, file: self.gui.load_sgf_file(file.decode("utf8")))
399
+ Window.bind(on_resize=self.on_resize)
400
+ self.gui = KaTrainGui(katrain_app=self, config=self.config)
401
+ Window.size = Window.system_size
402
+ return self.gui
403
+
404
+ def on_resize(self, window, width, height):
405
+ """Controls the storm of resize events by debouncing them with a short delay."""
406
+ if self._resize_event:
407
+ self._resize_event.cancel()
408
+ self._resize_event = Clock.schedule_once(self._redraw_all, 0.15)
409
+
410
+ def _redraw_all(self, dt):
411
+ """The actual function that redraws the entire screen after a resize."""
412
+ if self.gui:
413
+ self.gui.update_state(redraw_board=True)
414
+
415
+ def on_start(self):
416
+ """Called when the application is starting."""
417
+ self.language = self.gui.config("general/lang") or DEFAULT_LANGUAGE
418
+ self.gui.start()
419
+ Window.show()
420
+
421
+ def on_language(self, _instance, language):
422
+ """Handles language changes."""
423
+ i18n.switch_lang(language)
424
+ self.gui.config_set("general", "lang", language)
425
+
426
+ def on_request_close(self, *_args, **_kwargs):
427
+ """Handles the window close event, saving the window size and shutting down the engine."""
428
+ if getattr(self, "gui", None):
429
+ size_str = json.dumps([int(d) for d in Window.size])
430
+ self.gui.config_set("uistate", "size", size_str)
431
+ self.gui.save_config("uistate")
432
+ if self.gui.engine: self.gui.engine.shutdown()
433
+
434
+ def signal_handler(self, _signal, _frame):
435
+ """Handles signals like Ctrl+C."""
436
+ self.stop()
437
+
438
+ def run_app():
439
+ """Initializes and runs the application."""
440
+ class CrashHandler(ExceptionHandler):
441
+ def handle_exception(self, inst):
442
+ trace = "".join(traceback.format_tb(sys.exc_info()[2]))
443
+ app = MDApp.get_running_app()
444
+ message = f"Exception {inst.__class__.__name__}: {inst}\n{trace}"
445
+ if app and app.gui: app.gui.log(message, OUTPUT_ERROR)
446
+ else: print(message)
447
+ return ExceptionManager.PASS
448
+ ExceptionManager.add_handler(CrashHandler())
449
+
450
+ Config.set('graphics', 'window_state', 'hidden')
451
+
452
+ app = KaTrainApp(); signal.signal(signal.SIGINT, app.signal_handler); app.run()
453
+
454
+ if __name__ == "__main__":
455
+ run_app()
katrain/katrain/__main__.spec ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- mode: python ; coding: utf-8 -*-
2
+
3
+
4
+ a = Analysis(
5
+ ['__main__.py'],
6
+ pathex=[],
7
+ binaries=[],
8
+ datas=[('C:\\Users\\puco2\\katrain\\katrain\\fonts\\Roboto-Regular.ttf', 'kivymd\\fonts'), ('C:\\Users\\puco2\\katrain\\katrain\\fonts\\Roboto-Italic.ttf', 'kivymd\\fonts'), ('C:\\Users\\puco2\\katrain\\katrain\\fonts\\Roboto-Bold.ttf', 'kivymd\\fonts'), ('C:\\Users\\puco2\\katrain\\katrain\\fonts\\Roboto-BoldItalic.ttf', 'kivymd\\fonts'), ('C:\\Users\\puco2\\katrain\\katrain\\fonts\\Roboto-Thin.ttf', 'kivymd\\fonts'), ('C:\\Users\\puco2\\katrain\\katrain\\fonts\\Roboto-BoldItalic.ttf', 'kivymd\\fonts'), ('C:\\Users\\puco2\\katrain\\katrain\\fonts\\Roboto-LightItalic.ttf', 'kivymd\\fonts'), ('C:\\Users\\puco2\\katrain\\katrain\\fonts\\Roboto-Light.ttf', 'kivymd\\fonts'), ('C:\\Users\\puco2\\katrain\\katrain\\fonts\\Roboto-BlackItalic.ttf', 'kivymd\\fonts'), ('C:\\Users\\puco2\\katrain\\katrain\\fonts\\Roboto-Black.ttf', 'kivymd\\fonts'), ('C:\\Users\\puco2\\katrain\\katrain\\fonts\\Roboto-MediumItalic.ttf', 'kivymd\\fonts'), ('C:\\Users\\puco2\\katrain\\katrain\\fonts\\Roboto-Medium.ttf', 'kivymd\\fonts'), ('C:\\Users\\puco2\\katrain\\katrain\\fonts\\Roboto-ThinItalic.ttf', 'kivymd\\fonts'), ('C:\\Users\\puco2\\katrain\\katrain\\fonts\\materialdesignicons-webfont.ttf', 'kivymd\\fonts')],
9
+ hiddenimports=[],
10
+ hookspath=[],
11
+ hooksconfig={},
12
+ runtime_hooks=[],
13
+ excludes=[],
14
+ noarchive=False,
15
+ optimize=0,
16
+ )
17
+ pyz = PYZ(a.pure)
18
+
19
+ exe = EXE(
20
+ pyz,
21
+ a.scripts,
22
+ a.binaries,
23
+ a.datas,
24
+ [],
25
+ name='__main__',
26
+ debug=False,
27
+ bootloader_ignore_signals=False,
28
+ strip=False,
29
+ upx=True,
30
+ upx_exclude=[],
31
+ runtime_tmpdir=None,
32
+ console=False,
33
+ disable_windowed_traceback=False,
34
+ argv_emulation=False,
35
+ target_arch=None,
36
+ codesign_identity=None,
37
+ entitlements_file=None,
38
+ )
katrain/katrain/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (142 Bytes). View file
 
katrain/katrain/config.json ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "engine": {
3
+ "katago": "",
4
+ "altcommand": "",
5
+ "model": "katrain/models/g170e-b15c192-s1672170752-d466197061.bin.gz",
6
+ "config": "katrain/KataGo/analysis_config.cfg",
7
+ "threads": 12,
8
+ "max_visits": 500,
9
+ "fast_visits": 25,
10
+ "max_time": 8.0,
11
+ "wide_root_noise": 0.04,
12
+ "_enable_ownership": true
13
+ },
14
+ "contribute": {
15
+ "katago": "",
16
+ "config": "katrain/KataGo/contribute_config.cfg",
17
+ "ownership": false,
18
+ "maxgames": 6,
19
+ "movespeed": 2,
20
+ "username": "",
21
+ "password": "",
22
+ "savepath": "./dist_sgf/",
23
+ "savesgf": false
24
+ },
25
+ "general": {
26
+ "sgf_load": "~/Downloads",
27
+ "sgf_save": "./sgfout",
28
+ "anim_pv_time": 0.5,
29
+ "debug_level": 0,
30
+ "lang": "en",
31
+ "version": "1.13.0",
32
+ "load_fast_analysis": false,
33
+ "load_sgf_rewind": true
34
+ },
35
+ "timer": {
36
+ "byo_length": 30,
37
+ "byo_periods": 5,
38
+ "minimal_use": 0,
39
+ "main_time": 0,
40
+ "sound": true
41
+ },
42
+ "game": {
43
+ "size": "19",
44
+ "komi": 6.5,
45
+ "handicap": 0,
46
+ "rules": "japanese",
47
+ "clear_cache": false,
48
+ "setup_move":100,
49
+ "setup_advantage":20
50
+ },
51
+ "trainer": {
52
+ "theme": "theme:normal",
53
+ "num_undo_prompts": [
54
+ 1,
55
+ 1,
56
+ 1,
57
+ 0.5,
58
+ 0,
59
+ 0
60
+ ],
61
+ "eval_thresholds": [
62
+ 12,
63
+ 6,
64
+ 3,
65
+ 1.5,
66
+ 0.5,
67
+ 0
68
+ ],
69
+ "save_feedback": [
70
+ true,
71
+ true,
72
+ true,
73
+ true,
74
+ false,
75
+ false
76
+ ],
77
+ "show_dots": [
78
+ true,
79
+ true,
80
+ true,
81
+ true,
82
+ true,
83
+ true
84
+ ],
85
+ "extra_precision": false,
86
+ "save_analysis": false,
87
+ "save_marks": false,
88
+ "low_visits": 25,
89
+ "eval_on_show_last": 3,
90
+ "top_moves_show": "top_move_delta_score",
91
+ "top_moves_show_secondary": "top_move_visits",
92
+ "eval_show_ai": true,
93
+ "lock_ai": false
94
+ },
95
+ "ai": {
96
+ "ai:default": {},
97
+ "ai:antimirror": {},
98
+ "ai:handicap": {
99
+ "automatic": true,
100
+ "pda": 0
101
+ },
102
+ "ai:jigo": {
103
+ "target_score": 0.5
104
+ },
105
+ "ai:scoreloss": {
106
+ "strength": 0.2
107
+ },
108
+ "ai:policy": {
109
+ "opening_moves": 22.0
110
+ },
111
+ "ai:simple": {
112
+ "max_points_lost": 1.75,
113
+ "settled_weight": 1.0,
114
+ "opponent_fac": 0.5,
115
+ "min_visits": 3,
116
+ "attach_penalty": 1,
117
+ "tenuki_penalty": 0.5
118
+ },
119
+ "ai:p:weighted": {
120
+ "weaken_fac": 1.25,
121
+ "pick_override": 1.0,
122
+ "lower_bound": 0.001
123
+ },
124
+ "ai:p:pick": {
125
+ "pick_override": 0.95,
126
+ "pick_n": 5,
127
+ "pick_frac": 0.35
128
+ },
129
+ "ai:p:local": {
130
+ "pick_override": 0.95,
131
+ "stddev": 1.5,
132
+ "pick_n": 15,
133
+ "pick_frac": 0.0,
134
+ "endgame": 0.5
135
+ },
136
+ "ai:p:tenuki": {
137
+ "pick_override": 0.85,
138
+ "stddev": 7.5,
139
+ "pick_n": 5,
140
+ "pick_frac": 0.4,
141
+ "endgame": 0.45
142
+ },
143
+ "ai:p:influence": {
144
+ "pick_override": 0.95,
145
+ "pick_n": 5,
146
+ "pick_frac": 0.3,
147
+ "threshold": 3.5,
148
+ "line_weight": 10,
149
+ "endgame": 0.4
150
+ },
151
+ "ai:p:territory": {
152
+ "pick_override": 0.95,
153
+ "pick_n": 5,
154
+ "pick_frac": 0.3,
155
+ "threshold": 3.5,
156
+ "line_weight": 2,
157
+ "endgame": 0.4
158
+ },
159
+ "ai:p:rank": {
160
+ "kyu_rank": 4.0
161
+ }
162
+ },
163
+ "ui_state": {
164
+ "restoresize": true,
165
+ "size": [],
166
+ "play": {
167
+ "analysis_controls": {
168
+ "show_children": true,
169
+ "eval": false,
170
+ "hints": false,
171
+ "policy": false,
172
+ "ownership": false
173
+ },
174
+ "panels": {
175
+ "graph_panel": [
176
+ "open",
177
+ {
178
+ "score": true,
179
+ "winrate": false
180
+ }
181
+ ],
182
+ "stats_panel": [
183
+ "open",
184
+ {
185
+ "score": true,
186
+ "winrate": true,
187
+ "points": true
188
+ }
189
+ ],
190
+ "notes_panel": [
191
+ "open",
192
+ {
193
+ "info": true,
194
+ "info-details": false,
195
+ "notes": false
196
+ }
197
+ ]
198
+ }
199
+ },
200
+ "analyze": {
201
+ "analysis_controls": {
202
+ "show_children": true,
203
+ "eval": true,
204
+ "hints": true,
205
+ "policy": false,
206
+ "ownership": true
207
+ },
208
+ "panels": {
209
+ "graph_panel": [
210
+ "open",
211
+ {
212
+ "score": true,
213
+ "winrate": true
214
+ }
215
+ ],
216
+ "stats_panel": [
217
+ "open",
218
+ {
219
+ "score": true,
220
+ "winrate": true,
221
+ "points": true
222
+ }
223
+ ],
224
+ "notes_panel": [
225
+ "open",
226
+ {
227
+ "info": true,
228
+ "info-details": true,
229
+ "notes": false
230
+ }
231
+ ]
232
+ }
233
+ }
234
+ }
235
+ }
katrain/katrain/core/__init__.py ADDED
File without changes
katrain/katrain/core/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (147 Bytes). View file
 
katrain/katrain/core/__pycache__/ai.cpython-310.pyc ADDED
Binary file (20.3 kB). View file
 
katrain/katrain/core/__pycache__/base_katrain.cpython-310.pyc ADDED
Binary file (4.11 kB). View file
 
katrain/katrain/core/__pycache__/constants.cpython-310.pyc ADDED
Binary file (8.15 kB). View file
 
katrain/katrain/core/__pycache__/game.cpython-310.pyc ADDED
Binary file (28.4 kB). View file
 
katrain/katrain/core/__pycache__/game_node.cpython-310.pyc ADDED
Binary file (15.4 kB). View file
 
katrain/katrain/core/__pycache__/lang.cpython-310.pyc ADDED
Binary file (2.9 kB). View file
 
katrain/katrain/core/__pycache__/sgf_parser.cpython-310.pyc ADDED
Binary file (23.1 kB). View file
 
katrain/katrain/core/__pycache__/utils.cpython-310.pyc ADDED
Binary file (3.92 kB). View file
 
katrain/katrain/core/ai.py ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import heapq
2
+ import math
3
+ import random
4
+ import time
5
+ from typing import Dict, List, Optional, Tuple
6
+
7
+ from katrain.core.constants import (
8
+ AI_DEFAULT,
9
+ AI_HANDICAP,
10
+ AI_INFLUENCE,
11
+ AI_INFLUENCE_ELO_GRID,
12
+ AI_JIGO,
13
+ AI_ANTIMIRROR,
14
+ AI_LOCAL,
15
+ AI_LOCAL_ELO_GRID,
16
+ AI_PICK,
17
+ AI_PICK_ELO_GRID,
18
+ AI_POLICY,
19
+ AI_RANK,
20
+ AI_SCORELOSS,
21
+ AI_SCORELOSS_ELO,
22
+ AI_SETTLE_STONES,
23
+ AI_SIMPLE_OWNERSHIP,
24
+ AI_STRATEGIES_PICK,
25
+ AI_STRATEGIES_POLICY,
26
+ AI_STRENGTH,
27
+ AI_TENUKI,
28
+ AI_TENUKI_ELO_GRID,
29
+ AI_TERRITORY,
30
+ AI_TERRITORY_ELO_GRID,
31
+ AI_WEIGHTED,
32
+ AI_WEIGHTED_ELO,
33
+ CALIBRATED_RANK_ELO,
34
+ OUTPUT_DEBUG,
35
+ OUTPUT_ERROR,
36
+ OUTPUT_INFO,
37
+ PRIORITY_EXTRA_AI_QUERY,
38
+ ADDITIONAL_MOVE_ORDER,
39
+ )
40
+ from katrain.core.game import Game, GameNode, Move
41
+ from katrain.core.utils import var_to_grid, weighted_selection_without_replacement, evaluation_class
42
+
43
+
44
+ def interp_ix(lst, x):
45
+ i = 0
46
+ while i + 1 < len(lst) - 1 and lst[i + 1] < x:
47
+ i += 1
48
+ t = max(0, min(1, (x - lst[i]) / (lst[i + 1] - lst[i])))
49
+ return i, t
50
+
51
+
52
+ def interp1d(lst, x):
53
+ xs, ys = zip(*lst)
54
+ i, t = interp_ix(xs, x)
55
+ return (1 - t) * ys[i] + t * ys[i + 1]
56
+
57
+
58
+ def interp2d(gridspec, x, y):
59
+ xs, ys, matrix = gridspec
60
+ i, t = interp_ix(xs, x)
61
+ j, s = interp_ix(ys, y)
62
+ return (
63
+ matrix[j][i] * (1 - t) * (1 - s)
64
+ + matrix[j][i + 1] * t * (1 - s)
65
+ + matrix[j + 1][i] * (1 - t) * s
66
+ + matrix[j + 1][i + 1] * t * s
67
+ )
68
+
69
+
70
+ def ai_rank_estimation(strategy, settings) -> int:
71
+ if strategy in [AI_DEFAULT, AI_HANDICAP, AI_JIGO]:
72
+ return 9
73
+ if strategy == AI_RANK:
74
+ return 1 - settings["kyu_rank"]
75
+ if strategy in [AI_WEIGHTED, AI_SCORELOSS, AI_LOCAL, AI_TENUKI, AI_TERRITORY, AI_INFLUENCE, AI_PICK]:
76
+ if strategy == AI_WEIGHTED:
77
+ elo = interp1d(AI_WEIGHTED_ELO, settings["weaken_fac"])
78
+ if strategy == AI_SCORELOSS:
79
+ elo = interp1d(AI_SCORELOSS_ELO, settings["strength"])
80
+ if strategy == AI_PICK:
81
+ elo = interp2d(AI_PICK_ELO_GRID, settings["pick_frac"], settings["pick_n"])
82
+ if strategy == AI_LOCAL:
83
+ elo = interp2d(AI_LOCAL_ELO_GRID, settings["pick_frac"], settings["pick_n"])
84
+ if strategy == AI_TENUKI:
85
+ elo = interp2d(AI_TENUKI_ELO_GRID, settings["pick_frac"], settings["pick_n"])
86
+ if strategy == AI_TERRITORY:
87
+ elo = interp2d(AI_TERRITORY_ELO_GRID, settings["pick_frac"], settings["pick_n"])
88
+ if strategy == AI_INFLUENCE:
89
+ elo = interp2d(AI_INFLUENCE_ELO_GRID, settings["pick_frac"], settings["pick_n"])
90
+
91
+ kyu = interp1d(CALIBRATED_RANK_ELO, elo)
92
+ return 1 - kyu
93
+ else:
94
+ return AI_STRENGTH[strategy]
95
+
96
+
97
+ def game_report(game, thresholds, depth_filter=None):
98
+ cn = game.current_node
99
+ nodes = cn.nodes_from_root
100
+ while cn.children: # main branch
101
+ cn = cn.children[0]
102
+ nodes.append(cn)
103
+
104
+ x, y = game.board_size
105
+ depth_filter = [math.ceil(board_frac * x * y) for board_frac in depth_filter or (0, 1e9)]
106
+ nodes = [n for n in nodes if n.move and not n.is_root and depth_filter[0] <= n.depth < depth_filter[1]]
107
+ histogram = [{"B": 0, "W": 0} for _ in thresholds]
108
+ ai_top_move_count = {"B": 0, "W": 0}
109
+ ai_approved_move_count = {"B": 0, "W": 0}
110
+ player_ptloss = {"B": [], "W": []}
111
+ weights = {"B": [], "W": []}
112
+
113
+ for n in nodes:
114
+ points_lost = n.points_lost
115
+ if n.points_lost is None:
116
+ continue
117
+ else:
118
+ points_lost = max(0, points_lost)
119
+ bucket = len(thresholds) - 1 - evaluation_class(points_lost, thresholds)
120
+ player_ptloss[n.player].append(points_lost)
121
+ histogram[bucket][n.player] += 1
122
+ cands = n.parent.candidate_moves
123
+ filtered_cands = [d for d in cands if d["order"] < ADDITIONAL_MOVE_ORDER and "prior" in d]
124
+ weight = min(
125
+ 1.0,
126
+ sum([max(d["pointsLost"], 0) * d["prior"] for d in filtered_cands])
127
+ / (sum(d["prior"] for d in filtered_cands) or 1e-6),
128
+ ) # complexity capped at 1
129
+ # adj_weight between 0.05 - 1, dependent on difficulty and points lost
130
+ adj_weight = max(0.05, min(1.0, max(weight, points_lost / 4)))
131
+ weights[n.player].append((weight, adj_weight))
132
+ if n.parent.analysis_complete:
133
+ ai_top_move_count[n.player] += int(cands[0]["move"] == n.move.gtp())
134
+ ai_approved_move_count[n.player] += int(
135
+ n.move.gtp()
136
+ in [d["move"] for d in filtered_cands if d["order"] == 0 or (d["pointsLost"] < 0.5 and d["order"] < 5)]
137
+ )
138
+
139
+ wt_loss = {
140
+ bw: sum(s * aw for s, (w, aw) in zip(player_ptloss[bw], weights[bw]))
141
+ / (sum(aw for _, aw in weights[bw]) or 1e-6)
142
+ for bw in "BW"
143
+ }
144
+ sum_stats = {
145
+ bw: {
146
+ "accuracy": 100 * 0.75 ** wt_loss[bw],
147
+ "complexity": sum(w for w, aw in weights[bw]) / len(player_ptloss[bw]),
148
+ "mean_ptloss": sum(player_ptloss[bw]) / len(player_ptloss[bw]),
149
+ "weighted_ptloss": wt_loss[bw],
150
+ "ai_top_move": ai_top_move_count[bw] / len(player_ptloss[bw]),
151
+ "ai_top5_move": ai_approved_move_count[bw] / len(player_ptloss[bw]),
152
+ }
153
+ if len(player_ptloss[bw]) > 0
154
+ else {}
155
+ for bw in "BW"
156
+ }
157
+ return sum_stats, histogram, player_ptloss
158
+
159
+
160
+ def dirichlet_noise(num, dir_alpha=0.3):
161
+ sample = [random.gammavariate(dir_alpha, 1) for _ in range(num)]
162
+ sum_sample = sum(sample)
163
+ return [s / sum_sample for s in sample]
164
+
165
+
166
+ def fmt_moves(moves: List[Tuple[float, Move]]):
167
+ return ", ".join(f"{mv.gtp()} ({p:.2%})" for p, mv in moves)
168
+
169
+
170
+ def policy_weighted_move(policy_moves, lower_bound, weaken_fac):
171
+ lower_bound, weaken_fac = max(0, lower_bound), max(0.01, weaken_fac)
172
+ weighted_coords = [
173
+ (pv, pv ** (1 / weaken_fac), move) for pv, move in policy_moves if pv > lower_bound and not move.is_pass
174
+ ]
175
+ if weighted_coords:
176
+ top = weighted_selection_without_replacement(weighted_coords, 1)[0]
177
+ move = top[2]
178
+ ai_thoughts = f"Playing policy-weighted random move {move.gtp()} ({top[0]:.1%}) from {len(weighted_coords)} moves above lower_bound of {lower_bound:.1%}."
179
+ else:
180
+ move = policy_moves[0][1]
181
+ ai_thoughts = f"Playing top policy move because no non-pass move > above lower_bound of {lower_bound:.1%}."
182
+ return move, ai_thoughts
183
+
184
+
185
+ def generate_influence_territory_weights(ai_mode, ai_settings, policy_grid, size):
186
+ thr_line = ai_settings["threshold"] - 1 # zero-based
187
+ if ai_mode == AI_INFLUENCE:
188
+ weight = lambda x, y: (1 / ai_settings["line_weight"]) ** ( # noqa E731
189
+ max(0, thr_line - min(size[0] - 1 - x, x)) + max(0, thr_line - min(size[1] - 1 - y, y))
190
+ ) # noqa E731
191
+ else:
192
+ weight = lambda x, y: (1 / ai_settings["line_weight"]) ** ( # noqa E731
193
+ max(0, min(size[0] - 1 - x, x, size[1] - 1 - y, y) - thr_line)
194
+ )
195
+ weighted_coords = [
196
+ (policy_grid[y][x] * weight(x, y), weight(x, y), x, y)
197
+ for x in range(size[0])
198
+ for y in range(size[1])
199
+ if policy_grid[y][x] > 0
200
+ ]
201
+ ai_thoughts = f"Generated weights for {ai_mode} according to weight factor {ai_settings['line_weight']} and distance from {thr_line + 1}th line. "
202
+ return weighted_coords, ai_thoughts
203
+
204
+
205
+ def generate_local_tenuki_weights(ai_mode, ai_settings, policy_grid, cn, size):
206
+ var = ai_settings["stddev"] ** 2
207
+ mx, my = cn.move.coords
208
+ weighted_coords = [
209
+ (policy_grid[y][x], math.exp(-0.5 * ((x - mx) ** 2 + (y - my) ** 2) / var), x, y)
210
+ for x in range(size[0])
211
+ for y in range(size[1])
212
+ if policy_grid[y][x] > 0
213
+ ]
214
+ ai_thoughts = f"Generated weights based on one minus gaussian with variance {var} around coordinates {mx},{my}. "
215
+ if ai_mode == AI_TENUKI:
216
+ weighted_coords = [(p, 1 - w, x, y) for p, w, x, y in weighted_coords]
217
+ ai_thoughts = (
218
+ f"Generated weights based on one minus gaussian with variance {var} around coordinates {mx},{my}. "
219
+ )
220
+ return weighted_coords, ai_thoughts
221
+
222
+
223
+ def request_ai_analysis(game: Game, cn: GameNode, extra_settings: Dict) -> Optional[Dict]:
224
+ error = False
225
+ analysis = None
226
+
227
+ def set_analysis(a, partial_result):
228
+ nonlocal analysis
229
+ if not partial_result:
230
+ analysis = a
231
+
232
+ def set_error(a):
233
+ nonlocal error
234
+ game.katrain.log(f"Error in additional analysis query: {a}")
235
+ error = True
236
+
237
+ engine = game.engines[cn.player]
238
+ engine.request_analysis(
239
+ cn,
240
+ callback=set_analysis,
241
+ error_callback=set_error,
242
+ priority=PRIORITY_EXTRA_AI_QUERY,
243
+ ownership=False,
244
+ extra_settings=extra_settings,
245
+ )
246
+ while not (error or analysis):
247
+ time.sleep(0.01) # TODO: prevent deadlock if esc, check node in queries?
248
+ engine.check_alive(exception_if_dead=True)
249
+ return analysis
250
+
251
+
252
+ def generate_ai_move(game: Game, ai_mode: str, ai_settings: Dict) -> Tuple[Move, GameNode]:
253
+ cn = game.current_node
254
+
255
+ if ai_mode == AI_HANDICAP:
256
+ pda = ai_settings["pda"]
257
+ if ai_settings["automatic"]:
258
+ n_handicaps = len(game.root.get_list_property("AB", []))
259
+ MOVE_VALUE = 14 # could be rules dependent
260
+ b_stones_advantage = max(n_handicaps - 1, 0) - (cn.komi - MOVE_VALUE / 2) / MOVE_VALUE
261
+ pda = min(3, max(-3, -b_stones_advantage * (3 / 8))) # max PDA at 8 stone adv, normal 9 stone game is 8.46
262
+ handicap_analysis = request_ai_analysis(
263
+ game, cn, {"playoutDoublingAdvantage": pda, "playoutDoublingAdvantagePla": "BLACK"}
264
+ )
265
+ if not handicap_analysis:
266
+ game.katrain.log("Error getting handicap-based move", OUTPUT_ERROR)
267
+ ai_mode = AI_DEFAULT
268
+ elif ai_mode == AI_ANTIMIRROR:
269
+ antimirror_analysis = request_ai_analysis(game, cn, {"antiMirror": True})
270
+ if not antimirror_analysis:
271
+ game.katrain.log("Error getting antimirror move", OUTPUT_ERROR)
272
+ ai_mode = AI_DEFAULT
273
+
274
+ while not cn.analysis_complete:
275
+ time.sleep(0.01)
276
+ game.engines[cn.next_player].check_alive(exception_if_dead=True)
277
+
278
+ ai_thoughts = ""
279
+ if (ai_mode in AI_STRATEGIES_POLICY) and cn.policy: # pure policy based move
280
+ policy_moves = cn.policy_ranking
281
+ pass_policy = cn.policy[-1]
282
+ # dont make it jump around for the last few sensible non pass moves
283
+ top_5_pass = any([polmove[1].is_pass for polmove in policy_moves[:5]])
284
+
285
+ size = game.board_size
286
+ policy_grid = var_to_grid(cn.policy, size) # type: List[List[float]]
287
+ top_policy_move = policy_moves[0][1]
288
+ ai_thoughts += f"Using policy based strategy, base top 5 moves are {fmt_moves(policy_moves[:5])}. "
289
+ if (ai_mode == AI_POLICY and cn.depth <= ai_settings["opening_moves"]) or (
290
+ ai_mode in [AI_LOCAL, AI_TENUKI] and not (cn.move and cn.move.coords)
291
+ ):
292
+ ai_mode = AI_WEIGHTED
293
+ ai_thoughts += "Strategy override, using policy-weighted strategy instead. "
294
+ ai_settings = {"pick_override": 0.9, "weaken_fac": 1, "lower_bound": 0.02}
295
+
296
+ if top_5_pass:
297
+ aimove = top_policy_move
298
+ ai_thoughts += "Playing top one because one of them is pass."
299
+ elif ai_mode == AI_POLICY:
300
+ aimove = top_policy_move
301
+ ai_thoughts += f"Playing top policy move {aimove.gtp()}."
302
+ else: # weighted or pick-based
303
+ legal_policy_moves = [(pol, mv) for pol, mv in policy_moves if not mv.is_pass and pol > 0]
304
+ board_squares = size[0] * size[1]
305
+ if ai_mode == AI_RANK: # calibrated, override from 0.8 at start to ~0.4 at full board
306
+ override = 0.8 * (1 - 0.5 * (board_squares - len(legal_policy_moves)) / board_squares)
307
+ overridetwo = 0.85 + max(0, 0.02 * (ai_settings["kyu_rank"] - 8))
308
+ else:
309
+ override = ai_settings["pick_override"]
310
+ overridetwo = 1.0
311
+
312
+ if policy_moves[0][0] > override:
313
+ aimove = top_policy_move
314
+ ai_thoughts += f"Top policy move has weight > {override:.1%}, so overriding other strategies."
315
+ elif policy_moves[0][0] + policy_moves[1][0] > overridetwo:
316
+ aimove = top_policy_move
317
+ ai_thoughts += (
318
+ f"Top two policy moves have cumulative weight > {overridetwo:.1%}, so overriding other strategies."
319
+ )
320
+ elif ai_mode == AI_WEIGHTED:
321
+ aimove, ai_thoughts = policy_weighted_move(
322
+ policy_moves, ai_settings["lower_bound"], ai_settings["weaken_fac"]
323
+ )
324
+ elif ai_mode in AI_STRATEGIES_PICK:
325
+
326
+ if ai_mode != AI_RANK:
327
+ n_moves = max(1, int(ai_settings["pick_frac"] * len(legal_policy_moves) + ai_settings["pick_n"]))
328
+ else:
329
+ orig_calib_avemodrank = 0.063015 + 0.7624 * board_squares / (
330
+ 10 ** (-0.05737 * ai_settings["kyu_rank"] + 1.9482)
331
+ )
332
+ norm_leg_moves = len(legal_policy_moves) / board_squares
333
+ modified_calib_avemodrank = (
334
+ 0.3931
335
+ + 0.6559
336
+ * norm_leg_moves
337
+ * math.exp(
338
+ -1
339
+ * (
340
+ 3.002 * norm_leg_moves * norm_leg_moves
341
+ - norm_leg_moves
342
+ - 0.034889 * ai_settings["kyu_rank"]
343
+ - 0.5097
344
+ )
345
+ ** 2
346
+ )
347
+ - 0.01093 * ai_settings["kyu_rank"]
348
+ ) * orig_calib_avemodrank
349
+ n_moves = board_squares * norm_leg_moves / (1.31165 * (modified_calib_avemodrank + 1) - 0.082653)
350
+ n_moves = max(1, round(n_moves))
351
+
352
+ if ai_mode in [AI_INFLUENCE, AI_TERRITORY, AI_LOCAL, AI_TENUKI]:
353
+ if cn.depth > ai_settings["endgame"] * board_squares:
354
+ weighted_coords = [(pol, 1, *mv.coords) for pol, mv in legal_policy_moves]
355
+ x_ai_thoughts = (
356
+ f"Generated equal weights as move number >= {ai_settings['endgame'] * size[0] * size[1]}. "
357
+ )
358
+ n_moves = int(max(n_moves, len(legal_policy_moves) // 2))
359
+ elif ai_mode in [AI_INFLUENCE, AI_TERRITORY]:
360
+ weighted_coords, x_ai_thoughts = generate_influence_territory_weights(
361
+ ai_mode, ai_settings, policy_grid, size
362
+ )
363
+ else: # ai_mode in [AI_LOCAL, AI_TENUKI]
364
+ weighted_coords, x_ai_thoughts = generate_local_tenuki_weights(
365
+ ai_mode, ai_settings, policy_grid, cn, size
366
+ )
367
+ ai_thoughts += x_ai_thoughts
368
+ else: # ai_mode in [AI_PICK, AI_RANK]:
369
+ weighted_coords = [
370
+ (policy_grid[y][x], 1, x, y)
371
+ for x in range(size[0])
372
+ for y in range(size[1])
373
+ if policy_grid[y][x] > 0
374
+ ]
375
+
376
+ pick_moves = weighted_selection_without_replacement(weighted_coords, n_moves)
377
+ ai_thoughts += f"Picked {min(n_moves,len(weighted_coords))} random moves according to weights. "
378
+
379
+ if pick_moves:
380
+ new_top = [
381
+ (p, Move((x, y), player=cn.next_player)) for p, wt, x, y in heapq.nlargest(5, pick_moves)
382
+ ]
383
+ aimove = new_top[0][1]
384
+ ai_thoughts += f"Top 5 among these were {fmt_moves(new_top)} and picked top {aimove.gtp()}. "
385
+ if new_top[0][0] < pass_policy:
386
+ ai_thoughts += f"But found pass ({pass_policy:.2%} to be higher rated than {aimove.gtp()} ({new_top[0][0]:.2%}) so will play top policy move instead."
387
+ aimove = top_policy_move
388
+ else:
389
+ aimove = top_policy_move
390
+ ai_thoughts += f"Pick policy strategy {ai_mode} failed to find legal moves, so is playing top policy move {aimove.gtp()}."
391
+ else:
392
+ raise ValueError(f"Unknown Policy-based AI mode {ai_mode}")
393
+ else: # Engine based move
394
+ candidate_ai_moves = cn.candidate_moves
395
+ if ai_mode == AI_HANDICAP:
396
+ candidate_ai_moves = handicap_analysis["moveInfos"]
397
+ elif ai_mode == AI_ANTIMIRROR:
398
+ candidate_ai_moves = antimirror_analysis["moveInfos"]
399
+
400
+ top_cand = Move.from_gtp(candidate_ai_moves[0]["move"], player=cn.next_player)
401
+ if top_cand.is_pass and ai_mode not in [
402
+ AI_DEFAULT,
403
+ AI_HANDICAP,
404
+ ]: # don't play suicidal to balance score
405
+ aimove = top_cand
406
+ ai_thoughts += "Top move is pass, so passing regardless of strategy. "
407
+ else:
408
+ if ai_mode == AI_JIGO:
409
+ sign = cn.player_sign(cn.next_player)
410
+ jigo_move = min(
411
+ candidate_ai_moves, key=lambda move: abs(sign * move["scoreLead"] - ai_settings["target_score"])
412
+ )
413
+ aimove = Move.from_gtp(jigo_move["move"], player=cn.next_player)
414
+ ai_thoughts += f"Jigo strategy found {len(candidate_ai_moves)} candidate moves (best {top_cand.gtp()}) and chose {aimove.gtp()} as closest to 0.5 point win"
415
+ elif ai_mode == AI_SCORELOSS:
416
+ c = ai_settings["strength"]
417
+ moves = [
418
+ (
419
+ d["pointsLost"],
420
+ math.exp(min(200, -c * max(0, d["pointsLost"]))),
421
+ Move.from_gtp(d["move"], player=cn.next_player),
422
+ )
423
+ for d in candidate_ai_moves
424
+ ]
425
+ topmove = weighted_selection_without_replacement(moves, 1)[0]
426
+ aimove = topmove[2]
427
+ ai_thoughts += f"ScoreLoss strategy found {len(candidate_ai_moves)} candidate moves (best {top_cand.gtp()}) and chose {aimove.gtp()} (weight {topmove[1]:.3f}, point loss {topmove[0]:.1f}) based on score weights."
428
+ elif ai_mode in [AI_SIMPLE_OWNERSHIP, AI_SETTLE_STONES]:
429
+ stones_with_player = {(*s.coords, s.player) for s in game.stones}
430
+ next_player_sign = cn.player_sign(cn.next_player)
431
+ if ai_mode == AI_SIMPLE_OWNERSHIP:
432
+
433
+ def settledness(d, player_sign, player):
434
+ return sum([abs(o) for o in d["ownership"] if player_sign * o > 0])
435
+
436
+ else:
437
+ board_size_x, board_size_y = game.board_size
438
+
439
+ def settledness(d, player_sign, player):
440
+ ownership_grid = var_to_grid(d["ownership"], (board_size_x, board_size_y))
441
+ return sum(
442
+ [abs(ownership_grid[s.coords[0]][s.coords[1]]) for s in game.stones if s.player == player]
443
+ )
444
+
445
+ def is_attachment(move):
446
+ if move.is_pass:
447
+ return False
448
+ attach_opponent_stones = sum(
449
+ (move.coords[0] + dx, move.coords[1] + dy, cn.player) in stones_with_player
450
+ for dx in [-1, 0, 1]
451
+ for dy in [-1, 0, 1]
452
+ if abs(dx) + abs(dy) == 1
453
+ )
454
+ nearby_own_stones = sum(
455
+ (move.coords[0] + dx, move.coords[1] + dy, cn.next_player) in stones_with_player
456
+ for dx in [-2, 0, 1, 2]
457
+ for dy in [-2 - 1, 0, 1, 2]
458
+ if abs(dx) + abs(dy) <= 2 # allows clamps/jumps
459
+ )
460
+ return attach_opponent_stones >= 1 and nearby_own_stones == 0
461
+
462
+ def is_tenuki(d):
463
+ return not d.is_pass and not any(
464
+ not node
465
+ or not node.move
466
+ or node.move.is_pass
467
+ or max(abs(last_c - cand_c) for last_c, cand_c in zip(node.move.coords, d.coords)) < 5
468
+ for node in [cn, cn.parent]
469
+ )
470
+
471
+ moves_with_settledness = sorted(
472
+ [
473
+ (
474
+ move,
475
+ settledness(d, next_player_sign, cn.next_player),
476
+ settledness(d, -next_player_sign, cn.player),
477
+ is_attachment(move),
478
+ is_tenuki(move),
479
+ d,
480
+ )
481
+ for d in candidate_ai_moves
482
+ if d["pointsLost"] < ai_settings["max_points_lost"]
483
+ and "ownership" in d
484
+ and (d["order"] <= 1 or d["visits"] >= ai_settings.get("min_visits", 1))
485
+ for move in [Move.from_gtp(d["move"], player=cn.next_player)]
486
+ if not (move.is_pass and d["pointsLost"] > 0.75)
487
+ ],
488
+ key=lambda t: t[5]["pointsLost"]
489
+ + ai_settings["attach_penalty"] * t[3]
490
+ + ai_settings["tenuki_penalty"] * t[4]
491
+ - ai_settings["settled_weight"] * (t[1] + ai_settings["opponent_fac"] * t[2]),
492
+ )
493
+ if moves_with_settledness:
494
+ cands = [
495
+ f"{move.gtp()} ({d['pointsLost']:.1f} pt lost, {d['visits']} visits, {settled:.1f} settledness, {oppsettled:.1f} opponent settledness{', attachment' if isattach else ''}{', tenuki' if istenuki else ''})"
496
+ for move, settled, oppsettled, isattach, istenuki, d in moves_with_settledness[:5]
497
+ ]
498
+ ai_thoughts += f"{ai_mode} strategy. Top 5 Candidates {', '.join(cands)} "
499
+ aimove = moves_with_settledness[0][0]
500
+ else:
501
+ raise (Exception("No moves found - are you using an older KataGo with no per-move ownership info?"))
502
+ else:
503
+ if ai_mode not in [AI_DEFAULT, AI_HANDICAP, AI_ANTIMIRROR]:
504
+ game.katrain.log(f"Unknown AI mode {ai_mode} or policy missing, using default.", OUTPUT_INFO)
505
+ ai_thoughts += f"Strategy {ai_mode} not found or unexpected fallback."
506
+ aimove = top_cand
507
+ if ai_mode == AI_HANDICAP:
508
+ ai_thoughts += f"Handicap strategy found {len(candidate_ai_moves)} moves returned from the engine and chose {aimove.gtp()} as top move. PDA based score {cn.format_score(handicap_analysis['rootInfo']['scoreLead'])} and win rate {cn.format_winrate(handicap_analysis['rootInfo']['winrate'])}"
509
+ if ai_mode == AI_ANTIMIRROR:
510
+ ai_thoughts += f"AntiMirror strategy found {len(candidate_ai_moves)} moves returned from the engine and chose {aimove.gtp()} as top move. antiMirror based score {cn.format_score(antimirror_analysis['rootInfo']['scoreLead'])} and win rate {cn.format_winrate(antimirror_analysis['rootInfo']['winrate'])}"
511
+ else:
512
+ ai_thoughts += f"Default strategy found {len(candidate_ai_moves)} moves returned from the engine and chose {aimove.gtp()} as top move"
513
+ game.katrain.log(f"AI thoughts: {ai_thoughts}", OUTPUT_DEBUG)
514
+ played_node = game.play(aimove)
515
+ played_node.ai_thoughts = ai_thoughts
516
+ return aimove, played_node
katrain/katrain/core/base_katrain.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # base_katrain.py (진짜 최종 완성본)
2
+
3
+ import json
4
+ import os
5
+ from configparser import ConfigParser, NoSectionError, NoOptionError
6
+
7
+ # --- 필요한 모든 부품을 정확하게 import ---
8
+ from katrain.core.constants import *
9
+ from katrain.core.lang import i18n
10
+ from katrain.core.utils import PATHS
11
+ from katrain.core.ai import ai_rank_estimation
12
+
13
+ class KaTrainBase:
14
+ """KaTrain의 GUI와 엔진 로직 사이에 공유되는 기본 클래스"""
15
+
16
+ def __init__(self, katrain_app, config: ConfigParser):
17
+ self.katrain = katrain_app
18
+ self._config = config
19
+ self.players_info = {}
20
+ self.debug_level = self.config("general/debug_level", 0, int)
21
+
22
+ for bw in "BW":
23
+ self.players_info[bw] = self.PlayerInfo(bw, self)
24
+
25
+ def config(self, key, default=None, vartype=str):
26
+ try:
27
+ if "/" in key:
28
+ parts = key.split("/")
29
+ section = parts[0]
30
+ name = "/".join(parts[1:])
31
+ if vartype == bool:
32
+ return self._config.getboolean(section, name)
33
+ if vartype == int:
34
+ return self._config.getint(section, name)
35
+ return self._config.get(section, name)
36
+ else: # section만 요청된 경우
37
+ return dict(self._config.items(key))
38
+ except (ValueError, KeyError, NoSectionError, NoOptionError):
39
+ return default if "/" in key else (default or {})
40
+
41
+ def save_config(self, sections=None):
42
+ config_path = os.path.join(PATHS["USER"], "config.json")
43
+ save_sections = [sections] if isinstance(sections, str) else (sections or self._config.sections())
44
+
45
+ output_dict = {}
46
+ for s in save_sections:
47
+ if self._config.has_section(s):
48
+ output_dict[s] = dict(self._config.items(s))
49
+
50
+ with open(config_path, "w") as f:
51
+ json.dump(output_dict, f, indent=4)
52
+
53
+ def log(self, message, level=OUTPUT_INFO):
54
+ if self.debug_level is not None and level <= self.debug_level:
55
+ print(message)
56
+
57
+ def update_player(self, bw, **kwargs):
58
+ self.players_info[bw].update(**kwargs)
59
+ self.save_config("players")
60
+ self.update_calculated_ranks()
61
+
62
+ def update_calculated_ranks(self):
63
+ for bw, player_info in self.players_info.items():
64
+ if player_info.player_type == PLAYER_AI:
65
+ settings = {"komi": self.config("game/komi"), "rules": self.config("game/rules")}
66
+ player_info.calculated_rank = ai_rank_estimation(player_info.player_subtype, settings)
67
+
68
+ @property
69
+ def next_player_info(self):
70
+ if hasattr(self, 'game') and self.game and self.game.current_node:
71
+ return self.players_info[self.game.current_node.next_player]
72
+ return self.players_info["B"]
73
+
74
+ @property
75
+ def last_player_info(self):
76
+ if hasattr(self, 'game') and self.game and self.game.current_node and self.game.current_node.player:
77
+ return self.players_info[self.game.current_node.player]
78
+ return self.players_info["W"]
79
+
80
+ class PlayerInfo:
81
+ def __init__(self, bw, katrain_base):
82
+ self.bw = bw
83
+ self.katrain_base = katrain_base
84
+ self.name = katrain_base.config(f"players/{bw}/name", None)
85
+ self.player_type = katrain_base.config(f"players/{bw}/type", PLAYER_HUMAN)
86
+ self.player_subtype = katrain_base.config(f"players/{bw}/subtype", AI_DEFAULT)
87
+ self.sgf_rank = None
88
+ self.calculated_rank = None
89
+
90
+ def update(self, **kwargs):
91
+ for k, v in kwargs.items():
92
+ if hasattr(self, k):
93
+ setattr(self, k, v)
94
+ if not self.katrain_base._config.has_section("players"):
95
+ self.katrain_base._config.add_section("players")
96
+ self.katrain_base._config.set("players", f"{self.bw}/{k}", str(v))
katrain/katrain/core/constants.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ PROGRAM_NAME = "KaTrain"
2
+ VERSION = "1.13.0"
3
+ HOMEPAGE = "https://github.com/sanderland/katrain"
4
+ CONFIG_MIN_VERSION = "1.11.0" # keep config files from this version
5
+ ANALYSIS_FORMAT_VERSION = "1.0"
6
+ DATA_FOLDER = "~/.katrain"
7
+
8
+
9
+ OUTPUT_ERROR = -1
10
+ OUTPUT_HONGIK_STDERR = -0.5
11
+ OUTPUT_INFO = 0
12
+ OUTPUT_DEBUG = 1
13
+ OUTPUT_EXTRA_DEBUG = 2
14
+
15
+ HONGIK_EXCEPTION = "KATAGO-INTERNAL-ERROR"
16
+
17
+ STATUS_ANALYSIS = 1.0 # same priority for analysis/info
18
+ STATUS_INFO = 1.1
19
+ STATUS_TEACHING = 2.0
20
+ STATUS_ERROR = 1000.0
21
+
22
+ ADDITIONAL_MOVE_ORDER = 999
23
+
24
+ PRIORITY_GAME_ANALYSIS = -100
25
+ PRIORITY_SWEEP = -10 # sweep is live, but slow, so deprioritize
26
+ PRIORITY_ALTERNATIVES = 100 # extra analysis, live interaction
27
+ PRIORITY_EQUALIZE = 100
28
+ PRIORITY_EXTRA_ANALYSIS = 100
29
+ PRIORITY_DEFAULT = 1000 # new move, high pri
30
+ PRIORITY_EXTRA_AI_QUERY = 10_000
31
+
32
+ PLAYER_HUMAN, PLAYER_AI = "player:human", "player:ai"
33
+ PLAYER_TYPES = [PLAYER_HUMAN, PLAYER_AI]
34
+
35
+ PLAYING_NORMAL, PLAYING_TEACHING = "game:normal", "game:teach"
36
+ GAME_TYPES = [PLAYING_NORMAL, PLAYING_TEACHING]
37
+
38
+ PLAYER_BLACK = "B"
39
+ PLAYER_WHITE = "W"
40
+
41
+ MODE_PLAY, MODE_ANALYZE = "play", "analyze"
42
+
43
+ AI_DEFAULT = "ai:default"
44
+ AI_HANDICAP = "ai:handicap"
45
+ AI_SCORELOSS = "ai:scoreloss"
46
+ AI_WEIGHTED = "ai:p:weighted"
47
+ AI_JIGO = "ai:jigo"
48
+ AI_ANTIMIRROR = "ai:antimirror"
49
+ AI_POLICY = "ai:policy"
50
+ AI_PICK = "ai:p:pick"
51
+ AI_LOCAL = "ai:p:local"
52
+ AI_TENUKI = "ai:p:tenuki"
53
+ AI_INFLUENCE = "ai:p:influence"
54
+ AI_TERRITORY = "ai:p:territory"
55
+ AI_RANK = "ai:p:rank"
56
+ AI_SIMPLE_OWNERSHIP = "ai:simple"
57
+ AI_SETTLE_STONES = "ai:settle"
58
+
59
+ AI_CONFIG_DEFAULT = AI_RANK
60
+
61
+ AI_STRATEGIES_ENGINE = [AI_DEFAULT, AI_HANDICAP, AI_SCORELOSS, AI_SIMPLE_OWNERSHIP, AI_JIGO, AI_ANTIMIRROR]
62
+ AI_STRATEGIES_PICK = [AI_PICK, AI_LOCAL, AI_TENUKI, AI_INFLUENCE, AI_TERRITORY, AI_RANK]
63
+ AI_STRATEGIES_POLICY = [AI_WEIGHTED, AI_POLICY] + AI_STRATEGIES_PICK
64
+ AI_STRATEGIES = AI_STRATEGIES_ENGINE + AI_STRATEGIES_POLICY
65
+ AI_STRATEGIES_RECOMMENDED_ORDER = [
66
+ AI_DEFAULT,
67
+ AI_RANK,
68
+ AI_HANDICAP,
69
+ AI_SIMPLE_OWNERSHIP,
70
+ AI_SCORELOSS,
71
+ AI_POLICY,
72
+ AI_WEIGHTED,
73
+ AI_JIGO,
74
+ AI_ANTIMIRROR,
75
+ AI_PICK,
76
+ AI_LOCAL,
77
+ AI_TENUKI,
78
+ AI_TERRITORY,
79
+ AI_INFLUENCE,
80
+ ]
81
+
82
+ AI_STRENGTH = { # dan ranks, backup if model is missing. TODO: remove some?
83
+ AI_DEFAULT: 9,
84
+ AI_ANTIMIRROR: 9,
85
+ AI_POLICY: 5,
86
+ AI_JIGO: float("nan"),
87
+ AI_SCORELOSS: -4,
88
+ AI_WEIGHTED: -4,
89
+ AI_PICK: -7,
90
+ AI_LOCAL: -4,
91
+ AI_TENUKI: -7,
92
+ AI_INFLUENCE: -7,
93
+ AI_TERRITORY: -7,
94
+ AI_RANK: float("nan"),
95
+ AI_SIMPLE_OWNERSHIP: 2,
96
+ AI_SETTLE_STONES: 2,
97
+ }
98
+
99
+ AI_OPTION_VALUES = {
100
+ "kyu_rank": [(k, f"{k}[strength:kyu]") for k in range(15, 0, -1)]
101
+ + [(k, f"{1-k}[strength:dan]") for k in range(0, -3, -1)],
102
+ "strength": [0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5, 1],
103
+ "opening_moves": range(0, 51),
104
+ "pick_override": [0, 0.5, 0.6, 0.7, 0.8, 0.85, 0.9, 0.95, 0.99, 1],
105
+ "lower_bound": [(v, f"{v:.2%}") for v in [0, 0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05]],
106
+ "weaken_fac": [x / 20 for x in range(10, 3 * 20 + 1)],
107
+ "endgame": [x / 100 for x in range(10, 80, 5)],
108
+ "pick_frac": [x / 100 for x in range(0, 101, 5)],
109
+ "pick_n": range(0, 26),
110
+ "stddev": [x / 2 for x in range(21)],
111
+ "line_weight": range(0, 11),
112
+ "threshold": [2, 2.5, 3, 3.5, 4, 4.5],
113
+ "automatic": "bool",
114
+ "pda": [(x / 10, f"{'W' if x<0 else 'B'}+{abs(x/10):.1f}") for x in range(-30, 31)],
115
+ "max_points_lost": [x / 10 for x in range(51)],
116
+ "settled_weight": [x / 4 for x in range(0, 17)],
117
+ "opponent_fac": [x / 10 for x in range(-20, 11)],
118
+ "min_visits": range(1, 10),
119
+ "attach_penalty": [x / 10 for x in range(-10, 51)],
120
+ "tenuki_penalty": [x / 10 for x in range(-10, 51)],
121
+ }
122
+ AI_KEY_PROPERTIES = {
123
+ "kyu_rank",
124
+ "strength",
125
+ "weaken_fac",
126
+ "pick_frac",
127
+ "pick_n",
128
+ "automatic",
129
+ "max_points_lost",
130
+ "min_visits",
131
+ }
132
+
133
+
134
+ CALIBRATED_RANK_ELO = [
135
+ (-21.679482223451032, 18),
136
+ (42.60243194422105, 17),
137
+ (106.88434611189314, 16),
138
+ (171.16626027956522, 15),
139
+ (235.44817444723742, 14),
140
+ (299.7300886149095, 13),
141
+ (364.0120027825817, 12),
142
+ (428.2939169502538, 11),
143
+ (492.5758311179259, 10),
144
+ (556.8577452855981, 9),
145
+ (621.1396594532702, 8),
146
+ (685.4215736209424, 7),
147
+ (749.7034877886144, 6),
148
+ (813.9854019562865, 5),
149
+ (878.2673161239586, 4),
150
+ (942.5492302916308, 3),
151
+ (1006.8311444593029, 2),
152
+ (1071.113058626975, 1),
153
+ (1135.3949727946472, 0),
154
+ (1199.6768869623193, -1),
155
+ (1263.9588011299913, -2),
156
+ (1700, -4),
157
+ ]
158
+
159
+
160
+ AI_WEIGHTED_ELO = [
161
+ (0.5, 1591.5718897531551),
162
+ (1.0, 1269.9896556526198),
163
+ (1.25, 1042.25179764667),
164
+ (1.5, 848.9410084463602),
165
+ (1.75, 630.1483212024823),
166
+ (2, 575.3637091858013),
167
+ (2.5, 410.9747543504796),
168
+ (3.0, 219.8667371799533),
169
+ ]
170
+
171
+ AI_SCORELOSS_ELO = [
172
+ (0.0, 539),
173
+ (0.05, 625),
174
+ (0.1, 859),
175
+ (0.2, 1035),
176
+ (0.3, 1201),
177
+ (0.4, 1299),
178
+ (0.5, 1346),
179
+ (0.75, 1374),
180
+ (1.0, 1386),
181
+ ]
182
+
183
+
184
+ AI_LOCAL_ELO_GRID = [
185
+ [0.0, 0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0],
186
+ [0, 5, 10, 15, 25, 50],
187
+ [
188
+ [-204.0, 791.0, 1154.0, 1372.0, 1402.0, 1473.0, 1700.0, 1700.0],
189
+ [174.0, 1094.0, 1191.0, 1384.0, 1435.0, 1522.0, 1700.0, 1700.0],
190
+ [619.0, 1155.0, 1323.0, 1390.0, 1450.0, 1558.0, 1700.0, 1700.0],
191
+ [975.0, 1289.0, 1332.0, 1401.0, 1461.0, 1575.0, 1700.0, 1700.0],
192
+ [1344.0, 1348.0, 1358.0, 1467.0, 1477.0, 1616.0, 1700.0, 1700.0],
193
+ [1425.0, 1474.0, 1489.0, 1524.0, 1571.0, 1700.0, 1700.0, 1700.0],
194
+ ],
195
+ ]
196
+ AI_TENUKI_ELO_GRID = [
197
+ [0.0, 0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0],
198
+ [0, 5, 10, 15, 25, 50],
199
+ [
200
+ [47.0, 335.0, 530.0, 678.0, 830.0, 1070.0, 1376.0, 1700.0],
201
+ [99.0, 469.0, 546.0, 707.0, 855.0, 1090.0, 1413.0, 1700.0],
202
+ [327.0, 513.0, 605.0, 745.0, 875.0, 1110.0, 1424.0, 1700.0],
203
+ [429.0, 519.0, 620.0, 754.0, 900.0, 1130.0, 1435.0, 1700.0],
204
+ [492.0, 607.0, 682.0, 797.0, 1000.0, 1208.0, 1454.0, 1700.0],
205
+ [778.0, 830.0, 909.0, 949.0, 1169.0, 1461.0, 1483.0, 1700.0],
206
+ ],
207
+ ]
208
+ AI_TERRITORY_ELO_GRID = [
209
+ [0.0, 0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0],
210
+ [0, 5, 10, 15, 25, 50],
211
+ [
212
+ [34.0, 383.0, 566.0, 748.0, 980.0, 1264.0, 1527.0, 1700.0],
213
+ [131.0, 450.0, 586.0, 826.0, 995.0, 1280.0, 1537.0, 1700.0],
214
+ [291.0, 517.0, 627.0, 850.0, 1010.0, 1310.0, 1547.0, 1700.0],
215
+ [454.0, 526.0, 696.0, 870.0, 1038.0, 1340.0, 1590.0, 1700.0],
216
+ [491.0, 603.0, 747.0, 890.0, 1050.0, 1390.0, 1635.0, 1700.0],
217
+ [718.0, 841.0, 1039.0, 1076.0, 1332.0, 1523.0, 1700.0, 1700.0],
218
+ ],
219
+ ]
220
+ AI_INFLUENCE_ELO_GRID = [
221
+ [0.0, 0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0],
222
+ [0, 5, 10, 15, 25, 50],
223
+ [
224
+ [217.0, 439.0, 572.0, 768.0, 960.0, 1227.0, 1449.0, 1521.0],
225
+ [302.0, 551.0, 580.0, 800.0, 1028.0, 1257.0, 1470.0, 1529.0],
226
+ [388.0, 572.0, 619.0, 839.0, 1077.0, 1305.0, 1490.0, 1561.0],
227
+ [467.0, 591.0, 764.0, 878.0, 1097.0, 1390.0, 1530.0, 1591.0],
228
+ [539.0, 622.0, 815.0, 953.0, 1120.0, 1420.0, 1560.0, 1601.0],
229
+ [772.0, 912.0, 958.0, 1145.0, 1318.0, 1511.0, 1577.0, 1623.0],
230
+ ],
231
+ ]
232
+ AI_PICK_ELO_GRID = [
233
+ [0.0, 0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0],
234
+ [0, 5, 10, 15, 25, 50],
235
+ [
236
+ [-533.0, -515.0, -355.0, 234.0, 650.0, 1147.0, 1546.0, 1700.0],
237
+ [-531.0, -450.0, -69.0, 347.0, 670.0, 1182.0, 1550.0, 1700.0],
238
+ [-450.0, -311.0, 140.0, 459.0, 693.0, 1252.0, 1555.0, 1700.0],
239
+ [-365.0, -82.0, 265.0, 508.0, 864.0, 1301.0, 1619.0, 1700.0],
240
+ [-113.0, 273.0, 363.0, 641.0, 983.0, 1486.0, 1700.0, 1700.0],
241
+ [514.0, 670.0, 870.0, 1128.0, 1305.0, 1550.0, 1700.0, 1700.0],
242
+ ],
243
+ ]
244
+
245
+
246
+ TOP_MOVE_DELTA_SCORE = "top_move_delta_score"
247
+ TOP_MOVE_SCORE = "top_move_score"
248
+ TOP_MOVE_DELTA_WINRATE = "top_move_delta_winrate"
249
+ TOP_MOVE_WINRATE = "top_move_winrate"
250
+ TOP_MOVE_VISITS = "top_move_visits"
251
+ # TOP_MOVE_UTILITY = "top_move_utility"
252
+ # TOP_MOVE_UTILITYLCB = "top_move_utiltiy_lcb"
253
+ # TOP_MOVE_SCORE_STDDEV = "top_move_score_stddev"
254
+ TOP_MOVE_NOTHING = "top_move_nothing"
255
+
256
+
257
+ TOP_MOVE_OPTIONS = [
258
+ TOP_MOVE_SCORE,
259
+ TOP_MOVE_DELTA_SCORE,
260
+ TOP_MOVE_WINRATE,
261
+ TOP_MOVE_DELTA_WINRATE,
262
+ TOP_MOVE_VISITS,
263
+ TOP_MOVE_NOTHING,
264
+ # TOP_MOVE_SCORE_STDDEV,
265
+ # TOP_MOVE_UTILITY,
266
+ # TOP_MOVE_UTILITYLCB
267
+ ]
268
+ REPORT_DT = 1
269
+ PONDERING_REPORT_DT = 0.25
270
+
271
+ SGF_INTERNAL_COMMENTS_MARKER = "\u3164\u200b"
272
+ SGF_SEPARATOR_MARKER = "\u3164\u3164"
katrain/katrain/core/contribute_engine.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import random
4
+ import shlex
5
+ import shutil
6
+ import signal
7
+ import subprocess
8
+ import threading
9
+ import time
10
+ import traceback
11
+ from collections import defaultdict
12
+
13
+ from katrain.core.constants import OUTPUT_DEBUG, OUTPUT_ERROR, OUTPUT_INFO, OUTPUT_HONGIK_STDERR, DATA_FOLDER
14
+ from katrain.katrain.core.AI_engine import BaseEngine
15
+ from katrain.core.game import BaseGame
16
+ from katrain.core.lang import i18n
17
+ from katrain.core.sgf_parser import Move
18
+ from katrain.core.utils import find_package_resource
19
+
20
+
21
+ class KataGoContributeEngine(BaseEngine):
22
+ """Starts and communicates with the KataGo contribute program"""
23
+
24
+ DEFAULT_MAX_GAMES = 8
25
+
26
+ SHOW_RESULT_TIME = 5
27
+ GIVE_UP_AFTER = 120
28
+
29
+ def __init__(self, katrain):
30
+ super().__init__(katrain, katrain.config("contribute"))
31
+ self.katrain = katrain
32
+ base_dir = os.path.expanduser("~/.katrain/katago_contribute")
33
+ self.katago_process = None
34
+ self.stdout_thread = None
35
+ self.stderr_thread = None
36
+ self.shell = False
37
+ self.active_games = {}
38
+ self.finished_games = set()
39
+ self.showing_game = None
40
+ self.last_advance = 0
41
+ self.move_count = 0
42
+ self.uploaded_games_count = 0
43
+ self.last_move_for_game = defaultdict(int)
44
+ self.visits_count = 0
45
+ self.start_time = 0
46
+ self.server_error = None
47
+ self.paused = False
48
+ self.save_sgf = self.config.get("savesgf", False)
49
+ self.save_path = self.config.get("savepath", "./dist_sgf/")
50
+ self.move_speed = self.config.get("movespeed", 2.0)
51
+
52
+ exe = self.get_engine_path(self.config.get("katago"))
53
+ cacert_path = os.path.join(os.path.split(exe)[0], "cacert.pem")
54
+ if not os.path.isfile(cacert_path):
55
+ try:
56
+ shutil.copyfile(find_package_resource("katrain/KataGo/cacert.pem"), cacert_path)
57
+ except Exception as e:
58
+ self.katrain.log(
59
+ f"Could not copy cacert file ({e}), please add it manually to your katago.exe directory",
60
+ OUTPUT_ERROR,
61
+ )
62
+ cfg = find_package_resource(self.config.get("config"))
63
+
64
+ settings_dict = {
65
+ "username": self.config.get("username"),
66
+ "password": self.config.get("password"),
67
+ "maxSimultaneousGames": self.config.get("maxgames") or self.DEFAULT_MAX_GAMES,
68
+ "includeOwnership": self.config.get("ownership") or False,
69
+ "logGamesAsJson": True,
70
+ "homeDataDir": os.path.expanduser(DATA_FOLDER),
71
+ }
72
+ self.max_buffer_games = 2 * settings_dict["maxSimultaneousGames"]
73
+ settings = {f"{k}={v}" for k, v in settings_dict.items()}
74
+ self.command = shlex.split(
75
+ f'"{exe}" contribute -config "{cfg}" -base-dir "{base_dir}" -override-config "{",".join(settings)}"'
76
+ )
77
+ self.start()
78
+
79
+ @staticmethod
80
+ def game_ended(game):
81
+ cn = game.current_node
82
+ if cn.is_pass and cn.analysis_exists:
83
+ moves = cn.candidate_moves
84
+ if moves and moves[0]["move"] == "pass":
85
+ game.play(Move(None, player=game.current_node.next_player)) # play pass
86
+ return game.end_result
87
+
88
+ def advance_showing_game(self):
89
+ current_game = self.active_games.get(self.showing_game)
90
+ if current_game:
91
+ end_result = self.game_ended(current_game)
92
+ if end_result is not None:
93
+ self.finished_games.add(self.showing_game)
94
+ if time.time() - self.last_advance > self.SHOW_RESULT_TIME:
95
+ del self.active_games[self.showing_game]
96
+ if self.save_sgf:
97
+ filename = os.path.join(self.save_path, f"{self.showing_game}.sgf")
98
+ self.katrain.log(current_game.write_sgf(filename, self.katrain.config("trainer")), OUTPUT_INFO)
99
+
100
+ self.katrain.log(f"Game {self.showing_game} finished, finding a new one", OUTPUT_INFO)
101
+ self.showing_game = None
102
+ elif time.time() - self.last_advance > self.move_speed or len(self.active_games) > self.max_buffer_games:
103
+ if current_game.current_node.children:
104
+ current_game.redo(1)
105
+ self.last_advance = time.time()
106
+ self.katrain("update-state")
107
+ elif time.time() - self.last_advance > self.GIVE_UP_AFTER:
108
+ self.katrain.log(
109
+ f"Giving up on game {self.showing_game} which appears stuck, finding a new one", OUTPUT_INFO
110
+ )
111
+ self.showing_game = None
112
+ else:
113
+ if self.active_games:
114
+ self.showing_game = None
115
+ best_count = -1
116
+ for game_id, game in self.active_games.items(): # find game with most moves left to show
117
+ count = 0
118
+ node = game.current_node
119
+ while node.children:
120
+ node = node.children[0]
121
+ count += 1
122
+ if count > best_count:
123
+ best_count = count
124
+ self.showing_game = game_id
125
+ self.last_advance = time.time()
126
+ self.katrain.log(f"Showing game {self.showing_game}, {best_count} moves left to show.", OUTPUT_INFO)
127
+
128
+ self.katrain.game = self.active_games[self.showing_game]
129
+ self.katrain("update-state", redraw_board=True)
130
+
131
+ def status(self):
132
+ return f"Contributing to distributed training\nGames: {self.uploaded_games_count} uploaded, {len(self.active_games)} in buffer, {len(self.finished_games)} shown\n{self.move_count} moves played ({60*self.move_count/(time.time()-self.start_time):.1f}/min, {self.visits_count / (time.time() - self.start_time):.1f} visits/s)\n"
133
+
134
+ def is_idle(self):
135
+ return False
136
+
137
+ def queries_remaining(self):
138
+ return 1
139
+
140
+ def start(self):
141
+ try:
142
+ self.katrain.log(f"Starting Distributed KataGo with {self.command}", OUTPUT_INFO)
143
+ startupinfo = None
144
+ if hasattr(subprocess, "STARTUPINFO"):
145
+ startupinfo = subprocess.STARTUPINFO()
146
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # stop command box popups on win/pyinstaller
147
+ self.katago_process = subprocess.Popen(
148
+ self.command,
149
+ stdout=subprocess.PIPE,
150
+ stderr=subprocess.PIPE,
151
+ stdin=subprocess.PIPE,
152
+ startupinfo=startupinfo,
153
+ shell=self.shell,
154
+ )
155
+ except (FileNotFoundError, PermissionError, OSError) as e:
156
+ self.katrain.log(
157
+ i18n._("Starting Kata failed").format(command=self.command, error=e),
158
+ OUTPUT_ERROR,
159
+ )
160
+ return # don't start
161
+ self.paused = False
162
+ self.stdout_thread = threading.Thread(target=self._read_stdout_thread, daemon=True)
163
+ self.stderr_thread = threading.Thread(target=self._read_stderr_thread, daemon=True)
164
+ self.stdout_thread.start()
165
+ self.stderr_thread.start()
166
+
167
+ def check_alive(self, os_error="", maybe_open_help=False):
168
+ ok = self.katago_process and self.katago_process.poll() is None
169
+ if not ok:
170
+ if self.katago_process:
171
+ code = self.katago_process and self.katago_process.poll()
172
+ if code == 3221225781:
173
+ died_msg = i18n._("Engine missing DLL")
174
+ else:
175
+ os_error += f"status {code}"
176
+ died_msg = i18n._("Engine died unexpectedly").format(error=os_error)
177
+ if code != 1 and not self.server_error: # deliberate exit, already showed message?
178
+ self.katrain.log(died_msg, OUTPUT_ERROR)
179
+ self.katago_process = None
180
+ return ok
181
+
182
+ def shutdown(self, finish=False):
183
+ process = self.katago_process
184
+ if process:
185
+ self.katago_process.stdin.write(b"forcequit\n")
186
+ self.katago_process.stdin.flush()
187
+ self.katago_process = None
188
+ process.terminate()
189
+ if finish is not None:
190
+ for t in [self.stderr_thread, self.stdout_thread]:
191
+ if t:
192
+ t.join()
193
+
194
+ def graceful_shutdown(self):
195
+ """respond to esc"""
196
+ if self.katago_process:
197
+ self.katago_process.stdin.write(b"quit\n")
198
+ self.katago_process.stdin.flush()
199
+ self.katrain.log("Finishing games in progress and stopping contribution", OUTPUT_KATAGO_STDERR)
200
+
201
+ def pause(self):
202
+ """respond to pause"""
203
+ if self.katago_process:
204
+ if not self.paused:
205
+ self.katago_process.stdin.write(b"pause\n")
206
+ self.katago_process.stdin.flush()
207
+ self.katrain.log("Pausing contribution", OUTPUT_KATAGO_STDERR)
208
+ else:
209
+ self.katago_process.stdin.write(b"resume\n")
210
+ self.katago_process.stdin.flush()
211
+ self.katrain.log("Resuming contribution", OUTPUT_KATAGO_STDERR)
212
+ self.paused = not self.paused
213
+
214
+ def _read_stderr_thread(self):
215
+ while self.katago_process is not None:
216
+ try:
217
+ line = self.katago_process.stderr.readline()
218
+ if line:
219
+ try:
220
+ message = line.decode(errors="ignore").strip()
221
+ if any(
222
+ s in message
223
+ for s in ["not status code 200 OK", "Server returned error", "Uncaught exception:"]
224
+ ):
225
+ message = message.replace("what():", "").replace("Uncaught exception:", "").strip()
226
+ self.server_error = message # don't be surprised by engine dying
227
+ self.katrain.log(message, OUTPUT_ERROR)
228
+ return
229
+ else:
230
+ self.katrain.log(message, OUTPUT_KATAGO_STDERR)
231
+ except Exception as e:
232
+ print("ERROR in processing KataGo stderr:", line, "Exception", e)
233
+ elif self.katago_process and not self.check_alive():
234
+ return
235
+ except Exception as e:
236
+ self.katrain.log(f"Exception in reading stdout {e}", OUTPUT_DEBUG)
237
+ return
238
+
239
+ def _read_stdout_thread(self):
240
+ while self.katago_process is not None:
241
+ try:
242
+ line = self.katago_process.stdout.readline()
243
+ if line:
244
+ line = line.decode(errors="ignore").strip()
245
+ if line.startswith("{"):
246
+ try:
247
+ analysis = json.loads(line)
248
+ if "gameId" in analysis:
249
+ game_id = analysis["gameId"]
250
+ if game_id in self.finished_games:
251
+ continue
252
+ current_game = self.active_games.get(game_id)
253
+ new_game = current_game is None
254
+ if new_game:
255
+ board_size = [analysis["boardXSize"], analysis["boardYSize"]]
256
+ placements = {
257
+ f"A{bw}": [
258
+ Move.from_gtp(move, pl).sgf(board_size)
259
+ for pl, move in analysis["initialStones"]
260
+ if pl == bw
261
+ ]
262
+ for bw in "BW"
263
+ }
264
+ game_properties = {k: v for k, v in placements.items() if v}
265
+ game_properties["SZ"] = f"{board_size[0]}:{board_size[1]}"
266
+ game_properties["KM"] = analysis["rules"]["komi"]
267
+ game_properties["RU"] = json.dumps(analysis["rules"])
268
+ game_properties["PB"] = analysis["blackPlayer"]
269
+ game_properties["PW"] = analysis["whitePlayer"]
270
+ current_game = BaseGame(
271
+ self.katrain, game_properties=game_properties, bypass_config=True
272
+ )
273
+ self.active_games[game_id] = current_game
274
+ last_node = current_game.sync_branch(
275
+ [Move.from_gtp(coord, pl) for pl, coord in analysis["moves"]]
276
+ )
277
+ last_node.set_analysis(analysis)
278
+ if new_game:
279
+ current_game.set_current_node(last_node)
280
+ self.start_time = self.start_time or time.time() - 1
281
+ self.move_count += 1
282
+ self.visits_count += analysis["rootInfo"]["visits"]
283
+ last_move = self.last_move_for_game[game_id]
284
+ self.last_move_for_game[game_id] = time.time()
285
+ dt = self.last_move_for_game[game_id] - last_move if last_move else 0
286
+ self.katrain.log(
287
+ f"[{time.time()-self.start_time:.1f}] Game {game_id} Move {analysis['turnNumber']}: {' '.join(analysis['move'])} Visits {analysis['rootInfo']['visits']} Time {dt:.1f}s\t Moves/min {60*self.move_count/(time.time()-self.start_time):.1f} Visits/s {self.visits_count/(time.time()-self.start_time):.1f}",
288
+ OUTPUT_DEBUG,
289
+ )
290
+ self.katrain("update-state")
291
+ except Exception as e:
292
+ traceback.print_exc()
293
+ self.katrain.log(f"Exception {e} in parsing or processing JSON: {line}", OUTPUT_ERROR)
294
+ elif "uploaded sgf" in line:
295
+ self.uploaded_games_count += 1
296
+ else:
297
+ self.katrain.log(line, OUTPUT_KATAGO_STDERR)
298
+ elif self.katago_process and not self.check_alive(): # stderr will do this
299
+ return
300
+ except Exception as e:
301
+ self.katrain.log(f"Exception in reading stdout {e}", OUTPUT_DEBUG)
302
+ return
katrain/katrain/core/game.py ADDED
@@ -0,0 +1,818 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import copy
2
+ import math
3
+ import os
4
+ import re
5
+ import threading
6
+ from datetime import datetime
7
+ from typing import Dict, List, Optional, Union
8
+
9
+ from kivy.clock import Clock
10
+
11
+ from katrain.core.constants import (
12
+ OUTPUT_DEBUG,
13
+ OUTPUT_EXTRA_DEBUG,
14
+ OUTPUT_INFO,
15
+ PLAYER_AI,
16
+ PLAYER_HUMAN,
17
+ PROGRAM_NAME,
18
+ SGF_INTERNAL_COMMENTS_MARKER,
19
+ STATUS_ANALYSIS,
20
+ STATUS_ERROR,
21
+ STATUS_INFO,
22
+ STATUS_TEACHING,
23
+ PRIORITY_GAME_ANALYSIS,
24
+ PRIORITY_EXTRA_ANALYSIS,
25
+ PRIORITY_SWEEP,
26
+ PRIORITY_ALTERNATIVES,
27
+ PRIORITY_EQUALIZE,
28
+ PRIORITY_DEFAULT,
29
+ )
30
+ from hongik.engine_ai import HongikAIEngine
31
+ from katrain.core.game_node import GameNode
32
+ from katrain.core.lang import i18n, rank_label
33
+ from katrain.core.sgf_parser import SGF, Move
34
+ from katrain.core.utils import var_to_grid, weighted_selection_without_replacement
35
+
36
+
37
+ class IllegalMoveException(Exception):
38
+ pass
39
+
40
+
41
+ class KaTrainSGF(SGF):
42
+ _NODE_CLASS = GameNode
43
+
44
+
45
+ class BaseGame:
46
+ """Represents a game of go, including an implementation of capture rules."""
47
+
48
+ DEFAULT_PROPERTIES = {"GM": 1, "FF": 4}
49
+
50
+ def __init__(
51
+ self,
52
+ katrain,
53
+ move_tree: GameNode = None,
54
+ game_properties: Optional[Dict] = None,
55
+ sgf_filename=None,
56
+ bypass_config=False, # TODO: refactor?
57
+ ):
58
+ self.katrain = katrain
59
+ self._lock = threading.Lock()
60
+ self.game_id = datetime.strftime(datetime.now(), "%Y-%m-%d %H %M %S")
61
+ self.sgf_filename = sgf_filename
62
+
63
+ self.insert_mode = False
64
+ self.external_game = False # not generated by katrain at some point
65
+
66
+ if move_tree:
67
+ self.root = move_tree
68
+ self.external_game = PROGRAM_NAME not in self.root.get_property("AP", "")
69
+ handicap = int(self.root.handicap)
70
+ num_starting_moves_black = 0
71
+ node = self.root
72
+ while node.children:
73
+ node = node.children[0]
74
+ if node.player == "B":
75
+ num_starting_moves_black += 1
76
+ else:
77
+ break
78
+
79
+ if (
80
+ handicap >= 2
81
+ and not self.root.placements
82
+ and not (num_starting_moves_black == handicap)
83
+ and not (self.root.children and self.root.children[0].placements)
84
+ ): # not really according to sgf, and not sure if still needed, last clause for fox
85
+ self.root.place_handicap_stones(handicap)
86
+ else:
87
+ default_properties = {**Game.DEFAULT_PROPERTIES, "DT": self.game_id}
88
+ if not bypass_config:
89
+ default_properties.update(
90
+ {
91
+ "SZ": katrain.config("game/size"),
92
+ "KM": katrain.config("game/komi"),
93
+ "RU": katrain.config("game/rules"),
94
+ }
95
+ )
96
+ self.root = GameNode(
97
+ properties={
98
+ **default_properties,
99
+ **(game_properties or {}),
100
+ }
101
+ )
102
+ handicap = katrain.config("game/handicap")
103
+ if not bypass_config and handicap:
104
+ self.root.place_handicap_stones(handicap)
105
+
106
+ if not self.root.get_property("RU"): # if rules missing in sgf, inherit current
107
+ self.root.set_property("RU", katrain.config("game/rules"))
108
+
109
+ self.set_current_node(self.root)
110
+ self.main_time_used = 0
111
+
112
+ # restore shortcuts
113
+ shortcut_id_to_node = {node.get_property("KTSID", None): node for node in self.root.nodes_in_tree}
114
+ for node in self.root.nodes_in_tree:
115
+ shortcut_id = node.get_property("KTSF", None)
116
+ if shortcut_id and shortcut_id in shortcut_id_to_node:
117
+ shortcut_id_to_node[shortcut_id].add_shortcut(node)
118
+
119
+ # -- move tree functions --
120
+ def _init_state(self):
121
+ board_size_x, board_size_y = self.board_size
122
+ self.board = [
123
+ [-1 for _x in range(board_size_x)] for _y in range(board_size_y)
124
+ ] # type: List[List[int]] # board pos -> chain id
125
+ self.chains = [] # type: List[List[Move]] # chain id -> chain
126
+ self.prisoners = [] # type: List[Move]
127
+ self.last_capture = [] # type: List[Move]
128
+
129
+ def _calculate_groups(self):
130
+ with self._lock:
131
+ self._init_state()
132
+ try:
133
+ for node in self.current_node.nodes_from_root:
134
+ for m in node.move_with_placements:
135
+ self._validate_move_and_update_chains(
136
+ m, True
137
+ ) # ignore ko since we didn't know if it was forced
138
+ if node.clear_placements: # handle AE by playing all moves left from empty board
139
+ clear_coords = {c.coords for c in node.clear_placements}
140
+ stones = [m for c in self.chains for m in c if m.coords not in clear_coords]
141
+ self._init_state()
142
+ for m in stones:
143
+ self._validate_move_and_update_chains(m, True)
144
+ except IllegalMoveException as e:
145
+ raise Exception(f"Unexpected illegal move ({str(e)})")
146
+
147
+ def _validate_move_and_update_chains(self, move: Move, ignore_ko: bool):
148
+ board_size_x, board_size_y = self.board_size
149
+
150
+ def neighbours(moves):
151
+ return {
152
+ self.board[m.coords[1] + dy][m.coords[0] + dx]
153
+ for m in moves
154
+ for dy, dx in [(-1, 0), (1, 0), (0, -1), (0, 1)]
155
+ if 0 <= m.coords[0] + dx < board_size_x and 0 <= m.coords[1] + dy < board_size_y
156
+ }
157
+
158
+ ko_or_snapback = len(self.last_capture) == 1 and self.last_capture[0] == move
159
+ self.last_capture = []
160
+
161
+ if move.is_pass:
162
+ return
163
+
164
+ if self.board[move.coords[1]][move.coords[0]] != -1:
165
+ raise IllegalMoveException("Space occupied")
166
+
167
+ # merge chains connected by this move, or create a new one
168
+ nb_chains = list({c for c in neighbours([move]) if c >= 0 and self.chains[c][0].player == move.player})
169
+ if nb_chains:
170
+ this_chain = nb_chains[0]
171
+ self.board = [[nb_chains[0] if sq in nb_chains else sq for sq in line] for line in self.board]
172
+ for oc in nb_chains[1:]:
173
+ self.chains[nb_chains[0]] += self.chains[oc]
174
+ self.chains[oc] = []
175
+ self.chains[nb_chains[0]].append(move)
176
+ else:
177
+ this_chain = len(self.chains)
178
+ self.chains.append([move])
179
+ self.board[move.coords[1]][move.coords[0]] = this_chain
180
+
181
+ # check captures
182
+ opp_nb_chains = {c for c in neighbours([move]) if c >= 0 and self.chains[c][0].player != move.player}
183
+ for c in opp_nb_chains:
184
+ if -1 not in neighbours(self.chains[c]): # no liberties
185
+ self.last_capture += self.chains[c]
186
+ for om in self.chains[c]:
187
+ self.board[om.coords[1]][om.coords[0]] = -1
188
+ self.chains[c] = []
189
+ if ko_or_snapback and len(self.last_capture) == 1 and not ignore_ko:
190
+ raise IllegalMoveException("Ko")
191
+ self.prisoners += self.last_capture
192
+
193
+ # suicide: check rules and throw exception if needed
194
+ if -1 not in neighbours(self.chains[this_chain]):
195
+ rules = self.rules
196
+ if len(self.chains[this_chain]) == 1: # even in new zealand rules, single stone suicide is not allowed
197
+ raise IllegalMoveException("Single stone suicide")
198
+ elif (isinstance(rules, str) and rules in ["tromp-taylor", "new zealand"]) or (
199
+ isinstance(rules, dict) and rules.get("suicide", False)
200
+ ):
201
+ self.last_capture += self.chains[this_chain]
202
+ for om in self.chains[this_chain]:
203
+ self.board[om.coords[1]][om.coords[0]] = -1
204
+ self.chains[this_chain] = []
205
+ self.prisoners += self.last_capture
206
+ else: # suicide not allowed by rules
207
+ raise IllegalMoveException("Suicide")
208
+
209
+ # Play a Move from the current position, raise IllegalMoveException if invalid.
210
+ def play(self, move: Move, ignore_ko: bool = False):
211
+ board_size_x, board_size_y = self.board_size
212
+ if not move.is_pass and not (0 <= move.coords[0] < board_size_x and 0 <= move.coords[1] < board_size_y):
213
+ raise IllegalMoveException(f"Move {move} outside of board coordinates")
214
+ try:
215
+ self._validate_move_and_update_chains(move, ignore_ko)
216
+ except IllegalMoveException:
217
+ self._calculate_groups()
218
+ raise
219
+ with self._lock:
220
+ played_node = self.current_node.play(move)
221
+ self.current_node = played_node
222
+ return played_node
223
+
224
+ # Insert a list of moves from root, often just adding one.
225
+ def sync_branch(self, moves: List[Move]):
226
+ node = self.root
227
+ with self._lock:
228
+ for move in moves:
229
+ node = node.play(move)
230
+ return node
231
+
232
+ def set_current_node(self, node):
233
+ self.current_node = node
234
+ self._calculate_groups()
235
+
236
+ def undo(self, n_times=1, stop_on_mistake=None):
237
+ break_on_branch = False
238
+ cn = self.current_node # avoid race conditions
239
+ break_on_main_branch = False
240
+ last_branching_node = cn
241
+ if n_times == "branch":
242
+ n_times = 9999
243
+ break_on_branch = True
244
+ elif n_times == "main-branch":
245
+ n_times = 9999
246
+ break_on_main_branch = True
247
+ for move in range(n_times):
248
+ if (
249
+ stop_on_mistake is not None
250
+ and cn.points_lost is not None
251
+ and cn.points_lost >= stop_on_mistake
252
+ and self.katrain.players_info[cn.player].player_type != PLAYER_AI
253
+ ):
254
+ self.set_current_node(cn.parent)
255
+ return
256
+ previous_cn = cn
257
+ if cn.shortcut_from:
258
+ cn = cn.shortcut_from
259
+ elif not cn.is_root:
260
+ cn = cn.parent
261
+ else:
262
+ break # root
263
+ if break_on_branch and len(cn.children) > 1:
264
+ break
265
+ elif break_on_main_branch and cn.ordered_children[0] != previous_cn: # implies > 1 child
266
+ last_branching_node = cn
267
+ if break_on_main_branch:
268
+ cn = last_branching_node
269
+ if cn is not self.current_node:
270
+ self.set_current_node(cn)
271
+
272
+ def redo(self, n_times=1, stop_on_mistake=None):
273
+ cn = self.current_node # avoid race conditions
274
+ for move in range(n_times):
275
+ if cn.children:
276
+ child = cn.ordered_children[0]
277
+ shortcut_to = [m for m, v in cn.shortcuts_to if child == v] # are we about to go to a shortcut node?
278
+ if shortcut_to:
279
+ child = shortcut_to[0]
280
+ cn = child
281
+ if (
282
+ move > 0
283
+ and stop_on_mistake is not None
284
+ and cn.points_lost is not None
285
+ and cn.points_lost >= stop_on_mistake
286
+ and self.katrain.players_info[cn.player].player_type != PLAYER_AI
287
+ ):
288
+ self.set_current_node(cn.parent)
289
+ return
290
+ if stop_on_mistake is None:
291
+ self.set_current_node(cn)
292
+
293
+ @property
294
+ def komi(self):
295
+ return self.root.komi
296
+
297
+ @property
298
+ def board_size(self):
299
+ return self.root.board_size
300
+
301
+ @property
302
+ def stones(self):
303
+ with self._lock:
304
+ return sum(self.chains, [])
305
+
306
+ @property
307
+ def end_result(self):
308
+ if self.current_node.end_state:
309
+ return self.current_node.end_state
310
+ if self.current_node.parent and self.current_node.is_pass and self.current_node.parent.is_pass:
311
+ return self.manual_score or i18n._("board-game-end") #홍익 살펴볼곳
312
+
313
+ @property
314
+ def prisoner_count(
315
+ self,
316
+ ) -> Dict: # returns prisoners that are of a certain colour as {B: black stones captures, W: white stones captures}
317
+ return {player: sum([m.player == player for m in self.prisoners]) for player in Move.PLAYERS}
318
+
319
+ @property
320
+ def rules(self):
321
+ return HongikAIEngine.get_rules(self.root.ruleset)
322
+
323
+ @property
324
+ def manual_score(self):
325
+ rules = self.rules
326
+ if (
327
+ not self.current_node.ownership
328
+ or str(rules).lower() not in ["jp", "japanese"]
329
+ or not self.current_node.parent
330
+ or not self.current_node.parent.ownership
331
+ ):
332
+ if not self.current_node.score:
333
+ return None
334
+ return self.current_node.format_score(round(2 * self.current_node.score) / 2) + "?"
335
+ board_size_x, board_size_y = self.board_size
336
+ mean_ownership = [(c + p) / 2 for c, p in zip(self.current_node.ownership, self.current_node.parent.ownership)]
337
+ ownership_grid = var_to_grid(mean_ownership, (board_size_x, board_size_y))
338
+ stones = {m.coords: m.player for m in self.stones}
339
+ lo_threshold = 0.15
340
+ hi_threshold = 0.85
341
+ max_unknown = 10
342
+ max_dame = 4 * (board_size_x + board_size_y)
343
+
344
+ def japanese_score_square(square, owner):
345
+ player = stones.get(square, None)
346
+ if (
347
+ (player == "B" and owner > hi_threshold)
348
+ or (player == "W" and owner < -hi_threshold)
349
+ or abs(owner) < lo_threshold
350
+ ):
351
+ return 0 # dame or own stones
352
+ if player is None and abs(owner) >= hi_threshold:
353
+ return round(owner) # surrounded empty intersection
354
+ if (player == "B" and owner < -hi_threshold) or (player == "W" and owner > hi_threshold):
355
+ return 2 * round(owner) # captured stone
356
+ return math.nan # unknown!
357
+
358
+ scored_squares = [
359
+ japanese_score_square((x, y), ownership_grid[y][x])
360
+ for y in range(board_size_y)
361
+ for x in range(board_size_x)
362
+ ]
363
+ num_sq = {t: sum([s == t for s in scored_squares]) for t in [-2, -1, 0, 1, 2]}
364
+ num_unkn = sum(math.isnan(s) for s in scored_squares)
365
+ prisoners = self.prisoner_count
366
+ score = sum([t * n for t, n in num_sq.items()]) + prisoners["W"] - prisoners["B"] - self.komi
367
+ self.katrain.log(
368
+ f"Manual Scoring: {num_sq} score by square with {num_unkn} unknown, {prisoners} captures, and {self.komi} komi -> score = {score}",
369
+ OUTPUT_DEBUG,
370
+ )
371
+ if num_unkn > max_unknown or (num_sq[0] - len(stones)) > max_dame:
372
+ return None
373
+ return self.current_node.format_score(score)
374
+
375
+ def __repr__(self):
376
+ return (
377
+ "\n".join("".join(self.chains[c][0].player if c >= 0 else "-" for c in line) for line in self.board)
378
+ + f"\ncaptures: {self.prisoner_count}"
379
+ )
380
+
381
+ def update_root_properties(self):
382
+ def player_name(player_info):
383
+ if player_info.name and player_info.player_type == PLAYER_HUMAN:
384
+ return player_info.name
385
+ else:
386
+ return f"{i18n._(player_info.player_type)} ({i18n._(player_info.player_subtype)}){SGF_INTERNAL_COMMENTS_MARKER}"
387
+
388
+ root_properties = self.root.properties
389
+ x_properties = {}
390
+ for bw in "BW":
391
+ if not self.external_game:
392
+ x_properties["P" + bw] = player_name(self.katrain.players_info[bw])
393
+ player_info = self.katrain.players_info[bw]
394
+ if player_info.player_type == PLAYER_AI:
395
+ x_properties[bw + "R"] = rank_label(player_info.calculated_rank)
396
+ if "+" in str(self.end_result):
397
+ x_properties["RE"] = self.end_result
398
+ self.root.properties = {**root_properties, **{k: [v] for k, v in x_properties.items()}}
399
+
400
+ def generate_filename(self):
401
+ self.update_root_properties()
402
+ player_names = {
403
+ bw: re.sub(r"[\u200b\u3164'<>:\"/\\|?*]", "", self.root.get_property("P" + bw, bw)) for bw in "BW"
404
+ }
405
+ base_game_name = f"{PROGRAM_NAME}_{player_names['B']} vs {player_names['W']}"
406
+ return f"{base_game_name} {self.game_id}.sgf"
407
+
408
+ def write_sgf(self, filename: str, trainer_config: Optional[Dict] = None):
409
+ if trainer_config is None:
410
+ trainer_config = self.katrain.config("trainer", {})
411
+ save_feedback = trainer_config.get("save_feedback", False)
412
+ eval_thresholds = trainer_config["eval_thresholds"]
413
+ save_analysis = trainer_config.get("save_analysis", False)
414
+ save_marks = trainer_config.get("save_marks", False)
415
+ self.update_root_properties()
416
+ show_dots_for = {
417
+ bw: trainer_config.get("eval_show_ai", True) or self.katrain.players_info[bw].human for bw in "BW"
418
+ }
419
+ sgf = self.root.sgf(
420
+ save_comments_player=show_dots_for,
421
+ save_comments_class=save_feedback,
422
+ eval_thresholds=eval_thresholds,
423
+ save_analysis=save_analysis,
424
+ save_marks=save_marks,
425
+ )
426
+ self.sgf_filename = filename
427
+ os.makedirs(os.path.dirname(filename), exist_ok=True)
428
+ with open(filename, "w", encoding="utf-8") as f:
429
+ f.write(sgf)
430
+ return i18n._("sgf written").format(file_name=filename)
431
+
432
+
433
+ class Game(BaseGame):
434
+ """Extensions related to analysis etc."""
435
+
436
+ def __init__(
437
+ self,
438
+ katrain,
439
+ engine: Union[Dict, HongikAIEngine],
440
+ move_tree: GameNode = None,
441
+ analyze_fast=False,
442
+ game_properties: Optional[Dict] = None,
443
+ sgf_filename=None,
444
+ ):
445
+ super().__init__(
446
+ katrain=katrain, move_tree=move_tree, game_properties=game_properties, sgf_filename=sgf_filename
447
+ )
448
+ if not isinstance(engine, Dict):
449
+ engine = {"B": engine, "W": engine}
450
+ self.engines = engine
451
+
452
+ self.insert_mode = False
453
+ self.insert_after = None
454
+ self.region_of_interest = None
455
+
456
+ threading.Thread(
457
+ target=lambda: self.analyze_all_nodes(analyze_fast=analyze_fast, even_if_present=False),
458
+ daemon=True,
459
+ ).start() # return faster, but bypass Kivy Clock
460
+
461
+ def analyze_all_nodes(self, priority=PRIORITY_GAME_ANALYSIS, analyze_fast=False, even_if_present=True):
462
+ for node in self.root.nodes_in_tree:
463
+ # forced, or not present, or something went wrong in loading
464
+ if even_if_present or not node.analysis_from_sgf or not node.load_analysis():
465
+ node.clear_analysis()
466
+ node.analyze(self.engines[node.next_player], priority=priority, analyze_fast=analyze_fast)
467
+
468
+ def set_current_node(self, node):
469
+ if self.insert_mode:
470
+ self.katrain.controls.set_status(i18n._("finish inserting before navigating"), STATUS_ERROR)
471
+ return
472
+ super().set_current_node(node)
473
+
474
+ def undo(self, n_times=1, stop_on_mistake=None):
475
+ if self.insert_mode: # in insert mode, undo = delete
476
+ cn = self.current_node # avoid race conditions
477
+ if n_times == 1 and cn not in self.insert_after.nodes_from_root:
478
+ cn.parent.children = [c for c in cn.parent.children if c != cn]
479
+ self.current_node = cn.parent
480
+ self._calculate_groups()
481
+ return
482
+ super().undo(n_times=n_times, stop_on_mistake=stop_on_mistake)
483
+
484
+ def reset_current_analysis(self):
485
+ cn = self.current_node
486
+ engine = self.engines[cn.next_player]
487
+ engine.terminate_queries(cn)
488
+ cn.clear_analysis()
489
+ cn.analyze(engine)
490
+
491
+ def redo(self, n_times=1, stop_on_mistake=None):
492
+ if self.insert_mode:
493
+ return
494
+ super().redo(n_times=n_times, stop_on_mistake=stop_on_mistake)
495
+
496
+ def set_insert_mode(self, mode):
497
+ if mode == "toggle":
498
+ mode = not self.insert_mode
499
+ if mode == self.insert_mode:
500
+ return
501
+ self.insert_mode = mode
502
+ if mode:
503
+ children = self.current_node.ordered_children
504
+ if not children:
505
+ self.insert_mode = False
506
+ else:
507
+ self.insert_after = self.current_node.ordered_children[0]
508
+ self.katrain.controls.set_status(i18n._("starting insert mode"), STATUS_INFO)
509
+ else:
510
+ copy_from_node = self.insert_after
511
+ copy_to_node = self.current_node
512
+ num_copied = 0
513
+ if copy_to_node != self.insert_after.parent:
514
+ above_insertion_root = self.insert_after.parent.nodes_from_root
515
+ already_inserted_moves = [
516
+ n.move for n in copy_to_node.nodes_from_root if n not in above_insertion_root and n.move
517
+ ]
518
+ try:
519
+ while True:
520
+ for m in copy_from_node.move_with_placements:
521
+ if m not in already_inserted_moves:
522
+ self._validate_move_and_update_chains(m, True)
523
+ # this inserts
524
+ copy_to_node = GameNode(
525
+ parent=copy_to_node, properties=copy.deepcopy(copy_from_node.properties)
526
+ )
527
+ num_copied += 1
528
+ if not copy_from_node.children:
529
+ break
530
+ copy_from_node = copy_from_node.ordered_children[0]
531
+ except IllegalMoveException:
532
+ pass # illegal move = stop
533
+ self._calculate_groups() # recalculate groups
534
+ self.katrain.controls.set_status(
535
+ i18n._("ending insert mode").format(num_copied=num_copied), STATUS_INFO
536
+ )
537
+ self.analyze_all_nodes(analyze_fast=True, even_if_present=False)
538
+ else:
539
+ self.katrain.controls.set_status("", STATUS_INFO)
540
+ self.katrain.controls.move_tree.insert_node = self.insert_after if self.insert_mode else None
541
+ self.katrain.controls.move_tree.redraw()
542
+ self.katrain.update_state(redraw_board=True)
543
+
544
+ # Play a Move from the current position, raise IllegalMoveException if invalid.
545
+ def play(self, move: Move, ignore_ko: bool = False, analyze=True):
546
+ played_node = super().play(move, ignore_ko)
547
+ if analyze:
548
+ if self.region_of_interest:
549
+ played_node.analyze(self.engines[played_node.next_player], analyze_fast=True)
550
+ played_node.analyze(self.engines[played_node.next_player], region_of_interest=self.region_of_interest)
551
+ else:
552
+ played_node.analyze(self.engines[played_node.next_player])
553
+ return played_node
554
+
555
+ def set_region_of_interest(self, region_of_interest):
556
+ x1, x2, y1, y2 = region_of_interest
557
+ xmin, xmax = min(x1, x2), max(x1, x2)
558
+ ymin, ymax = min(y1, y2), max(y1, y2)
559
+ szx, szy = self.board_size
560
+ if not (xmin == xmax and ymin == ymax) and not (xmax - xmin + 1 >= szx and ymax - ymin + 1 >= szy):
561
+ self.region_of_interest = [xmin, xmax, ymin, ymax]
562
+ else:
563
+ self.region_of_interest = None
564
+ self.katrain.controls.set_status("", OUTPUT_INFO)
565
+
566
+ def analyze_extra(self, mode, **kwargs):
567
+ stones = {s.coords for s in self.stones}
568
+ cn = self.current_node
569
+
570
+ if mode == "stop":
571
+ self.katrain.pondering = False
572
+ for e in set(self.engines.values()):
573
+ e.stop_pondering()
574
+ e.terminate_queries()
575
+ return
576
+
577
+ engine = self.engines[cn.next_player]
578
+
579
+ if mode == "ponder":
580
+ cn.analyze(
581
+ engine,
582
+ ponder=True,
583
+ priority=PRIORITY_EXTRA_ANALYSIS,
584
+ region_of_interest=self.region_of_interest,
585
+ time_limit=False,
586
+ )
587
+ return
588
+
589
+ if mode == "extra":
590
+ visits = cn.analysis_visits_requested + engine.config["max_visits"]
591
+ self.katrain.controls.set_status(i18n._("extra analysis").format(visits=visits), STATUS_ANALYSIS)
592
+ cn.analyze(
593
+ engine,
594
+ visits=visits,
595
+ priority=PRIORITY_EXTRA_ANALYSIS,
596
+ region_of_interest=self.region_of_interest,
597
+ time_limit=False,
598
+ )
599
+ return
600
+
601
+ if mode == "game":
602
+ nodes = self.root.nodes_in_tree
603
+ only_mistakes = kwargs.get("mistakes_only", False)
604
+ move_range = kwargs.get("move_range", None)
605
+ if move_range:
606
+ if move_range[1] < move_range[0]:
607
+ move_range = reversed(move_range)
608
+ threshold = self.katrain.config("trainer/eval_thresholds")[-4]
609
+ if "visits" in kwargs:
610
+ visits = kwargs["visits"]
611
+ else:
612
+ min_visits = min(node.analysis_visits_requested for node in nodes)
613
+ visits = min_visits + engine.config["max_visits"]
614
+ for node in nodes:
615
+ max_point_loss = max(c.points_lost or 0 for c in [node] + node.children)
616
+ if only_mistakes and max_point_loss <= threshold:
617
+ continue
618
+ if move_range and (not node.depth - 1 in range(move_range[0], move_range[1] + 1)):
619
+ continue
620
+ node.analyze(engine, visits=visits, priority=-1_000_000, time_limit=False, report_every=None)
621
+ if not move_range:
622
+ self.katrain.controls.set_status(i18n._("game re-analysis").format(visits=visits), STATUS_ANALYSIS)
623
+ else:
624
+ self.katrain.controls.set_status(
625
+ i18n._("move range analysis").format(
626
+ start_move=move_range[0], end_move=move_range[1], visits=visits
627
+ ),
628
+ STATUS_ANALYSIS,
629
+ )
630
+ return
631
+
632
+ elif mode == "sweep":
633
+ board_size_x, board_size_y = self.board_size
634
+
635
+ if cn.analysis_exists:
636
+ policy_grid = (
637
+ var_to_grid(self.current_node.policy, size=(board_size_x, board_size_y))
638
+ if self.current_node.policy
639
+ else None
640
+ )
641
+ analyze_moves = sorted(
642
+ [
643
+ Move(coords=(x, y), player=cn.next_player)
644
+ for x in range(board_size_x)
645
+ for y in range(board_size_y)
646
+ if (policy_grid is None and (x, y) not in stones) or policy_grid[y][x] >= 0
647
+ ],
648
+ key=lambda mv: -policy_grid[mv.coords[1]][mv.coords[0]],
649
+ )
650
+ else:
651
+ analyze_moves = [
652
+ Move(coords=(x, y), player=cn.next_player)
653
+ for x in range(board_size_x)
654
+ for y in range(board_size_y)
655
+ if (x, y) not in stones
656
+ ]
657
+ visits = engine.config["fast_visits"]
658
+ self.katrain.controls.set_status(i18n._("sweep analysis").format(visits=visits), STATUS_ANALYSIS)
659
+ priority = PRIORITY_SWEEP
660
+ elif mode in ["equalize", "alternative", "local"]:
661
+ if not cn.analysis_complete and mode != "local":
662
+ self.katrain.controls.set_status(i18n._("wait-before-extra-analysis"), STATUS_INFO, self.current_node)
663
+ return
664
+ if mode == "alternative": # also do a quick update on current candidates so it doesn't look too weird
665
+ self.katrain.controls.set_status(i18n._("alternative analysis"), STATUS_ANALYSIS)
666
+ cn.analyze(engine, priority=PRIORITY_ALTERNATIVES, time_limit=False, find_alternatives="alternative")
667
+ visits = engine.config["fast_visits"]
668
+ else: # equalize
669
+ visits = max(d["visits"] for d in cn.analysis["moves"].values())
670
+ self.katrain.controls.set_status(i18n._("equalizing analysis").format(visits=visits), STATUS_ANALYSIS)
671
+ priority = PRIORITY_EQUALIZE
672
+ analyze_moves = [Move.from_gtp(gtp, player=cn.next_player) for gtp, _ in cn.analysis["moves"].items()]
673
+ else:
674
+ raise ValueError("Invalid analysis mode")
675
+
676
+ for move in analyze_moves:
677
+ if cn.analysis["moves"].get(move.gtp(), {"visits": 0})["visits"] < visits:
678
+ cn.analyze(
679
+ engine, priority=priority, visits=visits, refine_move=move, time_limit=False
680
+ ) # explicitly requested so take as long as you need
681
+
682
+ def selfplay(self, until_move, target_b_advantage=None):
683
+ cn = self.current_node
684
+
685
+ if target_b_advantage is not None:
686
+ analysis_kwargs = {"visits": max(25, self.katrain.config("engine/fast_visits"))}
687
+ engine_settings = {"wideRootNoise": 0.03}
688
+ else:
689
+ analysis_kwargs = engine_settings = {}
690
+
691
+ def set_analysis(node, result):
692
+ node.set_analysis(result)
693
+ analyze_and_play(node)
694
+
695
+ def request_analysis_for_node(node):
696
+ self.engines[node.player].request_analysis(
697
+ node,
698
+ callback=lambda result, _partial: set_analysis(node, result),
699
+ priority=PRIORITY_DEFAULT,
700
+ analyze_fast=True,
701
+ extra_settings=engine_settings,
702
+ **analysis_kwargs,
703
+ )
704
+
705
+ def analyze_and_play(node):
706
+ nonlocal cn, engine_settings
707
+ candidates = node.candidate_moves
708
+ if self.katrain.game is not self:
709
+ return # a new game happened
710
+ ai_thoughts = "Move generated by AI self-play\n"
711
+ if until_move != "end" and target_b_advantage is not None: # setup pos
712
+ if node.depth >= until_move or candidates[0]["move"] == "pass":
713
+ self.set_current_node(node)
714
+ return
715
+ target_score = cn.score + (node.depth - cn.depth + 1) * (target_b_advantage - cn.score) / (
716
+ until_move - cn.depth
717
+ )
718
+ max_loss = 5
719
+ stddev = min(3, 0.5 + (until_move - node.depth) * 0.15)
720
+ ai_thoughts += f"Selecting moves aiming at score {target_score:.1f} +/- {stddev:.2f} with < {max_loss} points lost\n"
721
+ if abs(node.score - target_score) < 3 * stddev:
722
+ weighted_cands = [
723
+ (
724
+ move,
725
+ math.exp(-0.5 * (abs(move["scoreLead"] - target_score) / stddev) ** 2)
726
+ * math.exp(-0.5 * (min(0, move["pointsLost"]) / max_loss) ** 2),
727
+ )
728
+ for i, move in enumerate(candidates)
729
+ if move["pointsLost"] < max_loss or i == 0
730
+ ]
731
+ move_info = weighted_selection_without_replacement(weighted_cands, 1)[0][0]
732
+ for move, wt in weighted_cands:
733
+ self.katrain.log(
734
+ f"{'* ' if move_info == move else ' '} {move['move']} {move['scoreLead']} {wt}",
735
+ OUTPUT_EXTRA_DEBUG,
736
+ )
737
+ ai_thoughts += f"Move option: {move['move']} score {move['scoreLead']:.2f} loss {move['pointsLost']:.2f} weight {wt:.3e}\n"
738
+ else: # we're a bit lost, far away from target, just push it closer
739
+ move_info = min(candidates, key=lambda move: abs(move["scoreLead"] - target_score))
740
+ self.katrain.log(
741
+ f"* Played {move_info['move']} {move_info['scoreLead']} because score deviation between current score {node.score} and target score {target_score} > {3*stddev}",
742
+ OUTPUT_EXTRA_DEBUG,
743
+ )
744
+ ai_thoughts += f"Move played to close difference between score {node.score:.1f} and target {target_score:.1f} quickly."
745
+
746
+ self.katrain.log(
747
+ f"Self-play until {until_move} target {target_b_advantage}: {len(candidates)} candidates -> move {move_info['move']} score {move_info['scoreLead']} point loss {move_info['pointsLost']}",
748
+ OUTPUT_DEBUG,
749
+ )
750
+ move = Move.from_gtp(move_info["move"], player=node.next_player)
751
+ elif candidates: # just selfplay to end
752
+ move = Move.from_gtp(candidates[0]["move"], player=node.next_player)
753
+ else: # 1 visit etc
754
+ polmoves = node.policy_ranking
755
+ move = polmoves[0][1] if polmoves else Move(None)
756
+ if move.is_pass:
757
+ if self.current_node == cn:
758
+ self.set_current_node(node)
759
+ return
760
+ new_node = GameNode(parent=node, move=move)
761
+ new_node.ai_thoughts = ai_thoughts
762
+ if until_move != "end" and target_b_advantage is not None:
763
+ self.set_current_node(new_node)
764
+ self.katrain.controls.set_status(
765
+ i18n._("setup game status message").format(move=new_node.depth, until_move=until_move),
766
+ STATUS_INFO,
767
+ )
768
+ else:
769
+ if node != cn:
770
+ node.remove_shortcut()
771
+ cn.add_shortcut(new_node)
772
+
773
+ self.katrain.controls.move_tree.redraw_tree_trigger()
774
+ request_analysis_for_node(new_node)
775
+
776
+ request_analysis_for_node(cn)
777
+
778
+ def analyze_undo(self, node):
779
+ train_config = self.katrain.config("trainer")
780
+ move = node.move
781
+ if node != self.current_node or node.auto_undo is not None or not node.analysis_complete or not move:
782
+ return
783
+ points_lost = node.points_lost
784
+ thresholds = train_config["eval_thresholds"]
785
+ num_undo_prompts = train_config["num_undo_prompts"]
786
+ i = 0
787
+ while i < len(thresholds) and points_lost < thresholds[i]:
788
+ i += 1
789
+ num_undos = num_undo_prompts[i] if i < len(num_undo_prompts) else 0
790
+ if num_undos == 0:
791
+ undo = False
792
+ elif num_undos < 1: # probability
793
+ undo = int(node.undo_threshold < num_undos) and len(node.parent.children) == 1
794
+ else:
795
+ undo = len(node.parent.children) <= num_undos
796
+
797
+ node.auto_undo = undo
798
+ if undo:
799
+ self.undo(1)
800
+ self.katrain.controls.set_status(
801
+ i18n._("teaching undo message").format(move=move.gtp(), points_lost=points_lost), STATUS_TEACHING
802
+ )
803
+ self.katrain.update_state()
804
+
805
+ def get_score(self):
806
+ if hasattr(self.engine, 'get_score'): # 우리 엔진에 get_score가 있는지 확인
807
+ score_data = self.engine.get_score(self.current_node)
808
+ if score_data:
809
+ self._score = score_data
810
+ self.end_result = f'B+R' if score_data['winner'] == 'B' else 'W+R' # 임시 결과 문자열
811
+ if 'score' in score_data:
812
+ self.end_result = f"{score_data['winner']}+{score_data['score']}"
813
+ return self._score
814
+
815
+ # 만약 우리 엔진에 기능이 없으면 원래 로직을 수행 (안전장치)
816
+ if self.engine:
817
+ return self.engine.get_score(self.current_node)
818
+ return self._score
katrain/katrain/core/game_node.py ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import copy
3
+ import gzip
4
+ import json
5
+ import random
6
+ from typing import Dict, List, Optional, Tuple
7
+
8
+ from katrain.core.constants import (
9
+ ANALYSIS_FORMAT_VERSION,
10
+ PROGRAM_NAME,
11
+ REPORT_DT,
12
+ SGF_INTERNAL_COMMENTS_MARKER,
13
+ SGF_SEPARATOR_MARKER,
14
+ VERSION,
15
+ PRIORITY_DEFAULT,
16
+ ADDITIONAL_MOVE_ORDER,
17
+ )
18
+ from katrain.core.lang import i18n
19
+ from katrain.core.sgf_parser import Move, SGFNode
20
+ from katrain.core.utils import evaluation_class, pack_floats, unpack_floats, var_to_grid
21
+ from katrain.gui.theme import Theme
22
+
23
+
24
+ def analysis_dumps(analysis):
25
+ analysis = copy.deepcopy(analysis)
26
+ for movedict in analysis["moves"].values():
27
+ if "ownership" in movedict: # per-move ownership rarely used
28
+ del movedict["ownership"]
29
+ ownership_data = pack_floats(analysis.pop("ownership"))
30
+ policy_data = pack_floats(analysis.pop("policy"))
31
+ main_data = json.dumps(analysis).encode("utf-8")
32
+ return [
33
+ base64.standard_b64encode(gzip.compress(data)).decode("utf-8")
34
+ for data in [ownership_data, policy_data, main_data]
35
+ ]
36
+
37
+
38
+ class GameNode(SGFNode):
39
+ """Represents a single game node, with one or more moves and placements."""
40
+
41
+ def __init__(self, parent=None, properties=None, move=None):
42
+ super().__init__(parent=parent, properties=properties, move=move)
43
+ self.auto_undo = None # None = not analyzed. False: not undone (good move). True: undone (bad move)
44
+ self.played_mistake_sound = None
45
+ self.ai_thoughts = ""
46
+ self.note = ""
47
+ self.move_number = 0
48
+ self.time_used = 0
49
+ self.undo_threshold = random.random() # for fractional undos
50
+ self.end_state = None
51
+ self.shortcuts_to = []
52
+ self.shortcut_from = None
53
+ self.analysis_from_sgf = None
54
+ self.clear_analysis()
55
+
56
+ def add_shortcut(self, to_node): # collapses the branch between them
57
+ nodes = [to_node]
58
+ while nodes[-1].parent and nodes[-1] != self: # ensure on path
59
+ nodes.append(nodes[-1].parent)
60
+ if nodes[-1] == self and len(nodes) > 2:
61
+ via = nodes[-2]
62
+ self.shortcuts_to.append((to_node, via)) # and first child
63
+ to_node.shortcut_from = self
64
+
65
+ def remove_shortcut(self):
66
+ from_node = self.shortcut_from
67
+ if from_node:
68
+ from_node.shortcuts_to = [(m, v) for m, v in from_node.shortcuts_to if m != self]
69
+ self.shortcut_from = None
70
+
71
+ def load_analysis(self):
72
+ if not self.analysis_from_sgf:
73
+ return False
74
+ try:
75
+ szx, szy = self.root.board_size
76
+ board_squares = szx * szy
77
+ version = self.root.get_property("KTV", ANALYSIS_FORMAT_VERSION)
78
+ if version > ANALYSIS_FORMAT_VERSION:
79
+ raise ValueError(f"Can not decode analysis data with version {version}, please update {PROGRAM_NAME}")
80
+ ownership_data, policy_data, main_data, *_ = [
81
+ gzip.decompress(base64.standard_b64decode(data)) for data in self.analysis_from_sgf
82
+ ]
83
+ self.analysis = {
84
+ **json.loads(main_data),
85
+ "policy": unpack_floats(policy_data, board_squares + 1),
86
+ "ownership": unpack_floats(ownership_data, board_squares),
87
+ }
88
+ return True
89
+ except Exception as e:
90
+ print(f"Error in loading analysis: {e}")
91
+ return False
92
+
93
+ def add_list_property(self, property: str, values: List):
94
+ if property == "KT":
95
+ self.analysis_from_sgf = values
96
+ elif property == "C":
97
+ comments = [ # strip out all previously auto generated comments
98
+ c
99
+ for v in values
100
+ for c in v.split(SGF_SEPARATOR_MARKER)
101
+ if c.strip() and SGF_INTERNAL_COMMENTS_MARKER not in c
102
+ ]
103
+ self.note = "".join(comments).strip() # no super call intended, just save as note to be editable
104
+ else:
105
+ return super().add_list_property(property, values)
106
+
107
+ def clear_analysis(self):
108
+ self.analysis_visits_requested = 0
109
+ self.analysis = {"moves": {}, "root": None, "ownership": None, "policy": None, "completed": False}
110
+
111
+ def sgf_properties(
112
+ self,
113
+ save_comments_player=None,
114
+ save_comments_class=None,
115
+ eval_thresholds=None,
116
+ save_analysis=False,
117
+ save_marks=False,
118
+ ):
119
+ properties = copy.copy(super().sgf_properties())
120
+ note = self.note.strip()
121
+ if save_analysis and self.analysis_complete:
122
+ try:
123
+ properties["KT"] = analysis_dumps(self.analysis)
124
+ except Exception as e:
125
+ print(f"Error in saving analysis: {e}")
126
+ if self.points_lost and save_comments_class is not None and eval_thresholds is not None:
127
+ show_class = save_comments_class[evaluation_class(self.points_lost, eval_thresholds)]
128
+ else:
129
+ show_class = False
130
+ comments = properties.get("C", [])
131
+ if (
132
+ self.parent
133
+ and self.parent.analysis_exists
134
+ and self.analysis_exists
135
+ and (note or ((save_comments_player or {}).get(self.player, False) and show_class))
136
+ ):
137
+ if save_marks:
138
+ candidate_moves = self.parent.candidate_moves
139
+ top_x = Move.from_gtp(candidate_moves[0]["move"]).sgf(self.board_size)
140
+ best_sq = [
141
+ Move.from_gtp(d["move"]).sgf(self.board_size)
142
+ for d in candidate_moves
143
+ if d["pointsLost"] <= 0.5 and d["move"] != "pass" and d["order"] != 0
144
+ ]
145
+ if best_sq and "SQ" not in properties:
146
+ properties["SQ"] = best_sq
147
+ if top_x and "MA" not in properties:
148
+ properties["MA"] = [top_x]
149
+ comments.append("\n" + self.comment(sgf=True, interactive=False) + SGF_INTERNAL_COMMENTS_MARKER)
150
+ if self.is_root:
151
+ if save_marks:
152
+ comments = [i18n._("SGF start message") + SGF_INTERNAL_COMMENTS_MARKER + "\n"]
153
+ else:
154
+ comments = []
155
+ comments += [
156
+ *comments,
157
+ f"\nSGF generated by {PROGRAM_NAME} {VERSION}{SGF_INTERNAL_COMMENTS_MARKER}\n",
158
+ ]
159
+ properties["CA"] = ["UTF-8"]
160
+ properties["AP"] = [f"{PROGRAM_NAME}:{VERSION}"]
161
+ properties["KTV"] = [ANALYSIS_FORMAT_VERSION]
162
+ if self.shortcut_from:
163
+ properties["KTSF"] = [id(self.shortcut_from)]
164
+ elif "KTSF" in properties:
165
+ del properties["KTSF"]
166
+ if self.shortcuts_to:
167
+ properties["KTSID"] = [id(self)]
168
+ elif "KTSID" in properties:
169
+ del properties["KTSID"]
170
+ if note:
171
+ comments.insert(0, f"{self.note}\n") # user notes at top!
172
+ if comments:
173
+ properties["C"] = [SGF_SEPARATOR_MARKER.join(comments).strip("\n")]
174
+ elif "C" in properties:
175
+ del properties["C"]
176
+ return properties
177
+
178
+ @property
179
+ def board_size(self):
180
+ # ★★★ 핵심 수정: SZ 속성이 없을 경우 기본값 19를 사용합니다. ★★★
181
+ sz = self.get_property("SZ", 19)
182
+ try:
183
+ if isinstance(sz, str) and ":" in sz:
184
+ x, y = sz.split(":")
185
+ return int(x), int(y)
186
+ return int(sz), int(sz)
187
+ except (ValueError, TypeError):
188
+ return 19, 19 # 혹시 모를 다른 에러에도 대비
189
+
190
+ @staticmethod
191
+ def order_children(children):
192
+ return sorted(
193
+ children, key=lambda c: 0.5 if c.auto_undo is None else int(c.auto_undo)
194
+ ) # analyzed/not undone main, non-teach second, undone last
195
+
196
+ # various analysis functions
197
+ def analyze(
198
+ self,
199
+ engine,
200
+ priority=PRIORITY_DEFAULT,
201
+ visits=None,
202
+ ponder=False,
203
+ time_limit=True,
204
+ refine_move=None,
205
+ analyze_fast=False,
206
+ find_alternatives=False,
207
+ region_of_interest=None,
208
+ report_every=REPORT_DT,
209
+ ):
210
+ engine.request_analysis(
211
+ self,
212
+ callback=lambda result, partial_result: self.set_analysis(
213
+ result, refine_move, find_alternatives, region_of_interest, partial_result
214
+ ),
215
+ priority=priority,
216
+ visits=visits,
217
+ ponder=ponder,
218
+ analyze_fast=analyze_fast,
219
+ time_limit=time_limit,
220
+ next_move=refine_move,
221
+ find_alternatives=find_alternatives,
222
+ region_of_interest=region_of_interest,
223
+ report_every=report_every,
224
+ )
225
+
226
+ def update_move_analysis(self, move_analysis, move_gtp):
227
+ cur = self.analysis["moves"].get(move_gtp)
228
+ if cur is None:
229
+ self.analysis["moves"][move_gtp] = {
230
+ "move": move_gtp,
231
+ "order": ADDITIONAL_MOVE_ORDER,
232
+ **move_analysis,
233
+ } # some default values for keys missing in rootInfo
234
+ else:
235
+ cur["order"] = min(
236
+ cur["order"], move_analysis.get("order", ADDITIONAL_MOVE_ORDER)
237
+ ) # parent arriving after child
238
+ if cur["visits"] < move_analysis["visits"]:
239
+ cur.update(move_analysis)
240
+ else: # prior etc only
241
+ cur.update({k: v for k, v in move_analysis.items() if k not in cur})
242
+
243
+ def set_analysis(
244
+ self,
245
+ analysis_json: Dict,
246
+ refine_move: Optional[Move] = None,
247
+ additional_moves: bool = False,
248
+ region_of_interest=None,
249
+ partial_result: bool = False,
250
+ ):
251
+ if refine_move:
252
+ pvtail = analysis_json["moveInfos"][0]["pv"] if analysis_json["moveInfos"] else []
253
+ self.update_move_analysis(
254
+ {"pv": [refine_move.gtp()] + pvtail, **analysis_json["rootInfo"]}, refine_move.gtp()
255
+ )
256
+ else:
257
+ if additional_moves: # additional moves: old order matters, ignore new order
258
+ for m in analysis_json["moveInfos"]:
259
+ del m["order"]
260
+ elif refine_move is None: # normal update: old moves to end, new order matters. also for region?
261
+ for move_dict in self.analysis["moves"].values():
262
+ move_dict["order"] = ADDITIONAL_MOVE_ORDER # old moves to end
263
+ for move_analysis in analysis_json["moveInfos"]:
264
+ self.update_move_analysis(move_analysis, move_analysis["move"])
265
+ self.analysis["ownership"] = analysis_json.get("ownership")
266
+ self.analysis["policy"] = analysis_json.get("policy")
267
+ if not additional_moves and not region_of_interest:
268
+ self.analysis["root"] = analysis_json["rootInfo"]
269
+ if self.parent and self.move:
270
+ analysis_json["rootInfo"]["pv"] = [self.move.gtp()] + (
271
+ analysis_json["moveInfos"][0]["pv"] if analysis_json["moveInfos"] else []
272
+ )
273
+ self.parent.update_move_analysis(
274
+ analysis_json["rootInfo"], self.move.gtp()
275
+ ) # update analysis in parent for consistency
276
+ is_normal_query = refine_move is None and not additional_moves
277
+ self.analysis["completed"] = self.analysis["completed"] or (is_normal_query and not partial_result)
278
+
279
+ @property
280
+ def ownership(self):
281
+ return self.analysis.get("ownership")
282
+
283
+ @property
284
+ def policy(self):
285
+ return self.analysis.get("policy")
286
+
287
+ @property
288
+ def analysis_exists(self):
289
+ return self.analysis.get("root") is not None
290
+
291
+ @property
292
+ def analysis_complete(self):
293
+ return self.analysis["completed"] and self.analysis["root"] is not None
294
+
295
+ @property
296
+ def root_visits(self):
297
+ return ((self.analysis or {}).get("root") or {}).get("visits", 0)
298
+
299
+ @property
300
+ def score(self) -> Optional[float]:
301
+ if self.analysis_exists:
302
+ return self.analysis["root"].get("scoreLead")
303
+
304
+ def format_score(self, score=None):
305
+ score = score or self.score
306
+ if score is not None:
307
+ return f"{'B' if score >= 0 else 'W'}+{abs(score):.1f}"
308
+
309
+ @property
310
+ def winrate(self) -> Optional[float]:
311
+ if self.analysis_exists:
312
+ return self.analysis["root"].get("winrate")
313
+
314
+ def format_winrate(self, win_rate=None):
315
+ win_rate = win_rate or self.winrate
316
+ if win_rate is not None:
317
+ return f"{'B' if win_rate > 0.5 else 'W'} {max(win_rate,1-win_rate):.1%}"
318
+
319
+ def move_policy_stats(self) -> Tuple[Optional[int], float, List]:
320
+ single_move = self.move
321
+ if single_move and self.parent:
322
+ policy_ranking = self.parent.policy_ranking
323
+ if policy_ranking:
324
+ for ix, (p, m) in enumerate(policy_ranking):
325
+ if m == single_move:
326
+ return ix + 1, p, policy_ranking
327
+ return None, 0.0, []
328
+
329
+ def make_pv(self, player, pv, interactive):
330
+ pvtext = f"{player}{' '.join(pv)}"
331
+ if interactive:
332
+ pvtext = f"[u][ref={pvtext}][color={Theme.INFO_PV_COLOR}]{pvtext}[/color][/ref][/u]"
333
+ return pvtext
334
+
335
+ def comment(self, sgf=False, teach=False, details=False, interactive=True):
336
+ single_move = self.move
337
+ if not self.parent or not single_move: # root
338
+ if self.root:
339
+ rules = self.get_property("RU", "Japanese")
340
+ if isinstance(rules, str): # else katago dict
341
+ rules = i18n._(rules.lower())
342
+ return f"{i18n._('komi')}: {self.komi:.1f}\n{i18n._('ruleset')}: {rules}\n"
343
+ return ""
344
+
345
+ text = i18n._("move").format(number=self.depth) + f": {single_move.player} {single_move.gtp()}\n"
346
+ if self.analysis_exists:
347
+ score = self.score
348
+ if sgf:
349
+ text += i18n._("Info:score").format(score=self.format_score(score)) + "\n"
350
+ text += i18n._("Info:winrate").format(winrate=self.format_winrate()) + "\n"
351
+ if self.parent and self.parent.analysis_exists:
352
+ previous_top_move = self.parent.candidate_moves[0]
353
+ if sgf or details:
354
+ if previous_top_move["move"] != single_move.gtp():
355
+ points_lost = self.points_lost
356
+ if sgf and points_lost > 0.5:
357
+ text += i18n._("Info:point loss").format(points_lost=points_lost) + "\n"
358
+ top_move = previous_top_move["move"]
359
+ score = self.format_score(previous_top_move["scoreLead"])
360
+ text += (
361
+ i18n._("Info:top move").format(
362
+ top_move=top_move,
363
+ score=score,
364
+ )
365
+ + "\n"
366
+ )
367
+ else:
368
+ text += i18n._("Info:best move") + "\n"
369
+ if previous_top_move.get("pv") and (sgf or details):
370
+ pv = self.make_pv(single_move.player, previous_top_move["pv"], interactive)
371
+ text += i18n._("Info:PV").format(pv=pv) + "\n"
372
+ if sgf or details or teach:
373
+ currmove_pol_rank, currmove_pol_prob, policy_ranking = self.move_policy_stats()
374
+ if currmove_pol_rank is not None:
375
+ policy_rank_msg = i18n._("Info:policy rank")
376
+ text += policy_rank_msg.format(rank=currmove_pol_rank, probability=currmove_pol_prob) + "\n"
377
+ if currmove_pol_rank != 1 and policy_ranking and (sgf or details):
378
+ policy_best_msg = i18n._("Info:policy best")
379
+ pol_move, pol_prob = policy_ranking[0][1].gtp(), policy_ranking[0][0]
380
+ text += policy_best_msg.format(move=pol_move, probability=pol_prob) + "\n"
381
+ if self.auto_undo and sgf:
382
+ text += i18n._("Info:teaching undo") + "\n"
383
+ top_pv = self.analysis_exists and self.candidate_moves[0].get("pv")
384
+ if top_pv:
385
+ text += i18n._("Info:undo predicted PV").format(pv=f"{self.next_player}{' '.join(top_pv)}") + "\n"
386
+ else:
387
+ text = i18n._("No analysis available") if sgf else i18n._("Analyzing move...")
388
+
389
+ if self.ai_thoughts and (sgf or details):
390
+ text += "\n" + i18n._("Info:AI thoughts").format(thoughts=self.ai_thoughts)
391
+
392
+ if "C" in self.properties:
393
+ text += "\n[u]SGF Comments:[/u]\n" + "\n".join(self.properties["C"])
394
+
395
+ return text
396
+
397
+ @property
398
+ def points_lost(self) -> Optional[float]:
399
+ single_move = self.move
400
+ if single_move and self.parent and self.analysis_exists and self.parent.analysis_exists:
401
+ parent_score = self.parent.score
402
+ score = self.score
403
+ return self.player_sign(single_move.player) * (parent_score - score)
404
+
405
+ @property
406
+ def parent_realized_points_lost(self) -> Optional[float]:
407
+ single_move = self.move
408
+ if (
409
+ single_move
410
+ and self.parent
411
+ and self.parent.parent
412
+ and self.analysis_exists
413
+ and self.parent.parent.analysis_exists
414
+ ):
415
+ parent_parent_score = self.parent.parent.score
416
+ score = self.score
417
+ return self.player_sign(single_move.player) * (score - parent_parent_score)
418
+
419
+ @staticmethod
420
+ def player_sign(player):
421
+ return {"B": 1, "W": -1, None: 0}[player]
422
+
423
+ @property
424
+ def candidate_moves(self) -> List[Dict]:
425
+ if not self.analysis_exists:
426
+ return []
427
+ if not self.analysis["moves"]:
428
+ polmoves = self.policy_ranking
429
+ top_polmove = polmoves[0][1] if polmoves else Move(None) # if no info at all, pass
430
+ return [
431
+ {
432
+ **self.analysis["root"],
433
+ "pointsLost": 0,
434
+ "winrateLost": 0,
435
+ "order": 0,
436
+ "move": top_polmove.gtp(),
437
+ "pv": [top_polmove.gtp()],
438
+ }
439
+ ] # single visit -> go by policy/root
440
+
441
+ root_score = self.analysis["root"]["scoreLead"]
442
+ root_winrate = self.analysis["root"]["winrate"]
443
+ move_dicts = list(self.analysis["moves"].values()) # prevent incoming analysis from causing crash
444
+ top_move = [d for d in move_dicts if d["order"] == 0]
445
+ top_score_lead = top_move[0]["scoreLead"] if top_move else root_score
446
+ return sorted(
447
+ [
448
+ {
449
+ "pointsLost": self.player_sign(self.next_player) * (root_score - d["scoreLead"]),
450
+ "relativePointsLost": self.player_sign(self.next_player) * (top_score_lead - d["scoreLead"]),
451
+ "winrateLost": self.player_sign(self.next_player) * (root_winrate - d["winrate"]),
452
+ **d,
453
+ }
454
+ for d in move_dicts
455
+ ],
456
+ key=lambda d: (d["order"], d["pointsLost"]),
457
+ )
458
+
459
+ @property
460
+ def policy_ranking(self) -> Optional[List[Tuple[float, Move]]]: # return moves from highest policy value to lowest
461
+ if self.policy:
462
+ szx, szy = self.board_size
463
+ policy_grid = var_to_grid(self.policy, size=(szx, szy))
464
+ moves = [(policy_grid[y][x], Move((x, y), player=self.next_player)) for x in range(szx) for y in range(szy)]
465
+ moves.append((self.policy[-1], Move(None, player=self.next_player)))
466
+ return sorted(moves, key=lambda mp: -mp[0])
katrain/katrain/core/lang.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gettext
2
+ import os
3
+ import sys
4
+
5
+ from kivy._event import Observable
6
+
7
+ from katrain.core.utils import find_package_resource
8
+ from katrain.gui.theme import Theme
9
+
10
+
11
+ class Lang(Observable):
12
+ observers = []
13
+ callbacks = []
14
+ FONTS = {"jp": "NotoSansJP-Regular.otf", "tr": "NotoSans-Regular.ttf"}
15
+
16
+ def __init__(self, lang):
17
+ super(Lang, self).__init__()
18
+ self.lang = None
19
+ self.switch_lang(lang)
20
+
21
+ def _(self, text):
22
+ return self.ugettext(text)
23
+
24
+ def set_widget_font(self, widget):
25
+ widget.font_name = self.font_name
26
+ for sub_widget in [getattr(widget, "_hint_lbl", None), getattr(widget, "_msg_lbl", None)]: # MDText
27
+ if sub_widget:
28
+ sub_widget.font_name = self.font_name
29
+
30
+ def fbind(self, name, func, *args):
31
+ if name == "_":
32
+ widget, property, *_ = args[0]
33
+ self.observers.append((widget, func, args))
34
+ try:
35
+ self.set_widget_font(widget)
36
+ except Exception as e:
37
+ print(e)
38
+ # pass
39
+ else:
40
+ return super(Lang, self).fbind(name, func, *args)
41
+
42
+ def funbind(self, name, func, *args):
43
+ if name == "_":
44
+ widget, *_ = args[0]
45
+ key = (widget, func, args)
46
+ if key in self.observers:
47
+ self.observers.remove(key)
48
+ else:
49
+ return super(Lang, self).funbind(name, func, *args)
50
+
51
+ def switch_lang(self, lang):
52
+ if lang == self.lang:
53
+ return
54
+ # get the right locales directory, and instantiate a gettext
55
+ self.lang = lang
56
+ self.font_name = self.FONTS.get(lang) or Theme.DEFAULT_FONT
57
+ i18n_dir, _ = os.path.split(find_package_resource("katrain/i18n/__init__.py"))
58
+ locale_dir = os.path.join(i18n_dir, "locales")
59
+ locales = gettext.translation("katrain", locale_dir, languages=[lang, DEFAULT_LANGUAGE])
60
+ self.ugettext = locales.gettext
61
+
62
+ # update all the kv rules attached to this text
63
+ for widget, func, args in self.observers:
64
+ try:
65
+ func(args[0], None, None)
66
+ self.set_widget_font(widget)
67
+ except ReferenceError:
68
+ pass # proxy no longer exists
69
+ except Exception as e:
70
+ print("Error in switching languages", e)
71
+ for cb in self.callbacks:
72
+ try:
73
+ cb(self)
74
+ except Exception as e:
75
+ print(f"Failed callback on language change: {e}", file=sys.stderr)
76
+
77
+
78
+ DEFAULT_LANGUAGE = "en"
79
+ i18n = Lang(DEFAULT_LANGUAGE)
80
+
81
+
82
+ def rank_label(rank):
83
+ if rank is None:
84
+ return "??k"
85
+
86
+ if rank >= 0.5:
87
+ return f"{rank:.0f}{i18n._('strength:dan')}"
88
+ else:
89
+ return f"{1-rank:.0f}{i18n._('strength:kyu')}"
katrain/katrain/core/sgf_parser.py ADDED
@@ -0,0 +1,714 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import copy
2
+ import chardet
3
+ import math
4
+ import re
5
+ from collections import defaultdict
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+
9
+ class ParseError(Exception):
10
+ """Exception raised on a parse error"""
11
+
12
+ pass
13
+
14
+
15
+ class Move:
16
+ GTP_COORD = list("ABCDEFGHJKLMNOPQRSTUVWXYZ") + [
17
+ xa + c for xa in "ABCDEFGH" for c in "ABCDEFGHJKLMNOPQRSTUVWXYZ"
18
+ ] # board size 52+ support
19
+ PLAYERS = "BW"
20
+ SGF_COORD = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ".lower()) + list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") # sgf goes to 52
21
+
22
+ @classmethod
23
+ def from_gtp(cls, gtp_coords, player="B"):
24
+ """Initialize a move from GTP coordinates and player"""
25
+ if "pass" in gtp_coords.lower():
26
+ return cls(coords=None, player=player)
27
+ match = re.match(r"([A-Z]+)(\d+)", gtp_coords)
28
+ return cls(coords=(Move.GTP_COORD.index(match[1]), int(match[2]) - 1), player=player)
29
+
30
+ @classmethod
31
+ def from_sgf(cls, sgf_coords, board_size, player="B"):
32
+ """Initialize a move from SGF coordinates and player"""
33
+ if sgf_coords == "" or (
34
+ sgf_coords == "tt" and board_size[0] <= 19 and board_size[1] <= 19
35
+ ): # [tt] can be used as "pass" for <= 19x19 board
36
+ return cls(coords=None, player=player)
37
+ return cls(
38
+ coords=(Move.SGF_COORD.index(sgf_coords[0]), board_size[1] - Move.SGF_COORD.index(sgf_coords[1]) - 1),
39
+ player=player,
40
+ )
41
+
42
+ def __init__(self, coords: Optional[Tuple[int, int]] = None, player: str = "B"):
43
+ """Initialize a move from zero-based coordinates and player"""
44
+ self.player = player
45
+ self.coords = coords
46
+
47
+ def __repr__(self):
48
+ return f"Move({self.player or ''}{self.gtp()})"
49
+
50
+ def __eq__(self, other):
51
+ return self.coords == other.coords and self.player == other.player
52
+
53
+ def __hash__(self):
54
+ return hash((self.coords, self.player))
55
+
56
+ def gtp(self):
57
+ """Returns GTP coordinates of the move"""
58
+ if self.is_pass:
59
+ return "pass"
60
+ return Move.GTP_COORD[self.coords[0]] + str(self.coords[1] + 1)
61
+
62
+ def sgf(self, board_size):
63
+ """Returns SGF coordinates of the move"""
64
+ if self.is_pass:
65
+ return ""
66
+ return f"{Move.SGF_COORD[self.coords[0]]}{Move.SGF_COORD[board_size[1] - self.coords[1] - 1]}"
67
+
68
+ @property
69
+ def is_pass(self):
70
+ """Returns True if the move is a pass"""
71
+ return self.coords is None
72
+
73
+ @staticmethod
74
+ def opponent_player(player):
75
+ """Returns the opposing player, i.e. W <-> B"""
76
+ return "W" if player == "B" else "B"
77
+
78
+ @property
79
+ def opponent(self):
80
+ """Returns the opposing player, i.e. W <-> B"""
81
+ return self.opponent_player(self.player)
82
+
83
+
84
+ class SGFNode:
85
+ def __init__(self, parent=None, properties=None, move=None):
86
+ self.children = []
87
+ self.properties = defaultdict(list)
88
+ if properties:
89
+ for k, v in properties.items():
90
+ self.set_property(k, v)
91
+ self.parent = parent
92
+ if self.parent:
93
+ self.parent.children.append(self)
94
+ if parent and move:
95
+ self.set_property(move.player, move.sgf(self.board_size))
96
+ self._clear_cache()
97
+
98
+ def _clear_cache(self):
99
+ self.moves_cache = None
100
+
101
+ def __repr__(self):
102
+ return f"SGFNode({dict(self.properties)})"
103
+
104
+ def sgf_properties(self, **xargs) -> Dict:
105
+ """For hooking into in a subclass and overriding/formatting any additional properties to be output."""
106
+ return copy.deepcopy(self.properties)
107
+
108
+ @staticmethod
109
+ def order_children(children):
110
+ """For hooking into in a subclass and overriding branch order."""
111
+ return children
112
+
113
+ @property
114
+ def ordered_children(self):
115
+ return self.order_children(self.children)
116
+
117
+ @staticmethod
118
+ def _escape_value(value):
119
+ return re.sub(r"([\]\\])", r"\\\1", value) if isinstance(value, str) else value # escape \ and ]
120
+
121
+ @staticmethod
122
+ def _unescape_value(value):
123
+ return re.sub(r"\\([\]\\])", r"\1", value) if isinstance(value, str) else value # unescape \ and ]
124
+
125
+ def sgf(self, **xargs) -> str:
126
+ """Generates an SGF, calling sgf_properties on each node with the given xargs, so it can filter relevant properties if needed."""
127
+
128
+ def node_sgf_str(node):
129
+ return ";" + "".join(
130
+ [
131
+ prop + "".join(f"[{self._escape_value(v)}]" for v in values)
132
+ for prop, values in node.sgf_properties(**xargs).items()
133
+ if values
134
+ ]
135
+ )
136
+
137
+ stack = [")", self, "("]
138
+ sgf_str = ""
139
+ while stack:
140
+ item = stack.pop()
141
+ if isinstance(item, str):
142
+ sgf_str += item
143
+ else:
144
+ sgf_str += node_sgf_str(item)
145
+ if len(item.children) == 1:
146
+ stack.append(item.children[0])
147
+ elif item.children:
148
+ stack += sum([[")", c, "("] for c in item.ordered_children[::-1]], [])
149
+ return sgf_str
150
+
151
+ def add_list_property(self, property: str, values: List):
152
+ """Add some values to the property list."""
153
+ # SiZe[19] ==> SZ[19] etc. for old SGF
154
+ normalized_property = re.sub("[a-z]", "", property)
155
+ self._clear_cache()
156
+ self.properties[normalized_property] += values
157
+
158
+ def get_list_property(self, property, default=None) -> Any:
159
+ """Get the list of values for a property."""
160
+ return self.properties.get(property, default)
161
+
162
+ def set_property(self, property: str, value: Any):
163
+ """Add some values to the property. If not a list, it will be made into a single-value list."""
164
+ if not isinstance(value, list):
165
+ value = [value]
166
+ self._clear_cache()
167
+ self.properties[property] = value
168
+
169
+ def get_property(self, property, default=None) -> Any:
170
+ """Get the first value of the property, typically when exactly one is expected."""
171
+ return self.properties.get(property, [default])[0]
172
+
173
+ def clear_property(self, property) -> Any:
174
+ """Removes property if it exists."""
175
+ return self.properties.pop(property, None)
176
+
177
+ @property
178
+ def parent(self) -> Optional["SGFNode"]:
179
+ """Returns the parent node"""
180
+ return self._parent
181
+
182
+ @parent.setter
183
+ def parent(self, parent_node):
184
+ self._parent = parent_node
185
+ self._root = None
186
+ self._depth = None
187
+
188
+ @property
189
+ def root(self) -> "SGFNode":
190
+ """Returns the root of the tree, cached for speed"""
191
+ if self._root is None:
192
+ self._root = self.parent.root if self.parent else self
193
+ return self._root
194
+
195
+ @property
196
+ def depth(self) -> int:
197
+ """Returns the depth of this node, where root is 0, cached for speed"""
198
+ if self._depth is None:
199
+ moves = self.moves
200
+ if self.is_root:
201
+ self._depth = 0
202
+ else: # no increase on placements etc
203
+ self._depth = self.parent.depth + len(moves)
204
+ return self._depth
205
+
206
+ @property
207
+ def board_size(self) -> Tuple[int, int]:
208
+ """Retrieves the root's SZ property, or 19 if missing. Parses it, and returns board size as a tuple x,y"""
209
+ size = str(self.root.get_property("SZ", "19"))
210
+ if ":" in size:
211
+ x, y = map(int, size.split(":"))
212
+ else:
213
+ x = int(size)
214
+ y = x
215
+ return x, y
216
+
217
+ @property
218
+ def komi(self) -> float:
219
+ """Retrieves the root's KM property, or 6.5 if missing"""
220
+ try:
221
+ km_value = self.root.get_property("KM")
222
+ km = float(km_value or 6.5)
223
+ except ValueError:
224
+ km = 6.5
225
+
226
+ return km
227
+
228
+ @property
229
+ def handicap(self) -> int:
230
+ try:
231
+ return int(self.root.get_property("HA", 0))
232
+ except ValueError:
233
+ return 0
234
+
235
+ @property
236
+ def ruleset(self) -> str:
237
+ """Retrieves the root's RU property, or 'japanese' if missing"""
238
+ return self.root.get_property("RU", "japanese")
239
+
240
+ @property
241
+ def moves(self) -> List[Move]:
242
+ """Returns all moves in the node - typically 'move' will be better."""
243
+ if self.moves_cache is None:
244
+ self.moves_cache = [
245
+ Move.from_sgf(move, player=pl, board_size=self.board_size)
246
+ for pl in Move.PLAYERS
247
+ for move in self.get_list_property(pl, [])
248
+ ]
249
+ return self.moves_cache
250
+
251
+ def _expanded_placements(self, player):
252
+ sgf_pl = player if player is not None else "E" # AE
253
+ placements = self.get_list_property("A" + sgf_pl, [])
254
+ if not placements:
255
+ return []
256
+ to_be_expanded = [p for p in placements if ":" in p]
257
+ board_size = self.board_size
258
+ if to_be_expanded:
259
+ coords = {
260
+ Move.from_sgf(sgf_coord, player=player, board_size=board_size)
261
+ for sgf_coord in placements
262
+ if ":" not in sgf_coord
263
+ }
264
+ for p in to_be_expanded:
265
+ from_coord, to_coord = [Move.from_sgf(c, board_size=board_size) for c in p.split(":")[:2]]
266
+ for x in range(from_coord.coords[0], to_coord.coords[0] + 1):
267
+ for y in range(to_coord.coords[1], from_coord.coords[1] + 1): # sgf upside dn
268
+ if 0 <= x < board_size[0] and 0 <= y < board_size[1]:
269
+ coords.add(Move((x, y), player=player))
270
+ return list(coords)
271
+ else:
272
+ return [Move.from_sgf(sgf_coord, player=player, board_size=board_size) for sgf_coord in placements]
273
+
274
+ @property
275
+ def placements(self) -> List[Move]:
276
+ """Returns all placements (AB/AW) in the node."""
277
+ return [coord for pl in Move.PLAYERS for coord in self._expanded_placements(pl)]
278
+
279
+ @property
280
+ def clear_placements(self) -> List[Move]:
281
+ """Returns all AE clear square commends in the node."""
282
+ return self._expanded_placements(None)
283
+
284
+ @property
285
+ def move_with_placements(self) -> List[Move]:
286
+ """Returns all moves (B/W) and placements (AB/AW) in the node."""
287
+ return self.placements + self.moves
288
+
289
+ @property
290
+ def move(self) -> Optional[Move]:
291
+ """Returns the single move for the node if one exists, or None if no moves (or multiple ones) exist."""
292
+ moves = self.moves
293
+ if len(moves) == 1:
294
+ return moves[0]
295
+
296
+ @property
297
+ def is_root(self) -> bool:
298
+ """Returns true if node is a root"""
299
+ return self.parent is None
300
+
301
+ @property
302
+ def is_pass(self) -> bool:
303
+ """Returns true if associated move is pass"""
304
+ return not self.placements and self.move and self.move.is_pass
305
+
306
+ @property
307
+ def empty(self) -> bool:
308
+ """Returns true if node has no children or properties"""
309
+ return not self.children and not self.properties
310
+
311
+ @property
312
+ def nodes_in_tree(self) -> List:
313
+ """Returns all nodes in the tree rooted at this node"""
314
+ stack = [self]
315
+ nodes = []
316
+ while stack:
317
+ item = stack.pop(0)
318
+ nodes.append(item)
319
+ stack += item.children
320
+ return nodes
321
+
322
+ @property
323
+ def nodes_from_root(self) -> List:
324
+ """Returns all nodes from the root up to this node, i.e. the moves played in the current branch of the game"""
325
+ nodes = [self]
326
+ n = self
327
+ while not n.is_root:
328
+ n = n.parent
329
+ nodes.append(n)
330
+ return nodes[::-1]
331
+
332
+ def play(self, move) -> "SGFNode":
333
+ """Either find an existing child or create a new one with the given move."""
334
+ for c in self.children:
335
+ if c.move and c.move == move:
336
+ return c
337
+ return self.__class__(parent=self, move=move)
338
+
339
+ @property
340
+ def initial_player(self): # player for first node
341
+ root = self.root
342
+ if "PL" in root.properties: # explicit
343
+ return "B" if self.root.get_property("PL").upper().strip() == "B" else "W"
344
+ elif root.children: # child exist, use it if not placement
345
+ for child in root.children:
346
+ for color in "BW":
347
+ if color in child.properties:
348
+ return color
349
+ # b move or setup with only black moves like handicap
350
+ if "AB" in self.properties and "AW" not in self.properties:
351
+ return "W"
352
+ else:
353
+ return "B"
354
+
355
+ @property
356
+ def next_player(self):
357
+ """Returns player to move"""
358
+ if self.is_root:
359
+ return self.initial_player
360
+ elif "B" in self.properties:
361
+ return "W"
362
+ elif "W" in self.properties:
363
+ return "B"
364
+ else: # only placements, find a parent node with a real move. TODO: better placement support
365
+ return self.parent.next_player
366
+
367
+ @property
368
+ def player(self):
369
+ """Returns player that moved last. nb root is considered white played if no handicap stones are placed"""
370
+ if "B" in self.properties or ("AB" in self.properties and "W" not in self.properties):
371
+ return "B"
372
+ else:
373
+ return "W"
374
+
375
+ def place_handicap_stones(self, n_handicaps, tygem=False):
376
+ board_size_x, board_size_y = self.board_size
377
+ if min(board_size_x, board_size_y) < 3:
378
+ return # No
379
+ near_x = 3 if board_size_x >= 13 else min(2, board_size_x - 1)
380
+ near_y = 3 if board_size_y >= 13 else min(2, board_size_y - 1)
381
+ far_x = board_size_x - 1 - near_x
382
+ far_y = board_size_y - 1 - near_y
383
+ middle_x = board_size_x // 2 # what for even sizes?
384
+ middle_y = board_size_y // 2
385
+ if n_handicaps > 9 and board_size_x == board_size_y:
386
+ stones_per_row = math.ceil(math.sqrt(n_handicaps))
387
+ spacing = (far_x - near_x) / (stones_per_row - 1)
388
+ if spacing < near_x:
389
+ far_x += 1
390
+ near_x -= 1
391
+ spacing = (far_x - near_x) / (stones_per_row - 1)
392
+ coords = list({math.floor(0.5 + near_x + i * spacing) for i in range(stones_per_row)})
393
+ stones = sorted(
394
+ [(x, y) for x in coords for y in coords],
395
+ key=lambda xy: -((xy[0] - (board_size_x - 1) / 2) ** 2 + (xy[1] - (board_size_y - 1) / 2) ** 2),
396
+ )
397
+ else: # max 9
398
+ stones = [(far_x, far_y), (near_x, near_y), (far_x, near_y), (near_x, far_y)]
399
+ if n_handicaps % 2 == 1:
400
+ stones.append((middle_x, middle_y))
401
+ stones += [(near_x, middle_y), (far_x, middle_y), (middle_x, near_y), (middle_x, far_y)]
402
+ if tygem:
403
+ stones[2], stones[3] = stones[3], stones[2]
404
+ self.set_property(
405
+ "AB", list({Move(stone).sgf(board_size=(board_size_x, board_size_y)) for stone in stones[:n_handicaps]})
406
+ )
407
+
408
+
409
+ class SGF:
410
+ DEFAULT_ENCODING = "UTF-8"
411
+
412
+ _NODE_CLASS = SGFNode # Class used for SGF Nodes, can change this to something that inherits from SGFNode
413
+ # https://xkcd.com/1171/
414
+ SGFPROP_PAT = re.compile(r"\s*(?:\(|\)|;|(\w+)((\s*\[([^\]\\]|\\.)*\])+))", flags=re.DOTALL)
415
+ SGF_PAT = re.compile(r"\(;.*\)", flags=re.DOTALL)
416
+
417
+ @classmethod
418
+ def parse_sgf(cls, input_str) -> SGFNode:
419
+ """Parse a string as SGF."""
420
+ match = re.search(cls.SGF_PAT, input_str)
421
+ clipped_str = match.group() if match else input_str
422
+ root = cls(clipped_str).root
423
+ # Fix weird FoxGo server KM values
424
+ if "foxwq" in root.get_list_property("AP", []):
425
+ if int(root.get_property("HA", 0)) >= 1:
426
+ corrected_komi = 0.5
427
+ elif root.get_property("RU").lower() in ["chinese", "cn"]:
428
+ corrected_komi = 7.5
429
+ else:
430
+ corrected_komi = 6.5
431
+ root.set_property("KM", corrected_komi)
432
+ return root
433
+
434
+ @classmethod
435
+ def parse_file(cls, filename, encoding=None) -> SGFNode:
436
+ is_gib = filename.lower().endswith(".gib")
437
+ is_ngf = filename.lower().endswith(".ngf")
438
+
439
+ """Parse a file as SGF, encoding will be detected if not given."""
440
+ with open(filename, "rb") as f:
441
+ bin_contents = f.read()
442
+ if not encoding:
443
+ if is_gib or is_ngf or b"AP[foxwq]" in bin_contents:
444
+ encoding = "utf8"
445
+ else: # sgf
446
+ match = re.search(rb"CA\[(.*?)\]", bin_contents)
447
+ if match:
448
+ encoding = match[1].decode("ascii", errors="ignore")
449
+ else:
450
+ encoding = chardet.detect(bin_contents[:300])["encoding"]
451
+ # workaround for some compatibility issues for Windows-1252 and GB2312 encodings
452
+ if encoding == "Windows-1252" or encoding == "GB2312":
453
+ encoding = "GBK"
454
+ try:
455
+ decoded = bin_contents.decode(encoding=encoding, errors="ignore")
456
+ except LookupError:
457
+ decoded = bin_contents.decode(encoding=cls.DEFAULT_ENCODING, errors="ignore")
458
+ if is_ngf:
459
+ return cls.parse_ngf(decoded)
460
+ if is_gib:
461
+ return cls.parse_gib(decoded)
462
+ else: # sgf
463
+ return cls.parse_sgf(decoded)
464
+
465
+ def __init__(self, contents):
466
+ self.contents = contents
467
+ try:
468
+ self.ix = self.contents.index("(") + 1
469
+ except ValueError:
470
+ raise ParseError(f"Parse error: Expected '(' at start, found {self.contents[:50]}")
471
+ self.root = self._NODE_CLASS()
472
+ self._parse_branch(self.root)
473
+
474
+ def _parse_branch(self, current_move: SGFNode):
475
+ while self.ix < len(self.contents):
476
+ match = re.match(self.SGFPROP_PAT, self.contents[self.ix :])
477
+ if not match:
478
+ break
479
+ self.ix += len(match[0])
480
+ matched_item = match[0].strip()
481
+ if matched_item == ")":
482
+ return
483
+ if matched_item == "(":
484
+ self._parse_branch(self._NODE_CLASS(parent=current_move))
485
+ elif matched_item == ";":
486
+ # ignore ;) for old SGF
487
+ useless = self.ix < len(self.contents) and self.contents[self.ix :].strip() == ")"
488
+ # ignore ; that generate empty nodes
489
+ if not (current_move.empty or useless):
490
+ current_move = self._NODE_CLASS(parent=current_move)
491
+ else:
492
+ property, value = match[1], match[2].strip()[1:-1]
493
+ values = re.split(r"\]\s*\[", value)
494
+ current_move.add_list_property(property, [SGFNode._unescape_value(v) for v in values])
495
+ if self.ix < len(self.contents):
496
+ raise ParseError(f"Parse Error: unexpected character at {self.contents[self.ix:self.ix+25]}")
497
+ raise ParseError("Parse Error: expected ')' at end of input.")
498
+
499
+ # NGF parser adapted from https://github.com/fohristiwhirl/gofish/
500
+ @classmethod
501
+ def parse_ngf(cls, ngf):
502
+ ngf = ngf.strip()
503
+ lines = ngf.split("\n")
504
+
505
+ try:
506
+ boardsize = int(lines[1])
507
+ handicap = int(lines[5])
508
+ pw = lines[2].split()[0]
509
+ pb = lines[3].split()[0]
510
+ rawdate = lines[8][0:8]
511
+ komi = float(lines[7])
512
+
513
+ if handicap == 0 and int(komi) == komi:
514
+ komi += 0.5
515
+
516
+ except (IndexError, ValueError):
517
+ boardsize = 19
518
+ handicap = 0
519
+ pw = ""
520
+ pb = ""
521
+ rawdate = ""
522
+ komi = 0
523
+
524
+ re = ""
525
+ try:
526
+ if "hite win" in lines[10]:
527
+ re = "W+"
528
+ elif "lack win" in lines[10]:
529
+ re = "B+"
530
+ except IndexError:
531
+ pass
532
+
533
+ if handicap < 0 or handicap > 9:
534
+ raise ParseError(f"Handicap {handicap} out of range")
535
+
536
+ root = cls._NODE_CLASS()
537
+ node = root
538
+
539
+ # Set root values...
540
+
541
+ root.set_property("SZ", boardsize)
542
+
543
+ if handicap >= 2:
544
+ root.set_property("HA", handicap)
545
+ root.place_handicap_stones(handicap, tygem=True) # While this isn't Tygem, it uses the same layout
546
+
547
+ if komi:
548
+ root.set_property("KM", komi)
549
+
550
+ if len(rawdate) == 8:
551
+ ok = True
552
+ for n in range(8):
553
+ if rawdate[n] not in "0123456789":
554
+ ok = False
555
+ if ok:
556
+ date = rawdate[0:4] + "-" + rawdate[4:6] + "-" + rawdate[6:8]
557
+ root.set_property("DT", date)
558
+
559
+ if pw:
560
+ root.set_property("PW", pw)
561
+ if pb:
562
+ root.set_property("PB", pb)
563
+
564
+ if re:
565
+ root.set_property("RE", re)
566
+
567
+ # Main parser...
568
+
569
+ for line in lines:
570
+ line = line.strip().upper()
571
+
572
+ if len(line) >= 7:
573
+ if line[0:2] == "PM":
574
+ if line[4] in ["B", "W"]:
575
+
576
+ # move format is similar to SGF, but uppercase and out-by-1
577
+
578
+ key = line[4]
579
+ raw_move = line[5:7].lower()
580
+ if raw_move == "aa":
581
+ value = "" # pass
582
+ else:
583
+ value = chr(ord(raw_move[0]) - 1) + chr(ord(raw_move[1]) - 1)
584
+
585
+ node = cls._NODE_CLASS(parent=node)
586
+ node.set_property(key, value)
587
+
588
+ if len(root.children) == 0: # We'll assume we failed in this case
589
+ raise ParseError("Found no moves")
590
+
591
+ return root
592
+
593
+ # GIB parser adapted from https://github.com/fohristiwhirl/gofish/
594
+ @classmethod
595
+ def parse_gib(cls, gib):
596
+ def parse_player_name(raw):
597
+ name = raw
598
+ rank = ""
599
+ foo = raw.split("(")
600
+ if len(foo) == 2:
601
+ if foo[1][-1] == ")":
602
+ name = foo[0].strip()
603
+ rank = foo[1][0:-1]
604
+ return name, rank
605
+
606
+ def gib_make_result(grlt, zipsu):
607
+ easycases = {3: "B+R", 4: "W+R", 7: "B+T", 8: "W+T"}
608
+
609
+ if grlt in easycases:
610
+ return easycases[grlt]
611
+
612
+ if grlt in [0, 1]:
613
+ return "{}+{}".format("B" if grlt == 0 else "W", zipsu / 10)
614
+
615
+ return ""
616
+
617
+ def gib_get_result(line, grlt_regex, zipsu_regex):
618
+ try:
619
+ grlt = int(re.search(grlt_regex, line).group(1))
620
+ zipsu = int(re.search(zipsu_regex, line).group(1))
621
+ except: # noqa E722
622
+ return ""
623
+ return gib_make_result(grlt, zipsu)
624
+
625
+ root = cls._NODE_CLASS()
626
+ node = root
627
+
628
+ lines = gib.split("\n")
629
+ for line in lines:
630
+ line = line.strip()
631
+ if line.startswith("\\[GAMEBLACKNAME=") and line.endswith("\\]"):
632
+ s = line[16:-2]
633
+ name, rank = parse_player_name(s)
634
+ if name:
635
+ root.set_property("PB", name)
636
+ if rank:
637
+ root.set_property("BR", rank)
638
+
639
+ if line.startswith("\\[GAMEWHITENAME=") and line.endswith("\\]"):
640
+ s = line[16:-2]
641
+ name, rank = parse_player_name(s)
642
+ if name:
643
+ root.set_property("PW", name)
644
+ if rank:
645
+ root.set_property("WR", rank)
646
+
647
+ if line.startswith("\\[GAMEINFOMAIN="):
648
+ result = gib_get_result(line, r"GRLT:(\d+),", r"ZIPSU:(\d+),")
649
+ if result:
650
+ root.set_property("RE", result)
651
+ try:
652
+ komi = int(re.search(r"GONGJE:(\d+),", line).group(1)) / 10
653
+ if komi:
654
+ root.set_property("KM", komi)
655
+ except: # noqa E722
656
+ pass
657
+
658
+ if line.startswith("\\[GAMETAG="):
659
+ if "DT" not in root.properties:
660
+ try:
661
+ match = re.search(r"C(\d\d\d\d):(\d\d):(\d\d)", line)
662
+ date = "{}-{}-{}".format(match.group(1), match.group(2), match.group(3))
663
+ root.set_property("DT", date)
664
+ except: # noqa E722
665
+ pass
666
+
667
+ if "RE" not in root.properties:
668
+ result = gib_get_result(line, r",W(\d+),", r",Z(\d+),")
669
+ if result:
670
+ root.set_property("RE", result)
671
+
672
+ if "KM" not in root.properties:
673
+ try:
674
+ komi = int(re.search(r",G(\d+),", line).group(1)) / 10
675
+ if komi:
676
+ root.set_property("KM", komi)
677
+ except: # noqa E722
678
+ pass
679
+
680
+ if line[0:3] == "INI":
681
+ if node is not root:
682
+ raise ParseError("Node is not root")
683
+ setup = line.split()
684
+ try:
685
+ handicap = int(setup[3])
686
+ except ParseError:
687
+ continue
688
+
689
+ if handicap < 0 or handicap > 9:
690
+ raise ParseError(f"Handicap {handicap} out of range")
691
+
692
+ if handicap >= 2:
693
+ root.set_property("HA", handicap)
694
+ root.place_handicap_stones(handicap, tygem=True)
695
+
696
+ if line[0:3] == "STO":
697
+ move = line.split()
698
+ key = "B" if move[3] == "1" else "W"
699
+ try:
700
+ x = int(move[4])
701
+ y = 18 - int(move[5])
702
+ if not (0 <= x < 19 and 0 <= y < 19):
703
+ raise ParseError(f"Coordinates for move ({x},{y}) out of range on line {line}")
704
+ value = Move(coords=(x, y)).sgf(board_size=(19, 19))
705
+ except IndexError:
706
+ continue
707
+
708
+ node = cls._NODE_CLASS(parent=node)
709
+ node.set_property(key, value)
710
+
711
+ if len(root.children) == 0: # We'll assume we failed in this case
712
+ raise ParseError("No valid nodes found")
713
+
714
+ return root
katrain/katrain/core/tsumego_frame.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from katrain.core.game_node import GameNode
2
+ from katrain.core.sgf_parser import Move
3
+
4
+ # tsumego frame ported from lizgoban by kaorahi
5
+ # note: coords = (j, i) in katrain
6
+
7
+ near_to_edge = 2
8
+ offence_to_win = 5
9
+
10
+ BLACK = "B"
11
+ WHITE = "W"
12
+
13
+
14
+ def tsumego_frame_from_katrain_game(game, komi, black_to_play_p, ko_p, margin):
15
+ current_node = game.current_node
16
+ bw_board = [[game.chains[c][0].player if c >= 0 else "-" for c in line] for line in game.board]
17
+ isize, jsize = ij_sizes(bw_board)
18
+ blacks, whites, analysis_region = tsumego_frame(bw_board, komi, black_to_play_p, ko_p, margin)
19
+ sgf_blacks = katrain_sgf_from_ijs(blacks, isize, jsize, "B")
20
+ sgf_whites = katrain_sgf_from_ijs(whites, isize, jsize, "W")
21
+
22
+ played_node = GameNode(parent=current_node, properties={"AB": sgf_blacks, "AW": sgf_whites}) # this inserts
23
+
24
+ katrain_region = analysis_region and (analysis_region[1], analysis_region[0])
25
+ return (played_node, katrain_region)
26
+
27
+
28
+ def katrain_sgf_from_ijs(ijs, isize, jsize, player):
29
+ return [Move((j, i)).sgf((jsize, isize)) for i, j in ijs]
30
+
31
+
32
+ def tsumego_frame(bw_board, komi, black_to_play_p, ko_p, margin):
33
+ stones = stones_from_bw_board(bw_board)
34
+ filled_stones = tsumego_frame_stones(stones, komi, black_to_play_p, ko_p, margin)
35
+ region_pos = pick_all(filled_stones, "tsumego_frame_region_mark")
36
+ bw = pick_all(filled_stones, "tsumego_frame")
37
+ blacks = [(i, j) for i, j, black in bw if black]
38
+ whites = [(i, j) for i, j, black in bw if not black]
39
+ return (blacks, whites, get_analysis_region(region_pos))
40
+
41
+
42
+ def pick_all(stones, key):
43
+ return [[i, j, s.get("black")] for i, row in enumerate(stones) for j, s in enumerate(row) if s.get(key)]
44
+
45
+
46
+ def get_analysis_region(region_pos):
47
+ if len(region_pos) == 0:
48
+ return None
49
+ ai, aj, dummy = tuple(zip(*region_pos))
50
+ ri = (min(ai), max(ai))
51
+ rj = (min(aj), max(aj))
52
+ return ri[0] < ri[1] and rj[0] < rj[1] and (ri, rj)
53
+
54
+
55
+ def tsumego_frame_stones(stones, komi, black_to_play_p, ko_p, margin):
56
+ sizes = ij_sizes(stones)
57
+ isize, jsize = sizes
58
+ ijs = [
59
+ {"i": i, "j": j, "black": h.get("black")}
60
+ for i, row in enumerate(stones)
61
+ for j, h in enumerate(row)
62
+ if h.get("stone")
63
+ ]
64
+
65
+ if len(ijs) == 0:
66
+ return []
67
+ # find range of problem
68
+ top = min_by(ijs, "i", +1)
69
+ left = min_by(ijs, "j", +1)
70
+ bottom = min_by(ijs, "i", -1)
71
+ right = min_by(ijs, "j", -1)
72
+ imin = snap0(top["i"])
73
+ jmin = snap0(left["j"])
74
+ imax = snapS(bottom["i"], isize)
75
+ jmax = snapS(right["j"], jsize)
76
+ # flip/rotate for standard position
77
+ # don't mix flip and swap (FF = SS = identity, but SFSF != identity)
78
+ flip_spec = (
79
+ [False, False, True] if imin < jmin else [need_flip_p(imin, imax, isize), need_flip_p(jmin, jmax, jsize), False]
80
+ )
81
+ if True in flip_spec:
82
+ flipped = flip_stones(stones, flip_spec)
83
+ filled = tsumego_frame_stones(flipped, komi, black_to_play_p, ko_p, margin)
84
+ return flip_stones(filled, flip_spec)
85
+ # put outside stones
86
+ i0 = imin - margin
87
+ i1 = imax + margin
88
+ j0 = jmin - margin
89
+ j1 = jmax + margin
90
+ frame_range = [i0, i1, j0, j1]
91
+ black_to_attack_p = guess_black_to_attack([top, bottom, left, right], sizes)
92
+ put_border(stones, sizes, frame_range, black_to_attack_p)
93
+ put_outside(stones, sizes, frame_range, black_to_attack_p, black_to_play_p, komi)
94
+ put_ko_threat(stones, sizes, frame_range, black_to_attack_p, black_to_play_p, ko_p)
95
+ return stones
96
+
97
+
98
+ # detect corner/edge/center problems
99
+ # (avoid putting border stones on the first lines)
100
+ def snap(k, to):
101
+ return to if abs(k - to) <= near_to_edge else k
102
+
103
+
104
+ def snap0(k):
105
+ return snap(k, 0)
106
+
107
+
108
+ def snapS(k, size):
109
+ return snap(k, size - 1)
110
+
111
+
112
+ def min_by(ary, key, sign):
113
+ by = [sign * z[key] for z in ary]
114
+ return ary[by.index(min(by))]
115
+
116
+
117
+ def need_flip_p(kmin, kmax, size):
118
+ return kmin < size - kmax - 1
119
+
120
+
121
+ def guess_black_to_attack(extrema, sizes):
122
+ return sum([sign_of_color(z) * height2(z, sizes) for z in extrema]) > 0
123
+
124
+
125
+ def sign_of_color(z):
126
+ return 1 if z["black"] else -1
127
+
128
+
129
+ def height2(z, sizes):
130
+ isize, jsize = sizes
131
+ return height(z["i"], isize) + height(z["j"], jsize)
132
+
133
+
134
+ def height(k, size):
135
+ return size - abs(k - (size - 1) / 2)
136
+
137
+
138
+ ######################################
139
+ # sub
140
+
141
+
142
+ def put_border(stones, sizes, frame_range, is_black):
143
+ i0, i1, j0, j1 = frame_range
144
+ put_twin(stones, sizes, i0, i1, j0, j1, is_black, False)
145
+ put_twin(stones, sizes, j0, j1, i0, i1, is_black, True)
146
+
147
+
148
+ def put_twin(stones, sizes, beg, end, at0, at1, is_black, reverse_p):
149
+ for at in (at0, at1):
150
+ for k in range(beg, end + 1):
151
+ i, j = (at, k) if reverse_p else (k, at)
152
+ put_stone(stones, sizes, i, j, is_black, False, True)
153
+
154
+
155
+ def put_outside(stones, sizes, frame_range, black_to_attack_p, black_to_play_p, komi):
156
+ isize, jsize = sizes
157
+ count = 0
158
+ offense_komi = (+1 if black_to_attack_p else -1) * komi
159
+ defense_area = (isize * jsize - offense_komi - offence_to_win) / 2
160
+ for i in range(isize):
161
+ for j in range(jsize):
162
+ if inside_p(i, j, frame_range):
163
+ continue
164
+ count += 1
165
+ black_p = xor(black_to_attack_p, (count <= defense_area))
166
+ empty_p = (i + j) % 2 == 0 and abs(count - defense_area) > isize
167
+ put_stone(stones, sizes, i, j, black_p, empty_p)
168
+
169
+
170
+ # standard position:
171
+ # ? = problem, X = offense, O = defense
172
+ # OOOOOOOOOOOOO
173
+ # OOOOOOOOOOOOO
174
+ # OOOOOOOOOOOOO
175
+ # XXXXXXXXXXXXX
176
+ # XXXXXXXXXXXXX
177
+ # XXXX.........
178
+ # XXXX.XXXXXXXX
179
+ # XXXX.X???????
180
+ # XXXX.X???????
181
+
182
+ # (pattern, top_p, left_p)
183
+ offense_ko_threat = (
184
+ """
185
+ ....OOOX.
186
+ .....XXXX
187
+ """,
188
+ True,
189
+ False,
190
+ )
191
+
192
+ defense_ko_threat = (
193
+ """
194
+ ..
195
+ ..
196
+ X.
197
+ XO
198
+ OO
199
+ .O
200
+ """,
201
+ False,
202
+ True,
203
+ )
204
+
205
+
206
+ def put_ko_threat(stones, sizes, frame_range, black_to_attack_p, black_to_play_p, ko_p):
207
+ isize, jsize = sizes
208
+ for_offense_p = xor(ko_p, xor(black_to_attack_p, black_to_play_p))
209
+ pattern, top_p, left_p = offense_ko_threat if for_offense_p else defense_ko_threat
210
+ aa = [list(line) for line in pattern.splitlines() if len(line) > 0]
211
+ height, width = ij_sizes(aa)
212
+ for i, row in enumerate(aa):
213
+ for j, ch in enumerate(row):
214
+ ai = i + (0 if top_p else isize - height)
215
+ aj = j + (0 if left_p else jsize - width)
216
+ if inside_p(ai, aj, frame_range):
217
+ return
218
+ black = xor(black_to_attack_p, ch == "O")
219
+ empty = ch == "."
220
+ put_stone(stones, sizes, ai, aj, black, empty)
221
+
222
+
223
+ def xor(a, b):
224
+ return bool(a) != bool(b)
225
+
226
+
227
+ ######################################
228
+ # util
229
+
230
+
231
+ def flip_stones(stones, flip_spec):
232
+ swap_p = flip_spec[2]
233
+ sizes = ij_sizes(stones)
234
+ isize, jsize = sizes
235
+ new_isize, new_jsize = [jsize, isize] if swap_p else [isize, jsize]
236
+ new_stones = [[None for z in range(new_jsize)] for row in range(new_isize)]
237
+ for i, row in enumerate(stones):
238
+ for j, z in enumerate(row):
239
+ new_i, new_j = flip_ij((i, j), sizes, flip_spec)
240
+ new_stones[new_i][new_j] = z
241
+ return new_stones
242
+
243
+
244
+ def put_stone(stones, sizes, i, j, black, empty, tsumego_frame_region_mark=False):
245
+ isize, jsize = sizes
246
+ if i < 0 or isize <= i or j < 0 or jsize <= j:
247
+ return
248
+ stones[i][j] = (
249
+ {}
250
+ if empty
251
+ else {
252
+ "stone": True,
253
+ "tsumego_frame": True,
254
+ "black": black,
255
+ "tsumego_frame_region_mark": tsumego_frame_region_mark,
256
+ }
257
+ )
258
+
259
+
260
+ def inside_p(i, j, region):
261
+ i0, i1, j0, j1 = region
262
+ return i0 <= i and i <= i1 and j0 <= j and j <= j1
263
+
264
+
265
+ def stones_from_bw_board(bw_board):
266
+ return [[stone_from_str(s) for s in row] for row in bw_board]
267
+
268
+
269
+ def stone_from_str(s):
270
+ black = s == BLACK
271
+ white = s == WHITE
272
+ return {"stone": True, "black": black} if (black or white) else {}
273
+
274
+
275
+ def ij_sizes(stones):
276
+ return (len(stones), len(stones[0]))
277
+
278
+
279
+ def flip_ij(ij, sizes, flip_spec):
280
+ i, j = ij
281
+ isize, jsize = sizes
282
+ flip_i, flip_j, swap_ij = flip_spec
283
+ fi = flip1(i, isize, flip_i)
284
+ fj = flip1(j, jsize, flip_j)
285
+ return (fj, fi) if swap_ij else (fi, fj)
286
+
287
+
288
+ def flip1(k, size, flag):
289
+ return size - 1 - k if flag else k
katrain/katrain/core/utils.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import heapq
2
+ import math
3
+ import os
4
+ import random
5
+ import struct
6
+ import sys
7
+ from typing import List, Tuple, TypeVar
8
+
9
+ try:
10
+ import importlib.resources as pkg_resources
11
+ except ImportError:
12
+ import importlib_resources as pkg_resources
13
+
14
+ T = TypeVar("T")
15
+
16
+
17
+ def var_to_grid(array_var: List[T], size: Tuple[int, int]) -> List[List[T]]:
18
+ """convert ownership/policy to grid format such that grid[y][x] is for move with coords x,y"""
19
+ ix = 0
20
+ grid = [[]] * size[1]
21
+ for y in range(size[1] - 1, -1, -1):
22
+ grid[y] = array_var[ix : ix + size[0]]
23
+ ix += size[0]
24
+ return grid
25
+
26
+
27
+ def evaluation_class(points_lost, eval_thresholds):
28
+ i = 0
29
+ while i < len(eval_thresholds) - 1 and points_lost > eval_thresholds[i + 1]:
30
+ i += 1
31
+ return i
32
+
33
+ def check_thread(tb=False): # for checking if draws occur in correct thread
34
+ import threading
35
+
36
+ print("build in ", threading.current_thread().ident)
37
+ if tb:
38
+ import traceback
39
+
40
+ traceback.print_stack()
41
+
42
+
43
+ PATHS = {}
44
+
45
+
46
+ def find_package_resource(path, silent_errors=False):
47
+ global PATHS
48
+ if path.startswith("katrain"):
49
+ if not PATHS.get("PACKAGE"):
50
+ try:
51
+ with pkg_resources.path("katrain", "gui.kv") as p:
52
+ PATHS["PACKAGE"] = os.path.split(str(p))[0]
53
+ except (ModuleNotFoundError, FileNotFoundError, ValueError) as e:
54
+ print(f"Package path not found, installation possibly broken. Error: {e}", file=sys.stderr)
55
+ return f"FILENOTFOUND/{path}"
56
+ return os.path.join(PATHS["PACKAGE"], path.replace("katrain\\", "katrain/").replace("katrain/", ""))
57
+ else:
58
+ return os.path.abspath(os.path.expanduser(path)) # absolute path
59
+
60
+
61
+ def pack_floats(float_list):
62
+ if float_list is None:
63
+ return b""
64
+ return struct.pack(f"{len(float_list)}e", *float_list)
65
+
66
+
67
+ def unpack_floats(str, num):
68
+ if not str:
69
+ return None
70
+ return struct.unpack(f"{num}e", str)
71
+
72
+
73
+ def format_visits(n):
74
+ if n < 1000:
75
+ return str(n)
76
+ if n < 1e5:
77
+ return f"{n/1000:.1f}k"
78
+ if n < 1e6:
79
+ return f"{n/1000:.0f}k"
80
+ return f"{n/1e6:.0f}M"
81
+
82
+
83
+ def json_truncate_arrays(data, lim=20):
84
+ if isinstance(data, list):
85
+ if data and isinstance(data[0], dict):
86
+ return [json_truncate_arrays(d) for d in data]
87
+ if len(data) > lim:
88
+ data = [f"{len(data)} x {type(data[0]).__name__}"]
89
+ return data
90
+ elif isinstance(data, dict):
91
+ return {k: json_truncate_arrays(v) for k, v in data.items()}
92
+ else:
93
+ return data
94
+
95
+
96
+ def weighted_selection_without_replacement(items: List[Tuple], pick_n: int) -> List[Tuple]:
97
+ """For a list of tuples where the second element is a weight, returns random items with those weights, without replacement."""
98
+ elt = [(math.log(random.random()) / (item[1] + 1e-18), item) for item in items] # magic
99
+ return [e[1] for e in heapq.nlargest(pick_n, elt)] # NB fine if too small