diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e8d0b8c --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +ignore = E501, E203, W503, E402 # line length, space before binary op, line break before binary op, import not at top +exclude = .git,__pycache__,build,dist \ No newline at end of file diff --git a/CONTRIBUTIONS.md b/CONTRIBUTIONS.md index 8220031..c3bcd76 100644 --- a/CONTRIBUTIONS.md +++ b/CONTRIBUTIONS.md @@ -11,6 +11,14 @@ This is not enforced, and contributions with incorrect formatting will be accept ## Translations +### Contributing to an existing translation + +* Go [here](https://github.com/sanderland/katrain/blob/master/katrain/i18n/locales/) and locate the `.po` file for your language. + * Alternatively, find the same file in the branch for the next version. +* Correct the relevant `msgstr` entries. + +### Adding a translation + Adding a translation requires making a new `.po` file with entries for that languages. * Copy the [English .po file](https://github.com/sanderland/katrain/blob/master/katrain/i18n/locales/en/LC_MESSAGES/katrain.po) @@ -19,19 +27,6 @@ Adding a translation requires making a new `.po` file with entries for that lang * The information at the top of the file should also not be translated. You can send me the resulting `.po` file and I will integrate it into the program. - -For those who have some python experience as well, you can: - -* Replicate the directory structure `katrain/i18n//LC_MESSAGES/katrain.po`. -* Run `python i18.py` which will generate the `.mo` files and check for any errors. -* Find `gui.kv` and add a button near the others: - ``` - LangButton: - icon: 'img/flaticon/flag-.png' - on_press: app.language = '' - ``` -* The language should now show up in the app. -* Pull request your changes to the latest minor version branch. # Contributors @@ -53,6 +48,8 @@ Many thanks to these additional authors: * "kaorahi" for bug fixes and SGF parser improvements. * "ajkenny84" for the red-green colourblind theme. * Lukasz Wierzbowski for the ability to paste urls for sgfs and helping fix alt-gr issues. +* Carton He for a fix to handling empty komi values in sgfs. +* "blamarche" for adding the board coordinates toggle ## Translators @@ -60,7 +57,7 @@ Many thanks to the following contributors for translations. * French: "Dontbtme" with contributions from "wonderingabout" * Korean: "isty2e" -* German: "nimets123" with contributions from "trohde" +* German: "nimets123", "trohde", and "Harleqin" * Spanish: Sergio Villegas ("serpiente") with contributions from the Spanish OGS community * Russian: Dmitry Ivankov and Alexander Kiselev * Simplified Chinese: Qing Mu with contributions from "Medwin" and Viktor Lin diff --git a/INSTALL.md b/INSTALL.md index e2a52fd..ae86c72 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -44,7 +44,7 @@ Simply download and run, everything is included. ### Installation from sources * Download the repository by clicking the green *Clone or download* on this page and *Download zip*. Extract the contents. -* Make sure you have a python installation, I will assume Anaconda (Python 3.7), available [here](https://www.anaconda.com/products/individual#download-section). +* 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). * Open 'Anaconda prompt' from the start menu and navigate to where you extracted the zip file using the `cd ` command. * Execute the command `pip3 install .` * Start the app by running `katrain` in the command prompt. @@ -53,7 +53,7 @@ Simply download and run, everything is included. ### Quick install guide -If you have a working Python 3.6/3.7/3.8 available, you should be able to simply: +If you have a working Python 3.6-3.8 available, you should be able to simply: * Run `pip3 install -U katrain` * Run the program by executing `katrain` in a terminal. @@ -94,11 +94,12 @@ Then, try installing python package dependencies using: pip3 install -r requirements.txt pip3 install screeninfo # Skip on MacOS, not working ``` -In case the sound is not working, or there is no available wheel for your OS or Python version, try: +In case the sound is not working, or there is no available wheel for your OS or Python version, try building kivy locally using: ```bash pip3 uninstall kivy -pip3 install --no-binary kivy kivy==2.0.0rc2 +pip3 install kivy --no-binary kivy ``` + You can now start KaTrain by running `python3 -m katrain` In case KataGo does not start, an alternative is to go [here](https://github.com/lightvector/KataGo) and compile KataGo yourself. diff --git a/LICENSE b/LICENSE index c972dd9..35f4d75 100644 --- a/LICENSE +++ b/LICENSE @@ -5,8 +5,8 @@ For on related licenses for these binaries and libraries see https://github.com/ 2. Icons from www.flaticon.com, used with permission with the following attributions: - Equalize icon and Thrash Icon: derived from work by bqlqn from www.flaticon.com -- Local search icon: derived from work by Pixel Perfect from www.flaticon.com -- Other Menu icons and Flag icons: derived from work by Freepik from www.flaticon.com +- Other Menu icons, Finish, and Flag icons: derived from work by Freepik from www.flaticon.com +- Collapse branch icon: derived from work by Kirill Kazachek from www.flaticon.com 3. The True Type Font DIGITAL-7 version 1.02 by Sizenko Alexander, which is free for non-commercial use. diff --git a/README.md b/README.md index ee5b446..1957b9a 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # KaTrain -[![Latest Release](https://img.shields.io/github/release/sanderland/katrain?label=download)](https://github.com/sanderland/katrain/releases) -[![License:MIT](https://img.shields.io/pypi/l/katrain)](https://en.wikipedia.org/wiki/MIT_License) -[![GitHub Downloads](https://img.shields.io/github/downloads/sanderland/katrain/total?color=%23336699&label=github%20downloads)](https://github.com/sanderland/katrain/releases) -[![PyPI Downloads](https://pepy.tech/badge/katrain)](https://pepy.tech/project/katrain) -[![Github sponsors](https://img.shields.io/static/v1?label=sponsor&message=7&logo=GitHub&color=dcb424&link=https://github.com/sponsors/sanderland/)](https://github.com/sponsors/sanderland) -[![Discord](https://img.shields.io/discord/417022162348802048?logo=discord)](https://discord.com/channels/417022162348802048/629446365688365067) +[![Latest Release](http://img.shields.io/github/release/sanderland/katrain?label=download)](http://github.com/sanderland/katrain/releases) +[![License:MIT](http://img.shields.io/pypi/l/katrain)](http://en.wikipedia.org/wiki/MIT_License) +[![GitHub Downloads](http://img.shields.io/github/downloads/sanderland/katrain/total?color=%23336699&label=github%20downloads)](http://github.com/sanderland/katrain/releases) +[![PyPI Downloads](http://pepy.tech/badge/katrain)](http://pepy.tech/project/katrain) +[![Github sponsors](http://img.shields.io/static/v1?label=sponsor&message=%E2%9D%A4&logo=GitHub&color=dcb424&link=http://github.com/sponsors/sanderland/)](http://github.com/sponsors/sanderland) +[![Discord](http://img.shields.io/discord/417022162348802048?logo=discord)](http://discord.com/channels/417022162348802048/629446365688365067)
@@ -24,15 +24,14 @@ -English -German -French -Spanish +English +German +French +Russian
-Russian -Chinese -Korean -Japanese +Chinese +Korean +Japanese
@@ -50,26 +49,18 @@ but has since grown to include a wide range of features, including: * Play against a stronger player and use the retry option instead of handicap stones. * Automatically generate focused SGF reviews which show your biggest mistakes. -## Previews and Youtube Videos +## Preview and Youtube Videos -### Play against an AI Teacher +![screenshot](http://raw.githubusercontent.com/sanderland/katrain/master/screenshots/analysis.png) -![screenshot](https://raw.githubusercontent.com/sanderland/katrain/master/screenshots/teaching.gif) - -### Analyze your games - -![screenshot](https://raw.githubusercontent.com/sanderland/katrain/master/screenshots/analysis.png) - -### YouTube videos - -| **New Features in v1.4** | **Analysis Tutorial** | **Teaching Game Tutorial** | +| **Local Joseki Analysis** | **Analysis Tutorial** | **Teaching Game Tutorial** | |:-----------------------------------------------------------------------------------------------------:|:-----------------------------------------------------------------------------------------------------:|:------------------------------------------------------------------------------------------------------------:| -| [![New Features Video](https://i.imgur.com/IXNwTHL.png)](https://www.youtube.com/watch?v=ujjRWGSZJrQ&feature=youtu.be) | [![Analysis Tutorial](https://i.imgur.com/3EP4IEr.png)](https://www.youtube.com/watch?v=qjxkcKgrsbU) | [![ Teaching Game Tutorial](https://i.imgur.com/jAdcSL5.png)](https://www.youtube.com/watch?v=wFl4Bab_eGM) | +| [![Local Joseki Analysi Video](http://i.imgur.com/YcpmSBx.png)](http://www.youtube.com/watch?v=ujjRWGSZJrQ&feature=youtu.be) | [![Analysis Tutorial](http://i.imgur.com/3EP4IEr.png)](http://www.youtube.com/watch?v=qjxkcKgrsbU) | [![ Teaching Game Tutorial](http://i.imgur.com/jAdcSL5.png)](http://www.youtube.com/watch?v=wFl4Bab_eGM) | ## Installation -* See the [releases page](https://github.com/sanderland/katrain/releases) for downloadable executables for Windows. +* See the [releases page](http://github.com/sanderland/katrain/releases) for downloadable executables for Windows. * Alternatively use `pip3 install -U katrain` to install the latest version from PyPI on any 64-bit OS. * [This page](INSTALL.md) has detailed instructions for Window, Linux and MacOS, as well as troubleshooting and setting up KataGo to use multiple GPUs. @@ -119,7 +110,7 @@ This estimate should be reasonably accurate as long as you have not changed the * **KataGo** is full KataGo, above professional level. The analysis and feedback given is always based on this full strength KataGo AI. * **Calibrated Rank Bot** was calibrated on various bots (e.g. GnuGo and Pachi at different strength settings) to play a balanced game from the opening to the endgame without making serious (DDK) blunders. Further discussion can be found - [here](https://github.com/sanderland/katrain/issues/44) and [here](https://github.com/sanderland/katrain/issues/74). + [here](http://github.com/sanderland/katrain/issues/44) and [here](http://github.com/sanderland/katrain/issues/74). * **Simple Style** Prefers moves that solidify both player's territory, leading to relatively simpler moves. * **ScoreLoss** is KataGo analyzing as usual, but choosing from potential moves depending on the expected score loss, leading to a varied style with mostly small mistakes. @@ -137,7 +128,7 @@ This estimate should be reasonably accurate as long as you have not changed the The Engine based AIs (KataGo, ScoreLoss, KataJigo) are affected by both the model and choice of visits and maximum time, while the policy net based AIs are affected by the choice of model file, but work identically with 1 visit. -Further technical details and discussion on these AIs can be found on [this](https://lifein19x19.com/viewtopic.php?f=10&t=17488&sid=b11e42c005bb6f4f48c83771e6a27eff) thread at the life in 19x19 forums. +Further technical details and discussion on 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. ## Analysis @@ -153,8 +144,8 @@ Keyboard shortcuts are shown with **[key]**. * The checkboxes at the top of the screen: * **[q]**: Child moves are shown. On by default, can turn it off to avoid obscuring other information or when wanting to guess the next move. - * **[w]**: Show all dots: Show all evaluation dots instead of the last few. - * You can configure how many are shown with this setting off, and whether they are shown for AIs under 'Configure Teacher'. + * **[w]**: Show all dots: Cycles through showing all evaluation dots, showing the last few, and showing none. + * You can configure whether they are shown for AIs under 'Teaching/Analysis Settings'. * **[e]**: Top moves: Show the next moves KataGo considered, colored by their expected point loss. Small/faint dots indicate high uncertainty and never show text (lower than your 'fast visits' setting). Hover over any of them to see the principal variation. @@ -167,7 +158,11 @@ Keyboard shortcuts are shown with **[key]**. * **[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. * **[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. * **[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. - * **[g]**: Find local responses: Searches within 5 spaces of the last move only. + * **[g]**: Select area of interest: set an area and search only for moves in this box. + Good for solving tsumegos. Note that some results may appear outside the box due to establishing a baseline for the best move, + and the opponent can tenuki in variations. + * **[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. + * **[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. * **[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. * **[enter]** AI move. Makes the AI move for the current player regardless of current player selection. @@ -175,15 +170,21 @@ Keyboard shortcuts are shown with **[key]**. In addition to shortcuts mentioned above and those shown in the main menu: -* **[Shift]**: Open the main menu. -* **[~]** or **[ ` ]** or **[m]**: Cycles through more minimalistic UI modes. +* **[Alt]**: Open the main menu. +* **[~]** or **[ ` ]** or **[F12]**: Cycles through more minimalistic UI modes. +* **[k]**: Toggle display of board coordinates. * **[p]**: Pass -* **[b]**: Pause/Resume timer -* **[arrow left]** or **[z]**: Undo move. Hold alt for 10 moves at a time, or ctrl to skip to the start. -* **[arrow right]** or **[x]**: Redo move. Hold alt for 10 moves at a time, or ctrl to skip to the start. +* **[pause]**: Pause/Resume timer +* **[arrow left]** or **[z]**: Undo move. Hold shift for 10 moves at a time, or ctrl to skip to the start. +* **[arrow right]** or **[x]**: Redo move. Hold shift for 10 moves at a time, or ctrl to skip to the end. * **[arrow up/down]** Switch branch, as would be expected from the move tree. -* **[n]** As in clicking the red arrow, go to one move before the next mistake (orange or worse) by a human player. * **[home/end]** Go to the beginning/end of the game. +* **[pageup]** Make the currently selected node the main branch +* **[Ctrl-delete]** Delete current node. +* **[c]** Collapse/Uncollapse the branch from the current node to the previous branching point. +* **[b]** Go back to the previous branching point. +* **[n]** As in clicking the forward red arrow, go to one move before the next mistake (orange or worse) by a human player. +* **[Shift-n]** As in clicking the backward red arrow, go to one move before the previous mistake. * **[scroll up]**: Undo move. Only works when hovering the cursor over the board. * **[scroll down]**: Redo move. Only works when hovering the cursor over the board. * **[click on a move]**: See detailed statistics for a previous move, along with expected variation that was best instead of this move. @@ -193,25 +194,28 @@ In addition to shortcuts mentioned above and those shown in the main menu: ## FAQ +* How can I create themes? + * See [these instructions](THEMES.md). * The program is running too slowly. How can I speed it up? * Adjust the number of visits or maximum time allowed in the settings. * KataGo crashes with out of memory errors, how can I prevent this? * Try using a lower number for `nnMaxBatchSize` in `KataGo/analysis_config.cfg`, and avoid using versions compiled with large board sizes. * If still encountering problems, please start KataGo by itself to check for any errors it gives. * 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. +* The font size is too small + * On some ultra-high resolution monitors, font scale is not set correctly. Please see [these](http://github.com/sanderland/katrain/issues/250) instructions to adjust them. ## Support / Contribute -[![GitHub issues](https://img.shields.io/github/issues/sanderland/katrain)](https://github.com/sanderland/katrain/issues) -[![Contributors](https://img.shields.io/static/v1?label=contributors&message=19&color=dcb424)](CONTRIBUTIONS.md) -[![Github sponsors](https://img.shields.io/static/v1?label=sponsor&message=7&logo=GitHub&color=dcb424&link=https://github.com/sponsors/sanderland/)](https://github.com/sponsors/sanderland) +[![GitHub issues](http://img.shields.io/github/issues/sanderland/katrain)](http://github.com/sanderland/katrain/issues) +[![Contributors](http://img.shields.io/static/v1?label=contributors&message=24&color=dcb424)](CONTRIBUTIONS.md) +[![Github sponsors](http://img.shields.io/static/v1?label=sponsor&message=%E2%9D%A4&logo=GitHub&color=dcb424&link=http://github.com/sponsors/sanderland/)](http://github.com/sponsors/sanderland) * Ideas, feedback, and contributions to code or translations are all very welcome. - * For suggestions and planned improvements, see [open issues](https://github.com/sanderland/katrain/issues) on github to check if the functionality is already planned. + * For suggestions and planned improvements, see [open issues](http://github.com/sanderland/katrain/issues) on github to check if the functionality is already planned. * I am looking for contributors of more translations of both this manual and the program itself. The best way to help with this is to contact me on discord. -* You can contact me on [discord](https://discord.gg/AjTPFpN) (Sander#3278), [KakaoTalk](https://open.kakao.com/o/gTsMJCac) - or [Reddit](http://reddit.com/u/sanderbaduk) to get help, discuss improvements, or simply show your appreciation. -* You can also donate to the project through [Github Sponsors](https://github.com/sponsors/sanderland). +* You can contact me on [discord](http://discord.gg/AjTPFpN) (Sander#3278) or [Reddit](http://reddit.com/u/sanderbaduk) to get help, discuss improvements, or simply show your appreciation. +* You can also donate to the project through [Github Sponsors](http://github.com/sponsors/sanderland). diff --git a/THEMES.md b/THEMES.md new file mode 100644 index 0000000..e1e66b9 --- /dev/null +++ b/THEMES.md @@ -0,0 +1,11 @@ +# Themes +Version 1.7 brings basic support for themes. + +* Look at the `Theme` class in `katrain/gui/theme.py`. +* Make a `theme.json` file in your `/.katrain` directory and specify any variables from the above class you want to override, e.g. + ```json + { + "BACKGROUND_COLOR": [1,0,0,1] +} + ``` +* All resources (including icons which can not be renamed for now) will be looked up in `/.katrain` first, so files with identical names there can be used to override sounds and images. \ No newline at end of file diff --git a/i18n.py b/i18n.py index 37f1dc0..9adf47b 100644 --- a/i18n.py +++ b/i18n.py @@ -1,7 +1,8 @@ +import copy +import glob import os import re import sys -import copy from collections import defaultdict import polib @@ -26,7 +27,7 @@ for lang in locales: pofile[lang] = os.path.join(localedir, lang, "LC_MESSAGES", "katrain.po") po[lang] = polib.pofile(pofile[lang]) for entry in po[lang].translated_entries(): - if "TODO" in entry.comment and not "DEPRECATED" in entry.comment: + if "TODO" in entry.comment and "DEPRECATED" not in entry.comment: todos[lang].append(entry) strings_to_langs[entry.msgid][lang] = entry strings_to_keys[entry.msgid][lang] = set(re.findall("{.*?}", entry.msgstr)) @@ -74,7 +75,7 @@ for lang in locales: for msgid, lang_entries in strings_to_langs.items(): if lang in lang_entries and "TODO" in lang_entries[lang].comment: - if any(e.msgstr == lang_entries[lang].msgstr for l, e in lang_entries.items() if l != lang): + if any(e.msgstr == lang_entries[lang].msgstr for ll, e in lang_entries.items() if ll != lang): todo_comment = ( f"TODO - {lang_entries[DEFAULT_LANG].comment}" if lang_entries[DEFAULT_LANG].comment else "TODO" ) # update todo @@ -100,4 +101,18 @@ for lang in locales: print("Fixed", pofile[lang], "and converted ->", mofile) +for ext in ["py", "kv"]: + lc = 0 + for file in glob.glob(f"katrain/*.{ext}") + glob.glob(f"katrain/**/*.{ext}"): + with open(file, "r") as f: + for i, line in enumerate(f.readlines()): + if line.strip(): + lc += 1 + matches = [m.strip() for m in re.findall(r"i18n._\((.*?)\)", line)] + for msgid in matches: + stripped_msgid = msgid.strip("\"'") + if stripped_msgid and msgid[0] in ['"', "'"] and stripped_msgid not in strings_to_langs: # not code + print(f"Missing {msgid} used in code at \t{file}:{i} \t'{line.strip()}'") + errors += 1 + print(f"Checked {lc} lines of {ext} code for missing i18n entries.") sys.exit(int(errors)) diff --git a/katrain/__main__.py b/katrain/__main__.py index e98a94a..dc1f76a 100644 --- a/katrain/__main__.py +++ b/katrain/__main__.py @@ -1,6 +1,10 @@ """isort:skip_file""" # first, logging level lower +import json import os +import kivy + +kivy.require("2.0.0") os.environ["KCFG_KIVY_LOG_LEVEL"] = os.environ.get("KCFG_KIVY_LOG_LEVEL", "warning") if "KIVY_AUDIO" not in os.environ: @@ -46,9 +50,13 @@ from kivy.lang import Builder from kivy.resources import resource_add_path from kivy.uix.popup import Popup from kivy.uix.screenmanager import Screen -from katrain.core.ai import generate_ai_move from kivy.core.window import Window +from kivy.uix.widget import Widget +from kivy.resources import resource_find +from kivy.properties import NumericProperty, ObjectProperty, StringProperty +from kivy.clock import Clock from kivy.metrics import dp +from katrain.core.ai import generate_ai_move from katrain.core.lang import DEFAULT_LANGUAGE, i18n from katrain.core.constants import ( @@ -57,7 +65,6 @@ from katrain.core.constants import ( OUTPUT_INFO, OUTPUT_DEBUG, OUTPUT_EXTRA_DEBUG, - MODE_PLAY, MODE_ANALYZE, HOMEPAGE, VERSION, @@ -65,18 +72,24 @@ from katrain.core.constants import ( STATUS_INFO, PLAYING_NORMAL, PLAYER_HUMAN, + SGF_INTERNAL_COMMENTS_MARKER, + MODE_PLAY, + DATA_FOLDER, ) -from katrain.gui.popups import ConfigTeacherPopup, ConfigTimerPopup, I18NPopup +from katrain.gui.popups import ConfigTeacherPopup, ConfigTimerPopup, I18NPopup, SaveSGFPopup from katrain.core.base_katrain import KaTrainBase from katrain.core.engine import KataGoEngine from katrain.core.game import Game, IllegalMoveException, KaTrainSGF from katrain.core.sgf_parser import Move, ParseError -from katrain.gui.kivyutils import * from katrain.gui.popups import ConfigPopup, LoadSGFPopup, NewGamePopup, ConfigAIPopup -from katrain.gui.style import ENGINE_BUSY_COL, ENGINE_DOWN_COL, ENGINE_READY_COL, LIGHTGREY -from katrain.gui.widgets import * -from katrain.gui.badukpan import AnalysisControls, BadukPanControls, BadukPanWidget -from katrain.gui.controlspanel import ControlsPanel +from katrain.gui.theme import Theme +from kivymd.app import MDApp + +# used in kv +from katrain.gui.kivyutils import * +from katrain.gui.widgets import MoveTree, I18NFileBrowser, SelectionSlider, ScoreGraph # noqa F401 +from katrain.gui.badukpan import AnalysisControls, BadukPanControls, BadukPanWidget # noqa F401 +from katrain.gui.controlspanel import ControlsPanel # noqa F401 class KaTrainGui(Screen, KaTrainBase): @@ -107,14 +120,14 @@ class KaTrainGui(Screen, KaTrainBase): super().log(message, level) if level == OUTPUT_KATAGO_STDERR and "ERROR" not in self.controls.status.text: if "starting" in message.lower(): - self.controls.set_status(f"KataGo engine starting...", STATUS_INFO) + self.controls.set_status("KataGo engine starting...", STATUS_INFO) if message.startswith("Tuning"): self.controls.set_status( - f"KataGo is tuning settings for first startup, please wait." + message, STATUS_INFO + "KataGo is tuning settings for first startup, please wait." + message, STATUS_INFO ) return if "ready" in message.lower(): - self.controls.set_status(f"KataGo engine ready.", STATUS_INFO) + self.controls.set_status("KataGo engine ready.", STATUS_INFO) if ( level == OUTPUT_ERROR or (level == OUTPUT_KATAGO_STDERR and "error" in message.lower() and "tuning" not in message.lower()) @@ -164,11 +177,11 @@ class KaTrainGui(Screen, KaTrainBase): # update engine status dot if not self.engine or not self.engine.katago_process or self.engine.katago_process.poll() is not None: - self.board_controls.engine_status_col = ENGINE_DOWN_COL + self.board_controls.engine_status_col = Theme.ENGINE_DOWN_COLOR elif len(self.engine.queries) == 0: - self.board_controls.engine_status_col = ENGINE_READY_COL + self.board_controls.engine_status_col = Theme.ENGINE_READY_COLOR else: - self.board_controls.engine_status_col = ENGINE_BUSY_COL + self.board_controls.engine_status_col = Theme.ENGINE_BUSY_COLOR self.board_controls.queries_remaining = len(self.engine.queries) # redraw board/stones @@ -216,6 +229,9 @@ class KaTrainGui(Screen, KaTrainBase): def update_player(self, bw, **kwargs): super().update_player(bw, **kwargs) + if self.game: + sgf_name = self.game.root.get_property("P" + bw) + self.players_info[bw].name = None if not sgf_name or SGF_INTERNAL_COMMENTS_MARKER in sgf_name else sgf_name if self.controls: self.controls.update_players() self.update_state() @@ -225,6 +241,7 @@ class KaTrainGui(Screen, KaTrainBase): def set_note(self, note): self.game.current_node.note = note + # The message loop is here to make sure moves happen in the right order, and slow operations don't hang the GUI def _message_loop_thread(self): while True: game, msg, args, kwargs = self.message_queue.get() @@ -251,26 +268,29 @@ class KaTrainGui(Screen, KaTrainBase): else: # game related actions self.message_queue.put([self.game.game_id, message, args, kwargs]) - def _do_new_game(self, move_tree=None, analyze_fast=False): + def _do_new_game(self, move_tree=None, analyze_fast=False, sgf_filename=None): self.idle_analysis = False mode = self.play_analyze_mode if (move_tree is not None and mode == MODE_PLAY) or (move_tree is None and mode == MODE_ANALYZE): self.play_mode.switch_ui_mode() # for new game, go to play, for loaded, analyze self.board_gui.animating_pv = None self.engine.on_new_game() # clear queries - self.game = Game(self, self.engine, move_tree=move_tree, analyze_fast=analyze_fast) + self.game = Game(self, self.engine, move_tree=move_tree, analyze_fast=analyze_fast, sgf_filename=sgf_filename) if move_tree: for bw, player_info in self.players_info.items(): player_info.player_type = PLAYER_HUMAN player_info.player_subtype = PLAYING_NORMAL player_info.sgf_rank = move_tree.root.get_property(bw + "R") player_info.calculated_rank = None - player_info.name = move_tree.root.get_property("P" + bw) self.update_player(bw) self.controls.graph.initialize_from_game(self.game.root) - # self.controls.rank_graph.initialize_from_game(self.game.root) self.update_state(redraw_board=True) + def _do_insert_mode(self, mode="toggle"): + self.game.set_insert_mode(mode) + if self.play_analyze_mode != MODE_ANALYZE: + self.play_mode.switch_ui_mode() + def _do_ai_move(self, node=None): if node is None or self.game.current_node == node: mode = self.next_player_info.strategy @@ -295,9 +315,9 @@ class KaTrainGui(Screen, KaTrainBase): self.board_gui.animating_pv = None self.game.redo(n_times) - def _do_next_mistake(self): + def _do_find_mistake(self, fn="redo"): self.board_gui.animating_pv = None - self.game.redo(999, stop_on_mistake=self.config("trainer/eval_thresholds")[-4]) + getattr(self.game, fn)(9999, stop_on_mistake=self.config("trainer/eval_thresholds")[-4]) def _do_cycle_children(self, *args): self.board_gui.animating_pv = None @@ -317,6 +337,13 @@ class KaTrainGui(Screen, KaTrainBase): def _do_analyze_extra(self, mode, **kwargs): self.game.analyze_extra(mode, **kwargs) + def _do_play_to_end(self): + self.game.play_to_end() + + def _do_select_box(self): + self.controls.set_status(i18n._("analysis:region:start"), STATUS_INFO) + self.board_gui.selecting_region_of_interest = True + def _do_new_game_popup(self): self.controls.timer.paused = True if not self.new_game_popup: @@ -325,6 +352,7 @@ class KaTrainGui(Screen, KaTrainBase): ).__self__ self.new_game_popup.content.popup = self.new_game_popup self.new_game_popup.open() + self.new_game_popup.content.update_from_current_game() def _do_timer_popup(self): self.controls.timer.paused = True @@ -339,7 +367,7 @@ class KaTrainGui(Screen, KaTrainBase): self.controls.timer.paused = True if not self.teacher_settings_popup: self.teacher_settings_popup = I18NPopup( - title_key="teacher settings", size=[dp(800), dp(750)], content=ConfigTeacherPopup(self) + title_key="teacher settings", size=[dp(800), dp(800)], content=ConfigTeacherPopup(self) ).__self__ self.teacher_settings_popup.content.popup = self.teacher_settings_popup self.teacher_settings_popup.open() @@ -365,10 +393,10 @@ class KaTrainGui(Screen, KaTrainBase): def load_sgf_file(self, file, fast=False, rewind=True): try: move_tree = KaTrainSGF.parse_file(file) - except ParseError as e: + except (ParseError, FileNotFoundError) as e: self.log(i18n._("Failed to load SGF").format(error=e), OUTPUT_ERROR) return - self._do_new_game(move_tree=move_tree, analyze_fast=fast) + self._do_new_game(move_tree=move_tree, analyze_fast=fast, sgf_filename=file) if not rewind: self.game.redo(999) @@ -384,9 +412,8 @@ class KaTrainGui(Screen, KaTrainBase): filename = popup_contents.filesel.filename self.fileselect_popup.dismiss() path, file = os.path.split(filename) - settings_path = self.config("general/sgf_load") - if path != settings_path: - self.log(f"Updating sgf load path default to {path}", OUTPUT_INFO) + if path != self.config("general/sgf_load"): + self.log(f"Updating sgf load path default to {path}", OUTPUT_DEBUG) self._config["general"]["sgf_load"] = path self.save_config("general") self.load_sgf_file(filename, popup_contents.fast.active, popup_contents.rewind.active) @@ -396,15 +423,45 @@ class KaTrainGui(Screen, KaTrainBase): self.fileselect_popup.open() self.fileselect_popup.content.filesel.ids.list_view._trigger_update() - def _do_output_sgf(self): - msg = self.game.write_sgf(self.config("general/sgf_save")) - self.log(msg, OUTPUT_INFO) - self.controls.set_status(msg, STATUS_INFO) + def _do_save_game(self, filename=None): + filename = filename or self.game.sgf_filename + if not filename: + return self("save-game-as-popup") + try: + msg = self.game.write_sgf(filename) + self.log(msg, OUTPUT_INFO) + self.controls.set_status(msg, STATUS_INFO, check_level=False) + except Exception as e: + self.log(f"Failed to save SGF to {filename}: {e}", OUTPUT_ERROR) + + def _do_save_game_as_popup(self): + popup_contents = SaveSGFPopup(suggested_filename=self.game.generate_filename()) + save_game_popup = I18NPopup( + title_key="save sgf title", size=[dp(1200), dp(800)], content=popup_contents + ).__self__ + + def readfile(*_args): + filename = popup_contents.filesel.filename + if not filename.lower().endswith(".sgf"): + filename += ".sgf" + save_game_popup.dismiss() + path, file = os.path.split(filename.strip()) + if not path: + path = popup_contents.filesel.path # whatever dir is shown + if path != self.config("general/sgf_save"): + self.log(f"Updating sgf save path default to {path}", OUTPUT_DEBUG) + self._config["general"]["sgf_save"] = path + self.save_config("general") + self._do_save_game(os.path.join(path, file)) + + popup_contents.filesel.on_success = readfile + popup_contents.filesel.on_submit = readfile + save_game_popup.open() def load_sgf_from_clipboard(self): clipboard = Clipboard.paste() if not clipboard: - self.controls.set_status(f"Ctrl-V pressed but clipboard is empty.", STATUS_INFO) + self.controls.set_status("Ctrl-V pressed but clipboard is empty.", STATUS_INFO) return url_match = re.match(r"(?Phttps?://[^\s]+)", clipboard) @@ -425,7 +482,7 @@ class KaTrainGui(Screen, KaTrainBase): self.engine, analyze_fast=False ) # speed up result for looking at end of game self._do_new_game(move_tree=move_tree, analyze_fast=True) - self("redo", 999) + self("redo", 9999) self.log("Imported game from clipboard.", OUTPUT_INFO) def on_touch_up(self, touch): @@ -455,9 +512,11 @@ class KaTrainGui(Screen, KaTrainBase): "s": ("analyze-extra", "equalize"), "d": ("analyze-extra", "sweep"), "f": ("analyze-extra", "alternative"), - "g": ("analyze-extra", "local"), + "g": ("select-box",), + "i": ("insert-mode",), "p": ("play", None), - "n": ("next-mistake",), + "l": ("play-to-end",), + "b": ("undo", "branch"), "down": ("switch-branch", 1), "up": ("switch-branch", -1), "f5": ("timer-popup",), @@ -473,44 +532,59 @@ class KaTrainGui(Screen, KaTrainBase): return first_child if isinstance(first_child, Popup) else None def _on_keyboard_down(self, _keyboard, keycode, _text, modifiers): + ctrl_pressed = "ctrl" in modifiers if self.controls.note.focus: return # when making notes, don't allow keyboard shortcuts - popup = self.popup_open if popup: if keycode[1] in ["f5", "f6", "f7", "f8"]: # switch between popups popup.dismiss() return + elif keycode[1] in ["enter", "numpadenter"]: + fn = getattr(popup.content, "on_submit", None) + if fn: + fn() + return else: return - ctrl_pressed = "ctrl" in modifiers - alt_pressed = "alt" in modifiers shift_pressed = "shift" in modifiers shortcuts = self.shortcuts if keycode[1] == "tab": self.play_mode.switch_ui_mode() - elif keycode[1] == "shift": + elif keycode[1] == "alt": self.nav_drawer.set_state("toggle") elif keycode[1] == "spacebar": self.toggle_continuous_analysis() - elif keycode[1] == "b" and ctrl_pressed: + elif keycode[1] == "k": + self.board_gui.toggle_coordinates() + elif keycode[1] in ["pause", "break", "f15"] and not ctrl_pressed: self.controls.timer.paused = not self.controls.timer.paused - elif keycode[1] in ["`", "~", "m"] and ctrl_pressed: + elif keycode[1] in ["`", "~", "f12"]: self.zen = (self.zen + 1) % 3 elif keycode[1] in ["left", "z"]: - self("undo", 1 + (alt_pressed or shift_pressed) * 9 + (ctrl_pressed and not alt_pressed) * 999) + self("undo", 1 + shift_pressed * 9 + ctrl_pressed * 9999) elif keycode[1] in ["right", "x"]: - self("redo", 1 + (alt_pressed or shift_pressed) * 9 + (ctrl_pressed and not alt_pressed) * 999) + self("redo", 1 + shift_pressed * 9 + ctrl_pressed * 9999) elif keycode[1] == "home": - self("undo", 999) + self("undo", 9999) elif keycode[1] == "end": - self("redo", 999) + self("redo", 9999) + elif keycode[1] == "pageup": + self.controls.move_tree.make_selected_node_main_branch() + elif keycode[1] == "n" and not ctrl_pressed: + self("find-mistake", "undo" if shift_pressed else "redo") + elif keycode[1] == "delete" and ctrl_pressed: + self.controls.move_tree.delete_selected_node() + elif keycode[1] == "c" and not ctrl_pressed: + self.controls.move_tree.toggle_selected_node_collapse() elif keycode[1] == "n" and ctrl_pressed: self("new-game-popup") elif keycode[1] == "l" and ctrl_pressed: self("analyze-sgf-popup") elif keycode[1] == "s" and ctrl_pressed: - self("output-sgf") + self("save-game") + elif keycode[1] == "d" and ctrl_pressed: + self("save-game-as-popup") elif keycode[1] == "c" and ctrl_pressed: Clipboard.copy(self.game.root.sgf()) self.controls.set_status(i18n._("Copied SGF to clipboard."), STATUS_INFO) @@ -522,6 +596,20 @@ class KaTrainGui(Screen, KaTrainBase): shortcut.trigger_action(duration=0) else: self(*shortcut) + elif keycode[1] == "f9" and self.debug_level >= OUTPUT_EXTRA_DEBUG: + import yappi + + yappi.set_clock_type("cpu") + yappi.start() + self.log("starting profiler", OUTPUT_ERROR) + elif keycode[1] == "f10" and self.debug_level >= OUTPUT_EXTRA_DEBUG: + import time + import yappi + + stats = yappi.get_func_stats() + filename = f"callgrind.{int(time.time())}.prof" + stats.save(filename, type="callgrind") + self.log(f"wrote profiling results to {filename}", OUTPUT_ERROR) return True @@ -542,7 +630,23 @@ class KaTrainApp(MDApp): kv_file = find_package_resource("katrain/gui.kv") popup_kv_file = find_package_resource("katrain/popups.kv") - resource_add_path(PATHS["PACKAGE"]) + resource_add_path(PATHS["PACKAGE"] + "/fonts") + resource_add_path(PATHS["PACKAGE"] + "/sounds") + resource_add_path(PATHS["PACKAGE"] + "/img") + resource_add_path(os.path.abspath(os.path.expanduser(DATA_FOLDER))) # prefer resources in .katrain + + theme_file = resource_find("theme.json") + if theme_file: + try: + with open(theme_file) as f: + theme_overrides = json.load(f) + for k, v in theme_overrides.items(): + setattr(Theme, k, v) + print(f"[{theme_file}] Found theme override {k} = {v}") + except Exception as e: # noqa E722 + print(f"Failed to load theme file {theme_file}: {e}") + + Theme.DEFAULT_FONT = resource_find(Theme.DEFAULT_FONT) Builder.load_file(kv_file) Window.bind(on_request_close=self.on_request_close) diff --git a/katrain/config.json b/katrain/config.json index 7e7ee35..5833713 100644 --- a/katrain/config.json +++ b/katrain/config.json @@ -17,7 +17,7 @@ "anim_pv_time": 0.5, "debug_level": 0, "lang": "en", - "version": "1.6.1" + "version": "1.7.0" }, "timer": { "byo_length": 30, @@ -67,9 +67,11 @@ true, true ], + "save_analysis": false, "low_visits": 25, "eval_off_show_last": 3, - "text_point_loss": true, + "top_moves_show": "top_move_delta_score", + "top_moves_show_secondary": "top_move_visits", "eval_show_ai": true, "lock_ai": false }, @@ -96,14 +98,6 @@ "attach_penalty": 1, "tenuki_penalty": 0.5 }, - "ai:settle": { - "max_points_lost": 1.75, - "settled_weight": 1.0, - "opponent_fac": 0.5, - "min_visits": 3, - "attach_penalty": 1, - "tenuki_penalty": 0.5 - }, "ai:p:weighted": { "weaken_fac": 1.25, "pick_override": 1.0, diff --git a/katrain/core/ai.py b/katrain/core/ai.py index b15a1d6..7a0c8e4 100644 --- a/katrain/core/ai.py +++ b/katrain/core/ai.py @@ -18,6 +18,7 @@ from katrain.core.constants import ( AI_RANK, AI_SCORELOSS, AI_SCORELOSS_ELO, + AI_SETTLE_STONES, AI_SIMPLE_OWNERSHIP, AI_STRATEGIES_PICK, AI_STRATEGIES_POLICY, @@ -32,7 +33,6 @@ from katrain.core.constants import ( OUTPUT_DEBUG, OUTPUT_ERROR, OUTPUT_INFO, - AI_SETTLE_STONES, ) from katrain.core.game import Game, GameNode, Move from katrain.core.utils import var_to_grid @@ -125,11 +125,11 @@ def policy_weighted_move(policy_moves, lower_bound, weaken_fac): def generate_influence_territory_weights(ai_mode, ai_settings, policy_grid, size): thr_line = ai_settings["threshold"] - 1 # zero-based if ai_mode == AI_INFLUENCE: - weight = lambda x, y: (1 / ai_settings["line_weight"]) ** ( + weight = lambda x, y: (1 / ai_settings["line_weight"]) ** ( # noqa E731 max(0, thr_line - min(size[0] - 1 - x, x)) + max(0, thr_line - min(size[1] - 1 - y, y)) - ) + ) # noqa E731 else: - weight = lambda x, y: (1 / ai_settings["line_weight"]) ** ( + weight = lambda x, y: (1 / ai_settings["line_weight"]) ** ( # noqa E731 max(0, min(size[0] - 1 - x, x, size[1] - 1 - y, y) - thr_line) ) weighted_coords = [ @@ -203,7 +203,7 @@ def generate_ai_move(game: Game, ai_mode: str, ai_settings: Dict) -> Tuple[Move, game, cn, {"playoutDoublingAdvantage": pda, "playoutDoublingAdvantagePla": "BLACK"} ) if not handicap_analysis: - game.katrain.log(f"Error getting handicap-based move", OUTPUT_ERROR) + game.katrain.log("Error getting handicap-based move", OUTPUT_ERROR) ai_mode = AI_DEFAULT while not cn.analysis_complete: @@ -225,7 +225,7 @@ def generate_ai_move(game: Game, ai_mode: str, ai_settings: Dict) -> Tuple[Move, ai_mode in [AI_LOCAL, AI_TENUKI] and not (cn.move and cn.move.coords) ): ai_mode = AI_WEIGHTED - ai_thoughts += f"Strategy override, using policy-weighted strategy instead. " + ai_thoughts += "Strategy override, using policy-weighted strategy instead. " ai_settings = {"pick_override": 0.9, "weaken_fac": 1, "lower_bound": 0.02} if top_5_pass: @@ -336,7 +336,7 @@ def generate_ai_move(game: Game, ai_mode: str, ai_settings: Dict) -> Tuple[Move, AI_HANDICAP, ]: # don't play suicidal to balance score aimove = top_cand - ai_thoughts += f"Top move is pass, so passing regardless of strategy. " + ai_thoughts += "Top move is pass, so passing regardless of strategy. " else: if ai_mode == AI_JIGO: sign = cn.player_sign(cn.next_player) diff --git a/katrain/core/base_katrain.py b/katrain/core/base_katrain.py index 819a30c..c5e2e49 100644 --- a/katrain/core/base_katrain.py +++ b/katrain/core/base_katrain.py @@ -6,7 +6,18 @@ from kivy import Config from kivy.storage.jsonstore import JsonStore from katrain.core.ai import ai_rank_estimation -from katrain.core.constants import * +from katrain.core.constants import ( + PLAYER_HUMAN, + PLAYER_AI, + PLAYING_NORMAL, + PLAYING_TEACHING, + OUTPUT_INFO, + OUTPUT_ERROR, + OUTPUT_DEBUG, + AI_DEFAULT, + CONFIG_MIN_VERSION, + DATA_FOLDER, +) from katrain.core.utils import find_package_resource @@ -44,7 +55,7 @@ class Player: class KaTrainBase: - USER_CONFIG_FILE = os.path.expanduser("~/.katrain/config.json") + USER_CONFIG_FILE = os.path.expanduser(os.path.join(DATA_FOLDER, "config.json")) PACKAGE_CONFIG_FILE = "katrain/config.json" """Settings, logging, and players functionality, so other classes like bots who need a katrain instance can be used without a GUI""" @@ -91,7 +102,7 @@ class KaTrainBase: else: # user file exists try: version = JsonStore(user_config_file).get("general")["version"] - except Exception: # broken file etc + except Exception: # noqa E722 broken file etc version = "0.0.0" if version < CONFIG_MIN_VERSION: backup = user_config_file + f".{version}.backup" diff --git a/katrain/core/constants.py b/katrain/core/constants.py index 40812bd..a7d2a99 100644 --- a/katrain/core/constants.py +++ b/katrain/core/constants.py @@ -1,6 +1,9 @@ -VERSION = "1.6.1" +PROGRAM_NAME = "KaTrain" +VERSION = "1.7.0" HOMEPAGE = "https://github.com/sanderland/katrain" -CONFIG_MIN_VERSION = "1.6.0" # keep config files from this version +CONFIG_MIN_VERSION = "1.7.0" # keep config files from this version +ANALYSIS_FORMAT_VERSION = "1.0" +DATA_FOLDER = "~/.katrain" OUTPUT_ERROR = -1 OUTPUT_KATAGO_STDERR = -0.5 @@ -220,3 +223,24 @@ AI_PICK_ELO_GRID = [ [514.0, 670.0, 870.0, 1128.0, 1305.0, 1550.0, 1700.0, 1700.0], ], ] + + +TOP_MOVE_DELTA_SCORE = "top_move_delta_score" +TOP_MOVE_SCORE = "top_move_score" +TOP_MOVE_DELTA_WINRATE = "top_move_delta_winrate" +TOP_MOVE_WINRATE = "top_move_winrate" +TOP_MOVE_VISITS = "top_move_visits" +TOP_MOVE_NOTHING = "top_move_nothing" + +TOP_MOVE_OPTIONS = [ + TOP_MOVE_SCORE, + TOP_MOVE_DELTA_SCORE, + TOP_MOVE_WINRATE, + TOP_MOVE_DELTA_WINRATE, + TOP_MOVE_VISITS, + TOP_MOVE_NOTHING, +] +REPORT_DT = 0.25 + +SGF_INTERNAL_COMMENTS_MARKER = "\u3164\u200b" +SGF_SEPARATOR_MARKER = "\u3164\u3164" diff --git a/katrain/core/engine.py b/katrain/core/engine.py index 914ad34..56c7ea9 100644 --- a/katrain/core/engine.py +++ b/katrain/core/engine.py @@ -6,7 +6,7 @@ import subprocess import threading import time import traceback -from typing import Callable, Dict, Optional +from typing import Callable, Dict, List, Optional from kivy.utils import platform @@ -103,8 +103,10 @@ class KataGoEngine: i18n._("Starting Kata failed").format(command=self.command, error=e), OUTPUT_ERROR, ) return # don't start - self.analysis_thread = threading.Thread(target=self._analysis_read_thread, daemon=True).start() - self.stderr_thread = threading.Thread(target=self._read_stderr_thread, daemon=True).start() + self.analysis_thread = threading.Thread(target=self._analysis_read_thread, daemon=True) + self.stderr_thread = threading.Thread(target=self._read_stderr_thread, daemon=True) + self.analysis_thread.start() + self.stderr_thread.start() def on_new_game(self): self.base_priority += 1 @@ -201,18 +203,21 @@ class KataGoEngine: self.katrain.log(f"{analysis} received from KataGo", OUTPUT_ERROR) elif "warning" in analysis: self.katrain.log(f"{analysis} received from KataGo", OUTPUT_DEBUG) + elif "terminateId" in analysis: + self.katrain.log(f"{analysis} received from KataGo", OUTPUT_DEBUG) else: partial_result = analysis.get("isDuringSearch", False) if not partial_result: del self.queries[query_id] time_taken = time.time() - start_time + results_exist = not analysis.get("noResults", False) self.katrain.log( - f"[{time_taken:.1f}][{query_id}][{'....' if partial_result else 'done'}] KataGo Analysis Received: {analysis.keys()}", + f"[{time_taken:.1f}][{query_id}][{'....' if partial_result else 'done'}] KataGo analysis received: {len(analysis.get('moveInfos',[]))} candidate moves, {analysis['rootInfo']['visits'] if results_exist else 'n/a'} visits", OUTPUT_DEBUG, ) self.katrain.log(line, OUTPUT_EXTRA_DEBUG) try: - if callback and not analysis.get("noResults", False): + if callback and results_exist: callback(analysis, partial_result) except Exception as e: self.katrain.log(f"Error in engine callback for query {query_id}: {e}", OUTPUT_ERROR) @@ -228,13 +233,13 @@ class KataGoEngine: if "id" not in query: query["id"] = f"QUERY:{str(self.query_counter)}" self.queries[query["id"]] = (callback, error_callback, time.time(), next_move) - if self.katago_process: - self.katrain.log(f"Sending query {query['id']}: {json.dumps(query)}", OUTPUT_DEBUG) - try: - self.katago_process.stdin.write((json.dumps(query) + "\n").encode()) - self.katago_process.stdin.flush() - except OSError as e: - self.check_alive(os_error=str(e), exception_if_dead=True) + if self.katago_process: + self.katrain.log(f"Sending query {query['id']}: {json.dumps(query)}", OUTPUT_DEBUG) + try: + self.katago_process.stdin.write((json.dumps(query) + "\n").encode()) + self.katago_process.stdin.flush() + except OSError as e: + self.check_alive(os_error=str(e), exception_if_dead=True) def terminate_query(self, query_id): if query_id is not None: @@ -249,7 +254,7 @@ class KataGoEngine: analyze_fast: bool = False, time_limit=True, find_alternatives: bool = False, - find_local: bool = False, + region_of_interest: Optional[List] = None, priority: int = 0, ownership: Optional[bool] = None, next_move: Optional[GameNode] = None, @@ -278,22 +283,20 @@ class KataGoEngine: "untilDepth": 1, } ] - elif find_local: - distance = 5 - last_move = analysis_node.move - if last_move is None or last_move.is_pass: - return + elif region_of_interest: + xmin, xmax, ymin, ymax = region_of_interest avoid = [ { "moves": [ Move((x, y)).gtp() for x in range(0, size_x) for y in range(0, size_y) - if max(abs(x - last_move.coords[0]), abs(y - last_move.coords[1])) > distance + if x < xmin or x > xmax or y < ymin or y > ymax ], - "player": analysis_node.next_player, - "untilDepth": 1, + "player": player, + "untilDepth": 1, # tried a large number here, or 2, but this seems more natural } + for player in "BW" ] else: avoid = [] diff --git a/katrain/core/game.py b/katrain/core/game.py index 71f72f5..dcf8401 100644 --- a/katrain/core/game.py +++ b/katrain/core/game.py @@ -1,3 +1,4 @@ +import copy import math import os import re @@ -8,11 +9,15 @@ from typing import Dict, List, Optional, Union from kivy.clock import Clock from katrain.core.constants import ( - HOMEPAGE, + ANALYSIS_FORMAT_VERSION, OUTPUT_DEBUG, OUTPUT_INFO, PLAYER_AI, + PLAYER_HUMAN, + PROGRAM_NAME, + SGF_INTERNAL_COMMENTS_MARKER, STATUS_ANALYSIS, + STATUS_ERROR, STATUS_INFO, STATUS_TEACHING, ) @@ -34,7 +39,7 @@ class KaTrainSGF(SGF): class Game: """Represents a game of go, including an implementation of capture rules.""" - DEFAULT_PROPERTIES = {"GM": 1, "FF": 4, "AP": f"KaTrain:{HOMEPAGE}", "CA": "UTF-8"} + DEFAULT_PROPERTIES = {"GM": 1, "FF": 4} def __init__( self, @@ -43,6 +48,7 @@ class Game: move_tree: GameNode = None, analyze_fast=False, game_properties: Optional[Dict] = None, + sgf_filename=None, ): self.katrain = katrain self._lock = threading.Lock() @@ -50,9 +56,16 @@ class Game: engine = {"B": engine, "W": engine} self.engines = engine self.game_id = datetime.strftime(datetime.now(), "%Y-%m-%d %H %M %S") + self.sgf_filename = sgf_filename + self.insert_mode = False + self.insert_after = None + + self.region_of_interest = None + self.external_game = False # not generated by katrain at some point if move_tree: self.root = move_tree + self.external_game = PROGRAM_NAME not in self.root.get_property("AP", "") self.komi = self.root.komi handicap = int(self.root.get_property("HA", 0)) if ( @@ -63,11 +76,12 @@ class Game: self.root.place_handicap_stones(handicap) else: board_size = katrain.config("game/size") + rules = katrain.config("game/rules") self.komi = katrain.config("game/komi") self.root = GameNode( properties={ **Game.DEFAULT_PROPERTIES, - **{"SZ": board_size, "KM": self.komi, "DT": self.game_id}, + **{"SZ": board_size, "KM": self.komi, "DT": self.game_id, "RU": rules}, **(game_properties or {}), } ) @@ -80,14 +94,24 @@ class Game: self.set_current_node(self.root) self.main_time_used = 0 + + # restore shortcuts + shortcut_id_to_node = {node.get_property("KTSID", None): node for node in self.root.nodes_in_tree} + for node in self.root.nodes_in_tree: + shortcut_id = node.get_property("KTSF", None) + if shortcut_id and shortcut_id in shortcut_id_to_node: + shortcut_id_to_node[shortcut_id].add_shortcut(node) + threading.Thread( - target=lambda: self.analyze_all_nodes(-1_000_000, analyze_fast=analyze_fast), daemon=True + target=lambda: self.analyze_all_nodes(-1_000_000, analyze_fast=analyze_fast, even_if_present=False), + daemon=True, ).start() # return faster, but bypass Kivy Clock - def analyze_all_nodes(self, priority=0, analyze_fast=False): + def analyze_all_nodes(self, priority=0, analyze_fast=False, even_if_present=True): for node in self.root.nodes_in_tree: - node.clear_analysis() - node.analyze(self.engines[node.next_player], priority=priority, analyze_fast=analyze_fast) + if even_if_present or not node.analysis_loaded: + node.clear_analysis() + node.analyze(self.engines[node.next_player], priority=priority, analyze_fast=analyze_fast) # -- move tree functions -- def _calculate_groups(self): @@ -157,6 +181,54 @@ class Game: if -1 not in neighbours(self.chains[this_chain]): # TODO: NZ rules? raise IllegalMoveException("Suicide") + def set_insert_mode(self, mode): + if mode == "toggle": + mode = not self.insert_mode + if mode == self.insert_mode: + return + self.insert_mode = mode + if mode: + children = self.current_node.ordered_children + if not children: + self.insert_mode = False + else: + self.insert_after = self.current_node.ordered_children[0] + self.katrain.controls.set_status(i18n._("starting insert mode"), STATUS_INFO) + else: + copy_from_node = self.insert_after + copy_to_node = self.current_node + num_copied = 0 + if copy_to_node != self.insert_after.parent: + above_insertion_root = self.insert_after.parent.nodes_from_root + already_inserted_moves = [ + n.move for n in copy_to_node.nodes_from_root if n not in above_insertion_root and n.move + ] + try: + while True: + if copy_from_node.move not in already_inserted_moves: + for m in copy_from_node.move_with_placements: + self._validate_move_and_update_chains(m, True) + # this inserts + copy_to_node = GameNode( + parent=copy_to_node, properties=copy.deepcopy(copy_from_node.properties) + ) + num_copied += 1 + if not copy_from_node.children: + break + copy_from_node = copy_from_node.ordered_children[0] + except IllegalMoveException: + pass # illegal move = stop + self._calculate_groups() # recalculate groups + self.katrain.controls.set_status( + i18n._("ending insert mode").format(num_copied=num_copied), STATUS_INFO + ) + self.analyze_all_nodes(analyze_fast=True, even_if_present=False) + else: + self.katrain.controls.set_status("", STATUS_INFO) + self.katrain.controls.move_tree.insert_node = self.insert_after if self.insert_mode else None + self.katrain.controls.move_tree.redraw() + self.katrain.update_state(redraw_board=True) + # Play a Move from the current position, raise IllegalMoveException if invalid. def play(self, move: Move, ignore_ko: bool = False, analyze=True): board_size_x, board_size_y = self.board_size @@ -171,25 +243,62 @@ class Game: played_node = self.current_node.play(move) self.current_node = played_node if analyze: - played_node.analyze(self.engines[played_node.next_player]) + if self.region_of_interest: + played_node.analyze(self.engines[played_node.next_player], analyze_fast=True) + played_node.analyze(self.engines[played_node.next_player], region_of_interest=self.region_of_interest) + else: + played_node.analyze(self.engines[played_node.next_player]) return played_node def set_current_node(self, node): + if self.insert_mode: + self.katrain.controls.set_status(i18n._("finish inserting before navigating"), STATUS_ERROR) + return + self.current_node = node self._calculate_groups() - def undo(self, n_times=1): + def undo(self, n_times=1, stop_on_mistake=None): + # allow undo/delete only in insert mode cn = self.current_node # avoid race conditions - for _ in range(n_times): - if not cn.is_root: + if self.insert_mode: # in insert mode, undo = delete + if n_times == 1 and cn not in self.insert_after.nodes_from_root: + cn.parent.children = [c for c in cn.parent.children if c != cn] + self.current_node = cn.parent + self._calculate_groups() + return + break_on_branch = False + if n_times == "branch": + n_times = 9999 + break_on_branch = True + for move in range(n_times): + if ( + stop_on_mistake is not None + and cn.points_lost is not None + and cn.points_lost >= stop_on_mistake + and self.katrain.players_info[cn.player].player_type != PLAYER_AI + ): + self.set_current_node(cn.parent) + return + if cn.shortcut_from: + cn = cn.shortcut_from + elif not cn.is_root: cn = cn.parent + if break_on_branch and len(cn.children) > 1: + break self.set_current_node(cn) def redo(self, n_times=1, stop_on_mistake=None): + if self.insert_mode: + return cn = self.current_node # avoid race conditions for move in range(n_times): if cn.children: - cn = cn.ordered_children[0] + child = cn.ordered_children[0] + shortcut_to = [m for m, v in cn.shortcuts_to if child == v] # are we about to go to a shortcut node? + if shortcut_to: + child = shortcut_to[0] + cn = child if ( move > 0 and stop_on_mistake is not None @@ -287,43 +396,65 @@ class Game: + f"\ncaptures: {self.prisoner_count}" ) - def write_sgf( - self, path: str, trainer_config: Optional[Dict] = None, - ): - if trainer_config is None: - trainer_config = self.katrain.config("trainer") - save_feedback = trainer_config["save_feedback"] - eval_thresholds = trainer_config["eval_thresholds"] - + def generate_filename(self): def player_name(player_info): - return f"{i18n._(player_info.player_type)} ({i18n._(player_info.player_subtype)})" + if player_info.name and player_info.player_type == PLAYER_HUMAN: + return player_info.name + else: + return f"{i18n._(player_info.player_type)} ({i18n._(player_info.player_subtype)})" root_properties = self.root.properties x_properties = {} - if "KaTrain" in self.root.get_property("AP", ""): - for bw in "BW": - x_properties["P" + bw] = player_name(self.katrain.players_info[bw]) + for bw in "BW": + if not self.external_game: + x_properties["P" + bw] = player_name(self.katrain.players_info[bw]) + SGF_INTERNAL_COMMENTS_MARKER player_info = self.katrain.players_info[bw] if player_info.player_type == PLAYER_AI: x_properties[bw + "R"] = rank_label(player_info.calculated_rank) if "+" in str(self.end_result): x_properties["RE"] = self.end_result + x_properties["KTV"] = ANALYSIS_FORMAT_VERSION self.root.properties = {**root_properties, **{k: [v] for k, v in x_properties.items()}} - player_names = {bw: re.sub(r"['<>:\"/\\|?*]", "", self.root.get_property("P" + bw, bw)) for bw in "BW"} - game_name = f"katrain_{player_names['B']} vs {player_names['W']} {self.game_id}" - file_name = os.path.abspath(os.path.join(path, f"{game_name}.sgf")) - os.makedirs(os.path.dirname(file_name), exist_ok=True) + player_names = { + bw: re.sub(r"[\u200b\u3164'<>:\"/\\|?*]", "", self.root.get_property("P" + bw, bw)) for bw in "BW" + } + base_game_name = f"{PROGRAM_NAME}_{player_names['B']} vs {player_names['W']}" + return f"{base_game_name} {self.game_id}.sgf" + + def write_sgf( + self, filename: str = None, trainer_config: Optional[Dict] = None, + ): + if trainer_config is None: + trainer_config = self.katrain.config("trainer", {}) + save_feedback = trainer_config.get("save_feedback", False) + eval_thresholds = trainer_config["eval_thresholds"] + save_analysis = trainer_config.get("save_analysis", False) show_dots_for = { bw: trainer_config.get("eval_show_ai", True) or self.katrain.players_info[bw].human for bw in "BW" } sgf = self.root.sgf( - save_comments_player=show_dots_for, save_comments_class=save_feedback, eval_thresholds=eval_thresholds + save_comments_player=show_dots_for, + save_comments_class=save_feedback, + eval_thresholds=eval_thresholds, + save_analysis=save_analysis, ) - with open(file_name, "w", encoding="utf-8") as f: + self.sgf_filename = filename + os.makedirs(os.path.dirname(filename), exist_ok=True) + with open(filename, "w", encoding="utf-8") as f: f.write(sgf) - self.root.properties = root_properties - return i18n._("sgf written").format(file_name=file_name) + return i18n._("sgf written").format(file_name=filename) + + def set_region_of_interest(self, region_of_interest): + x1, x2, y1, y2 = region_of_interest + xmin, xmax = min(x1, x2), max(x1, x2) + ymin, ymax = min(y1, y2), max(y1, y2) + szx, szy = self.board_size + if not (xmin == xmax and ymin == ymax) and not (xmax - xmin + 1 >= szx and ymax - ymin + 1 >= szy): + self.region_of_interest = [xmin, xmax, ymin, ymax] + else: + self.region_of_interest = None + self.katrain.controls.set_status("", OUTPUT_INFO) def analyze_extra(self, mode, **kwargs): stones = {s.coords for s in self.stones} @@ -341,7 +472,9 @@ class Game: visits = cn.analysis_visits_requested + engine.config["max_visits"] self.katrain.controls.set_status(i18n._("extra analysis").format(visits=visits), STATUS_ANALYSIS) self.katrain.controls.set_status(i18n._("extra analysis").format(visits=visits), STATUS_ANALYSIS) - cn.analyze(engine, visits=visits, priority=-1_000, time_limit=False) + cn.analyze( + engine, visits=visits, priority=-1_000, region_of_interest=self.region_of_interest, time_limit=False + ) return if mode == "game": nodes = self.root.nodes_in_tree @@ -382,13 +515,9 @@ class Game: visits = engine.config["fast_visits"] self.katrain.controls.set_status(i18n._("sweep analysis").format(visits=visits), STATUS_ANALYSIS) priority = -1_000_000_000 - elif mode == "local": # also do a quick update on current candidates so it doesn't look too weird - self.katrain.controls.set_status(i18n._("local analysis"), STATUS_ANALYSIS) - cn.analyze(engine, priority=-500, time_limit=False, find_local="local") - return elif mode in ["equalize", "alternative", "local"]: if not cn.analysis_complete and mode != "local": - self.katrain.controls.set_status(i18n._("wait-before-equalize"), STATUS_INFO, self.current_node) + self.katrain.controls.set_status(i18n._("wait-before-extra-analysis"), STATUS_INFO, self.current_node) return if mode == "alternative": # also do a quick update on current candidates so it doesn't look too weird self.katrain.controls.set_status(i18n._("alternative analysis"), STATUS_ANALYSIS) @@ -404,9 +533,48 @@ class Game: for move in analyze_moves: if cn.analysis["moves"].get(move.gtp(), {"visits": 0})["visits"] < visits: cn.analyze( - engine, priority=priority, visits=visits, refine_move=move, time_limit=False, report_every=None + engine, priority=priority, visits=visits, refine_move=move, time_limit=False ) # explicitly requested so take as long as you need + def play_to_end(self): + cn = self.current_node + count = 0 + if not cn.analysis_exists: + self.katrain.controls.set_status(i18n._("wait-before-extra-analysis"), STATUS_INFO, cn) + return + + def analyze_and_play_policy(node): + nonlocal count, cn + cand = node.candidate_moves + if self.katrain.game is not self: + return # a new game happened + if cand: + move = Move.from_gtp(cand[0]["move"], player=node.next_player) + else: + polmoves = node.policy_ranking + move = polmoves[0][1] if polmoves else Move(None) + + if move.is_pass: + if self.current_node == cn: + self.set_current_node(node) + return + count += 1 + new_node = GameNode(parent=node, move=move) + if node != cn: + node.remove_shortcut() + cn.add_shortcut(new_node) + self.katrain.controls.move_tree.redraw_tree_trigger() + + def set_analysis(result, _partial): + new_node.set_analysis(result) + analyze_and_play_policy(new_node) + + self.engines[node.next_player].request_analysis( + new_node, callback=set_analysis, priority=-1000, analyze_fast=True, + ) + + analyze_and_play_policy(cn) + def analyze_undo(self, node): train_config = self.katrain.config("trainer") move = node.move diff --git a/katrain/core/game_node.py b/katrain/core/game_node.py index de7bc60..148b337 100644 --- a/katrain/core/game_node.py +++ b/katrain/core/game_node.py @@ -1,11 +1,49 @@ +import base64 import copy +import gzip +import json import random from typing import Dict, List, Optional, Tuple +from katrain.core.constants import ( + ANALYSIS_FORMAT_VERSION, + PROGRAM_NAME, + SGF_INTERNAL_COMMENTS_MARKER, + SGF_SEPARATOR_MARKER, + VERSION, + REPORT_DT, +) from katrain.core.lang import i18n from katrain.core.sgf_parser import Move, SGFNode -from katrain.core.utils import evaluation_class, var_to_grid -from katrain.gui.style import INFO_PV_COLOR +from katrain.core.utils import evaluation_class, pack_floats, unpack_floats, var_to_grid +from katrain.gui.theme import Theme + + +def analysis_dumps(analysis): + analysis = copy.deepcopy(analysis) + for movedict in analysis["moves"].values(): + if "ownership" in movedict: # per-move ownership rarely used + del movedict["ownership"] + ownership_data = pack_floats(analysis.pop("ownership")) + policy_data = pack_floats(analysis.pop("policy")) + main_data = json.dumps(analysis).encode("utf-8") + return [ + base64.standard_b64encode(gzip.compress(data)).decode("utf-8") + for data in [ownership_data, policy_data, main_data] + ] + + +def analysis_loads(property_array, board_squares, version): + if version > ANALYSIS_FORMAT_VERSION: + raise ValueError(f"Can not decode analysis data with version {version}, please update {PROGRAM_NAME}") + ownership_data, policy_data, main_data, *_ = [ + gzip.decompress(base64.standard_b64decode(data)) for data in property_array + ] + return { + **json.loads(main_data), + "policy": unpack_floats(policy_data, board_squares + 1), + "ownership": unpack_floats(ownership_data, board_squares), + } class GameNode(SGFNode): @@ -13,8 +51,6 @@ class GameNode(SGFNode): def __init__(self, parent=None, properties=None, move=None): super().__init__(parent=parent, properties=properties, move=move) - self.ownership = None - self.policy = None self.auto_undo = None # None = not analyzed. False: not undone (good move). True: undone (bad move) self.ai_thoughts = "" self.note = "" @@ -22,19 +58,65 @@ class GameNode(SGFNode): self.time_used = 0 self.undo_threshold = random.random() # for fractional undos self.end_state = None + self.shortcuts_to = [] + self.shortcut_from = None + self.analysis_loaded = False self.clear_analysis() + def add_shortcut(self, to_node): # collapses the branch between them + nodes = [to_node] + while nodes[-1].parent and nodes[-1] != self: # ensure on path + nodes.append(nodes[-1].parent) + if nodes[-1] == self and len(nodes) > 2: + via = nodes[-2] + self.shortcuts_to.append((to_node, via)) # and first child + to_node.shortcut_from = self + + def remove_shortcut(self): + from_node = self.shortcut_from + if from_node: + from_node.shortcuts_to = [(m, v) for m, v in from_node.shortcuts_to if m != self] + self.shortcut_from = None + + def add_list_property(self, property: str, values: List): + if property == "KT": + try: + szx, szy = self.root.board_size + version = self.root.get_property("KTV", "") + self.analysis = analysis_loads(values, szx * szy, version) + self.analysis_loaded = True + except Exception as e: + print(f"Error in loading analysis: {e}") + elif property == "C": + comments = [ # strip out all previously auto generated comments + c + for v in values + for c in v.split(SGF_SEPARATOR_MARKER) + if c.strip() and SGF_INTERNAL_COMMENTS_MARKER not in c + ] + self.note = "".join(comments) # no super call intended, just save as note to be editable + else: + return super().add_list_property(property, values) + def clear_analysis(self): self.analysis_visits_requested = 0 - self.analysis = {"moves": {}, "root": None, "completed": False} + self.analysis = {"moves": {}, "root": None, "ownership": None, "policy": None, "completed": False} - def sgf_properties(self, save_comments_player=None, save_comments_class=None, eval_thresholds=None): + def sgf_properties( + self, save_comments_player=None, save_comments_class=None, eval_thresholds=None, save_analysis=False + ): properties = copy.copy(super().sgf_properties()) note = self.note.strip() + if save_analysis and self.analysis_complete: + try: + properties["KT"] = analysis_dumps(self.analysis) + except Exception as e: + print(f"Error in saving analysis: {e}") if self.points_lost and save_comments_class is not None and eval_thresholds is not None: show_class = save_comments_class[evaluation_class(self.points_lost, eval_thresholds)] else: show_class = False + comments = properties.get("C", []) if ( self.parent and self.parent.analysis_exists @@ -50,18 +132,29 @@ class GameNode(SGFNode): properties["SQ"] = best_sq if top_x and "MA" not in properties: properties["MA"] = [top_x] - comment = self.comment(sgf=True, interactive=False) - if comment: - properties["C"] = ["\n".join(properties.get("C", "")) + comment] + comments.append(self.comment(sgf=True, interactive=False) + SGF_INTERNAL_COMMENTS_MARKER) if self.is_root: - properties["C"] = [ - i18n._("SGF start message") - + "\n" - + "\n".join(properties.get("C", "")) - + "\nSGF with review generated by KaTrain." + comments = [ + i18n._("SGF start message") + SGF_INTERNAL_COMMENTS_MARKER + "\n", + *comments, + f"\nSGF generated by {PROGRAM_NAME} {VERSION}{SGF_INTERNAL_COMMENTS_MARKER}\n", ] + properties["CA"] = ["UTF-8"] + properties["AP"] = [f"{PROGRAM_NAME}:{VERSION}"] + if self.shortcut_from: + properties["KTSF"] = [id(self.shortcut_from)] + elif "KTSF" in properties: + del properties["KTSF"] + if self.shortcuts_to: + properties["KTSID"] = [id(self)] + elif "KTSID" in properties: + del properties["KTSID"] if note: - properties["C"] = ["\n".join(properties.get("C", "")) + f"\nNote: {self.note}"] + comments.append(f"{self.note}") + if comments: + properties["C"] = [SGF_SEPARATOR_MARKER.join(comments)] + elif "C" in properties: + del properties["C"] return properties @staticmethod @@ -80,13 +173,14 @@ class GameNode(SGFNode): refine_move=None, analyze_fast=False, find_alternatives=False, - find_local=False, - report_every=0.25, + region_of_interest=None, + report_every=REPORT_DT, ): + additional_moves = bool(find_alternatives or region_of_interest) engine.request_analysis( self, callback=lambda result, partial_result: self.set_analysis( - result, refine_move, find_alternatives or find_local, partial_result + result, refine_move, additional_moves, partial_result ), priority=priority, visits=visits, @@ -94,7 +188,7 @@ class GameNode(SGFNode): time_limit=time_limit, next_move=refine_move, find_alternatives=find_alternatives, - find_local=find_local, + region_of_interest=region_of_interest, report_every=report_every, ) @@ -112,7 +206,11 @@ class GameNode(SGFNode): cur.update(move_analysis) def set_analysis( - self, analysis_json: Dict, refine_move: Optional[Move], additional_moves: bool, partial_result: bool = False + self, + analysis_json: Dict, + refine_move: Optional[Move] = None, + additional_moves: bool = False, + partial_result: bool = False, ): if refine_move: pvtail = analysis_json["moveInfos"][0]["pv"] if analysis_json["moveInfos"] else [] @@ -128,8 +226,8 @@ class GameNode(SGFNode): move_dict["order"] = 999 # old moves to end for move_analysis in analysis_json["moveInfos"]: self.update_move_analysis(move_analysis, move_analysis["move"]) - self.ownership = analysis_json.get("ownership") - self.policy = analysis_json.get("policy") + self.analysis["ownership"] = analysis_json.get("ownership") + self.analysis["policy"] = analysis_json.get("policy") if not additional_moves: self.analysis["root"] = analysis_json["rootInfo"] if self.parent and self.move: @@ -142,6 +240,14 @@ class GameNode(SGFNode): is_normal_query = refine_move is None and not additional_moves self.analysis["completed"] = self.analysis["completed"] or (is_normal_query and not partial_result) + @property + def ownership(self): + return self.analysis.get("ownership") + + @property + def policy(self): + return self.analysis.get("policy") + @property def analysis_exists(self): return self.analysis["root"] is not None @@ -182,7 +288,7 @@ class GameNode(SGFNode): def make_pv(self, player, pv, interactive): pvtext = f"{player}{' '.join(pv)}" if interactive: - pvtext = f"[u][ref={pvtext}][color={INFO_PV_COLOR}]{pvtext}[/color][/ref][/u]" + pvtext = f"[u][ref={pvtext}][color={Theme.INFO_PV_COLOR}]{pvtext}[/color][/ref][/u]" return pvtext def comment(self, sgf=False, teach=False, details=False, interactive=True): @@ -276,6 +382,7 @@ class GameNode(SGFNode): { **self.analysis["root"], "pointsLost": 0, + "winrateLost": 0, "order": 0, "move": top_polmove.gtp(), "pv": [top_polmove.gtp()], @@ -283,10 +390,15 @@ class GameNode(SGFNode): ] # single visit -> go by policy/root root_score = self.analysis["root"]["scoreLead"] + root_winrate = self.analysis["root"]["winrate"] move_dicts = list(self.analysis["moves"].values()) # prevent incoming analysis from causing crash return sorted( [ - {"pointsLost": self.player_sign(self.next_player) * (root_score - d["scoreLead"]), **d} + { + "pointsLost": self.player_sign(self.next_player) * (root_score - d["scoreLead"]), + "winrateLost": self.player_sign(self.next_player) * (root_winrate - d["winrate"]), + **d, + } for d in move_dicts ], key=lambda d: (d["order"], d["pointsLost"]), diff --git a/katrain/core/lang.py b/katrain/core/lang.py index 7cff152..28eb2af 100644 --- a/katrain/core/lang.py +++ b/katrain/core/lang.py @@ -5,13 +5,13 @@ import sys from kivy._event import Observable from katrain.core.utils import find_package_resource -from katrain.gui.style import DEFAULT_FONT +from katrain.gui.theme import Theme class Lang(Observable): observers = [] callbacks = [] - FONTS = {"jp": "fonts/NotoSansJP-Regular.otf"} + FONTS = {"jp": "NotoSansJP-Regular.otf"} def __init__(self, lang): super(Lang, self).__init__() @@ -53,7 +53,7 @@ class Lang(Observable): return # get the right locales directory, and instantiate a gettext self.lang = lang - self.font_name = self.FONTS.get(lang) or DEFAULT_FONT + self.font_name = self.FONTS.get(lang) or Theme.DEFAULT_FONT i18n_dir, _ = os.path.split(find_package_resource("katrain/i18n/__init__.py")) locale_dir = os.path.join(i18n_dir, "locales") locales = gettext.translation("katrain", locale_dir, languages=[lang, DEFAULT_LANGUAGE]) diff --git a/katrain/core/sgf_parser.py b/katrain/core/sgf_parser.py index 7f284a4..158d6a8 100644 --- a/katrain/core/sgf_parser.py +++ b/katrain/core/sgf_parser.py @@ -64,10 +64,15 @@ class Move: """Returns True if the move is a pass""" return self.coords is None + @staticmethod + def opponent_player(player): + """Returns the opposing player, i.e. W <-> B""" + return "W" if player == "B" else "B" + @property def opponent(self): """Returns the opposing player, i.e. W <-> B""" - return "W" if self.player == "B" else "B" + return self.opponent_player(self.player) class SGFNode: @@ -82,6 +87,10 @@ class SGFNode: self.parent.children.append(self) if parent and move: self.set_property(move.player, move.sgf(self.board_size)) + self._clear_cache() + + def _clear_cache(self): + self.moves_cache = None def sgf_properties(self, **xargs) -> Dict: """For hooking into in a subclass and overriding/formatting any additional properties to be output.""" @@ -134,6 +143,7 @@ class SGFNode: """Add some values to the property list.""" # SiZe[19] ==> SZ[19] etc. for old SGF normalized_property = re.sub("[a-z]", "", property) + self._clear_cache() self.properties[normalized_property] += values def get_list_property(self, property, default=None) -> Any: @@ -144,6 +154,7 @@ class SGFNode: """Add some values to the property. If not a list, it will be made into a single-value list.""" if not isinstance(value, list): value = [value] + self._clear_cache() self.properties[property] = value def get_property(self, property, default=None) -> Any: @@ -192,7 +203,12 @@ class SGFNode: @property def komi(self) -> float: """Retrieves the root's KM property, or 6.5 if missing""" - return float(self.root.get_property("KM", 6.5)) + try: + km = float(self.root.get_property("KM", 6.5)) + except ValueError: + km = 6.5 + + return km @property def ruleset(self) -> str: @@ -202,11 +218,13 @@ class SGFNode: @property def moves(self) -> List[Move]: """Returns all moves in the node - typically 'move' will be better.""" - return [ - Move.from_sgf(move, player=pl, board_size=self.board_size) - for pl in Move.PLAYERS - for move in self.get_list_property(pl, []) - ] + if self.moves_cache is None: + self.moves_cache = [ + Move.from_sgf(move, player=pl, board_size=self.board_size) + for pl in Move.PLAYERS + for move in self.get_list_property(pl, []) + ] + return self.moves_cache @property def placements(self) -> List[Move]: @@ -268,14 +286,18 @@ class SGFNode: def play(self, move) -> "SGFNode": """Either find an existing child or create a new one with the given move.""" for c in self.children: - if c.move == move: + if c.move and c.move == move: return c return self.__class__(parent=self, move=move) @property def next_player(self): """Returns player to move""" - if "B" in self.properties or ("AB" in self.properties and not "W" in self.properties): # root or black moved + if "PL" in self.properties: # explicit + return "B" if self.get_property("PL").upper().strip() == "B" else "W" + elif "B" in self.properties or ( + "AB" in self.properties and "W" not in self.properties and "AW" not in self.properties + ): # b move or setup with only black moves like root handicap return "W" else: return "B" @@ -283,7 +305,7 @@ class SGFNode: @property def player(self): """Returns player that moved last. nb root is considered white played if no handicap stones are placed""" - if "B" in self.properties or ("AB" in self.properties and not "W" in self.properties): + if "B" in self.properties or ("AB" in self.properties and "W" not in self.properties): return "B" else: return "W" @@ -321,6 +343,7 @@ class SGFNode: class SGF: + DEFAULT_ENCODING = "ISO-8859-1" # as specified by the standard _NODE_CLASS = SGFNode # Class used for SGF Nodes, can change this to something that inherits from SGFNode # https://xkcd.com/1171/ @@ -360,8 +383,11 @@ class SGF: if match: encoding = match[1].decode("ascii", errors="ignore") else: - encoding = "ISO-8859-1" # default - decoded = bin_contents.decode(encoding=encoding, errors="ignore") + encoding = cls.DEFAULT_ENCODING + try: + decoded = bin_contents.decode(encoding=encoding, errors="ignore") + except LookupError: + decoded = bin_contents.decode(encoding=cls.DEFAULT_ENCODING, errors="ignore") if is_ngf: return cls.parse_ngf(decoded) if is_gib: @@ -434,7 +460,7 @@ class SGF: re = "W+" elif "lack win" in lines[10]: re = "B+" - except: + except IndexError: pass if handicap < 0 or handicap > 9: @@ -522,7 +548,7 @@ class SGF: try: grlt = int(re.search(grlt_regex, line).group(1)) zipsu = int(re.search(zipsu_regex, line).group(1)) - except: + except: # noqa E722 return "" return gib_make_result(grlt, zipsu) @@ -556,7 +582,7 @@ class SGF: komi = int(re.search(r"GONGJE:(\d+),", line).group(1)) / 10 if komi: root.set_property("KM", komi) - except: + except: # noqa E722 pass if line.startswith("\\[GAMETAG="): @@ -565,7 +591,7 @@ class SGF: match = re.search(r"C(\d\d\d\d):(\d\d):(\d\d)", line) date = "{}-{}-{}".format(match.group(1), match.group(2), match.group(3)) root.set_property("DT", date) - except: + except: # noqa E722 pass if "RE" not in root.properties: @@ -578,7 +604,7 @@ class SGF: komi = int(re.search(r",G(\d+),", line).group(1)) / 10 if komi: root.set_property("KM", komi) - except: + except: # noqa E722 pass if line[0:3] == "INI": @@ -587,7 +613,7 @@ class SGF: setup = line.split() try: handicap = int(setup[3]) - except IndexError: + except ParseError: continue if handicap < 0 or handicap > 9: diff --git a/katrain/core/utils.py b/katrain/core/utils.py index 368270e..9bd5738 100644 --- a/katrain/core/utils.py +++ b/katrain/core/utils.py @@ -1,10 +1,11 @@ import os +import struct import sys from typing import List, Tuple, TypeVar try: import importlib.resources as pkg_resources -except: +except ImportError: import importlib_resources as pkg_resources T = TypeVar("T") @@ -53,3 +54,21 @@ def find_package_resource(path, silent_errors=False): return os.path.join(PATHS["PACKAGE"], path.replace("katrain\\", "katrain/").replace("katrain/", "")) else: return os.path.abspath(os.path.expanduser(path)) # absolute path + + +def pack_floats(float_list): + return struct.pack(f"{len(float_list)}e", *float_list) + + +def unpack_floats(str, num): + return struct.unpack(f"{num}e", str) + + +def format_visits(n): + if n < 1000: + return str(n) + if n < 1e5: + return f"{n/1000:.1f}k" + if n < 1e6: + return f"{n/1000:.0f}k" + return f"{n/1e6:.0f}k" diff --git a/katrain/gui.kv b/katrain/gui.kv index 9e876d5..78a8697 100644 --- a/katrain/gui.kv +++ b/katrain/gui.kv @@ -10,29 +10,14 @@ #:import MODE_ANALYZE katrain.core.constants.MODE_ANALYZE #:import STATUS_INFO katrain.core.constants.STATUS_INFO +#:import Theme katrain.gui.theme.Theme +#:import BLACK katrain.gui.theme.BLACK +#:import LIGHTER_GREY katrain.gui.theme.LIGHTER_GREY -#:import WHITE katrain.gui.style.WHITE -#:import BLACK katrain.gui.style.BLACK -#:import LIGHTGREY katrain.gui.style.LIGHTGREY -#:import GREEN katrain.gui.style.GREEN -#:import YELLOW katrain.gui.style.YELLOW -#:import RED katrain.gui.style.RED -#:import DARKRED katrain.gui.style.DARKRED -#:import ORANGE katrain.gui.style.ORANGE -#:import BACKGROUND_COLOR katrain.gui.style.BACKGROUND_COLOR -#:import BOX_BACKGROUND_COLOR katrain.gui.style.BOX_BACKGROUND_COLOR -#:import LIGHTER_BACKGROUND_COLOR katrain.gui.style.LIGHTER_BACKGROUND_COLOR -#:import TEXT_COLOR katrain.gui.style.TEXT_COLOR -#:import SCORE_COLOR katrain.gui.style.SCORE_COLOR -#:import WINRATE_COLOR katrain.gui.style.WINRATE_COLOR -#:import POINTLOSS_COLOR katrain.gui.style.POINTLOSS_COLOR -#:set BUTTON_INACTIVE_COLOR LIGHTGREY - -#:set CP_SPACING dp(6) -#:set CP_SMALL_SPACING dp(3) -#:set CP_PADDING dp(6) -#:set NOTES_FONT_SIZE sp(18) +#:set CP_SPACING Theme.CP_SPACING +#:set CP_SMALL_SPACING Theme.CP_SMALL_SPACING +#:set CP_PADDING Theme.CP_PADDING #:set EPSILON 1e-9 @@ -67,8 +52,8 @@ : ripple_duration_in_slow: 0.6 - text_color: TEXT_COLOR - background_color: BOX_BACKGROUND_COLOR + text_color: Theme.TEXT_COLOR + background_color: Theme.BOX_BACKGROUND_COLOR label: label text_size: root.size Label: @@ -84,27 +69,27 @@ width: root.label.texture_size[0] : - inactive_background_color: BACKGROUND_COLOR - active_background_color: BOX_BACKGROUND_COLOR + inactive_background_color: Theme.BACKGROUND_COLOR + active_background_color: Theme.BOX_BACKGROUND_COLOR background_color: self.active_background_color if self.state=='down' else self.inactive_background_color outline_color: self.active_outline_color if self.state=='down' else self.inactive_outline_color : - outline_color: WHITE + outline_color: Theme.BUTTON_BORDER_COLOR text_color: self.outline_color : - outline_color: WHITE + outline_color: Theme.BUTTON_BORDER_COLOR text_color: self.outline_color : - inactive_outline_color: BUTTON_INACTIVE_COLOR - active_outline_color: WHITE + inactive_outline_color: Theme.BUTTON_INACTIVE_COLOR + active_outline_color: Theme.BUTTON_BORDER_COLOR text_color: self.outline_color : - inactive_outline_color: BUTTON_INACTIVE_COLOR - active_outline_color: WHITE + inactive_outline_color: Theme.BUTTON_INACTIVE_COLOR + active_outline_color: Theme.BUTTON_BORDER_COLOR text_color: self.outline_color : @@ -114,7 +99,7 @@ background_radius: self.height/3.5 : - background_color: BOX_BACKGROUND_COLOR + background_color: Theme.BOX_BACKGROUND_COLOR : background_normal: '' @@ -130,20 +115,20 @@ : - background_color: LIGHTER_BACKGROUND_COLOR + background_color: Theme.LIGHTER_BACKGROUND_COLOR background_normal: '' lang_change_tracking: i18n._('') # for font - color: WHITE + color: Theme.INPUT_FONT_COLOR canvas.after: Color: - rgba: LIGHTGREY + rgba: LIGHT_GREY Line points: self.x,self.y,self.x+self.width,self.y width: 1 : -font_size: self.height * 0.5 - background_color: [*[c*255/88 for c in BACKGROUND_COLOR[:3]], 1] # compensate for texture + background_color: [*[c*255/88 for c in Theme.BACKGROUND_COLOR[:3]], 1] # compensate for texture option_cls: 'I18NSpinnerOption'