Upload 211 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +44 -0
- katrain/CONTRIBUTIONS.md +79 -0
- katrain/ENGINE.md +74 -0
- katrain/INSTALL.md +177 -0
- katrain/LICENSE +40 -0
- katrain/README.md +253 -0
- katrain/THEMES.md +86 -0
- katrain/__main__.spec +38 -0
- katrain/__pycache__/board_ai.cpython-310.pyc +0 -0
- katrain/__pycache__/engine_ai.cpython-310.pyc +0 -0
- katrain/__pycache__/hongik_ai.cpython-310.pyc +0 -0
- katrain/fonts/Roboto-Black.ttf +3 -0
- katrain/fonts/Roboto-BlackItalic.ttf +3 -0
- katrain/fonts/Roboto-Bold.ttf +3 -0
- katrain/fonts/Roboto-BoldItalic.ttf +3 -0
- katrain/fonts/Roboto-Italic.ttf +3 -0
- katrain/fonts/Roboto-Light.ttf +3 -0
- katrain/fonts/Roboto-LightItalic.ttf +3 -0
- katrain/fonts/Roboto-Medium.ttf +3 -0
- katrain/fonts/Roboto-MediumItalic.ttf +3 -0
- katrain/fonts/Roboto-Regular.ttf +3 -0
- katrain/fonts/Roboto-Thin.ttf +3 -0
- katrain/fonts/Roboto-ThinItalic.ttf +3 -0
- katrain/i18n.py +125 -0
- katrain/katrain.py +4 -0
- katrain/katrain/__init__.py +0 -0
- katrain/katrain/__main__.py +455 -0
- katrain/katrain/__main__.spec +38 -0
- katrain/katrain/__pycache__/__init__.cpython-310.pyc +0 -0
- katrain/katrain/config.json +235 -0
- katrain/katrain/core/__init__.py +0 -0
- katrain/katrain/core/__pycache__/__init__.cpython-310.pyc +0 -0
- katrain/katrain/core/__pycache__/ai.cpython-310.pyc +0 -0
- katrain/katrain/core/__pycache__/base_katrain.cpython-310.pyc +0 -0
- katrain/katrain/core/__pycache__/constants.cpython-310.pyc +0 -0
- katrain/katrain/core/__pycache__/game.cpython-310.pyc +0 -0
- katrain/katrain/core/__pycache__/game_node.cpython-310.pyc +0 -0
- katrain/katrain/core/__pycache__/lang.cpython-310.pyc +0 -0
- katrain/katrain/core/__pycache__/sgf_parser.cpython-310.pyc +0 -0
- katrain/katrain/core/__pycache__/utils.cpython-310.pyc +0 -0
- katrain/katrain/core/ai.py +516 -0
- katrain/katrain/core/base_katrain.py +96 -0
- katrain/katrain/core/constants.py +272 -0
- katrain/katrain/core/contribute_engine.py +302 -0
- katrain/katrain/core/game.py +818 -0
- katrain/katrain/core/game_node.py +466 -0
- katrain/katrain/core/lang.py +89 -0
- katrain/katrain/core/sgf_parser.py +714 -0
- katrain/katrain/core/tsumego_frame.py +289 -0
- 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 |
+
[](http://github.com/sanderland/katrain/releases)
|
| 4 |
+
[](http://en.wikipedia.org/wiki/MIT_License)
|
| 5 |
+
[](http://github.com/sanderland/katrain/releases)
|
| 6 |
+
[](http://pepy.tech/project/katrain)
|
| 7 |
+
[](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 |
+
| [](https://www.youtube.com/watch?v=tXniX57KtKk) | [](http://www.youtube.com/watch?v=qjxkcKgrsbU) | [](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 |
+
[](http://github.com/sanderland/katrain/issues)
|
| 246 |
+
[](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 |
+

|
| 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 |
+

|
| 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
|