v1.18.0 (#815)
Co-authored-by: Artur Tamazian <artur-tamazian> Co-authored-by: Javier Gonzalez <javierggt@yahoo.com>
This commit is contained in:
34 files changed
+2285
-464
No files matched your search
+9
-4
@@ -15,11 +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.
|
||||
|
||||
You can also find downloadable .app files for macOS [here](https://github.com/sanderland/katrain/releases).
|
||||
Simply download, unzip the file, mount the .dmg and drag the .app file to your application folder, everything is included.
|
||||
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'.
|
||||
You can also find downloadable macOS installers [here](https://github.com/sanderland/katrain/releases). Recent releases include both Intel (`KaTrain-*-x86_64.dmg`) and Apple Silicon (`KaTrain-*-arm64.dmg`) installers, so download the one matching your Mac. Mount the `.dmg` and drag the `.app` to your Applications folder.
|
||||
|
||||
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.
|
||||
The first time you launch the app, macOS may block it as an app from an unknown developer.
|
||||
Follow Apple's current guide [here](https://support.apple.com/guide/mac-help/open-a-mac-app-from-an-unidentified-developer-mh40616/mac):
|
||||
1. Try opening the app once from Finder.
|
||||
2. Open `System Settings > Privacy & Security`.
|
||||
3. In the Security section, click `Open` and then `Open Anyway` for KaTrain.
|
||||
4. Enter your password to confirm.
|
||||
|
||||
This is simply a result of Apple charging $99/year to developers to be 'identified'.
|
||||
|
||||
### <a name="MacCommand"></a>Command line install guide
|
||||
|
||||
|
||||
+38
-9
@@ -29,6 +29,12 @@ else:
|
||||
Config.set("kivy", "window_icon", ICON)
|
||||
Config.set("input", "mouse", "mouse,multitouch_on_demand")
|
||||
|
||||
# Preload sounds before Window import creates the SDL2 window,
|
||||
# as SoundLoader.load() deadlocks once the SDL2 window exists.
|
||||
from katrain.gui.sound import preload_sounds
|
||||
|
||||
preload_sounds(PATHS["PACKAGE"] + "/sounds")
|
||||
|
||||
# next, certificates on package builds https://github.com/sanderland/katrain/issues/414
|
||||
if getattr(sys, "frozen", False):
|
||||
import ssl
|
||||
@@ -93,7 +99,7 @@ from katrain.gui.popups import (
|
||||
)
|
||||
from katrain.gui.sound import play_sound
|
||||
from katrain.core.base_katrain import KaTrainBase
|
||||
from katrain.core.engine import KataGoEngine
|
||||
from katrain.core.remote_engine import make_engine
|
||||
from katrain.core.contribute_engine import KataGoContributeEngine
|
||||
from katrain.core.game import Game, IllegalMoveException, KaTrainSGF, BaseGame
|
||||
from katrain.core.sgf_parser import Move, ParseError
|
||||
@@ -187,7 +193,7 @@ class KaTrainGui(Screen, KaTrainBase):
|
||||
if self.engine:
|
||||
return
|
||||
self.board_gui.trainer_config = self.config("trainer")
|
||||
self.engine = KataGoEngine(self, self.config("engine"))
|
||||
self.engine = make_engine(self, self.config("engine"))
|
||||
threading.Thread(target=self._message_loop_thread, daemon=True).start()
|
||||
sgf_args = [
|
||||
f
|
||||
@@ -207,6 +213,20 @@ class KaTrainGui(Screen, KaTrainBase):
|
||||
|
||||
MDApp.get_running_app().root_window.bind(focus=set_focus_event)
|
||||
|
||||
def restart_engine(self):
|
||||
"""Rebuild the analysis engine from current config and re-analyze.
|
||||
Shared by the engine-settings save and the recovery popup's Retry."""
|
||||
self.log("Restarting engine", OUTPUT_INFO)
|
||||
self.controls.set_status(i18n._("restarting engine"), STATUS_INFO)
|
||||
old_engine = self.engine
|
||||
old_engine.shutdown(finish=False)
|
||||
new_engine = make_engine(self, self.config("engine"))
|
||||
self.engine = new_engine
|
||||
self.game.engines = {"B": new_engine, "W": new_engine}
|
||||
# old engine was possibly broken, so make sure we redo any failures
|
||||
self.game.analyze_all_nodes(analyze_fast=True)
|
||||
self.update_state()
|
||||
|
||||
def update_gui(self, cn, redraw_board=False):
|
||||
# Handle prisoners and next player display
|
||||
prisoners = self.game.prisoner_count
|
||||
@@ -226,7 +246,7 @@ class KaTrainGui(Screen, KaTrainBase):
|
||||
self.controls.players["B"].captures = prisoners["B"]
|
||||
|
||||
# update engine status dot
|
||||
if not self.engine or not self.engine.katago_process or self.engine.katago_process.poll() is not None:
|
||||
if not self.engine or not self.engine.check_alive():
|
||||
self.board_controls.engine_status_col = Theme.ENGINE_DOWN_COLOR
|
||||
elif self.engine.is_idle():
|
||||
self.board_controls.engine_status_col = Theme.ENGINE_READY_COLOR
|
||||
@@ -512,7 +532,7 @@ class KaTrainGui(Screen, KaTrainBase):
|
||||
self.ai_settings_popup.content.popup = self.ai_settings_popup
|
||||
self.ai_settings_popup.open()
|
||||
|
||||
def _do_engine_recovery_popup(self, error_message, code):
|
||||
def _do_engine_recovery_popup(self, error_message, code, engine_type="local"):
|
||||
current_open = self.popup_open
|
||||
if current_open and isinstance(current_open.content, EngineRecoveryPopup):
|
||||
self.log(f"Not opening engine recovery popup with {error_message} as one is already open", OUTPUT_DEBUG)
|
||||
@@ -520,7 +540,7 @@ class KaTrainGui(Screen, KaTrainBase):
|
||||
popup = I18NPopup(
|
||||
title_key="engine recovery",
|
||||
size=[dp(600), dp(700)],
|
||||
content=EngineRecoveryPopup(self, error_message=error_message, code=code),
|
||||
content=EngineRecoveryPopup(self, error_message=error_message, code=code, engine_type=engine_type),
|
||||
).__self__
|
||||
popup.content.popup = popup
|
||||
popup.open()
|
||||
@@ -845,14 +865,19 @@ class KaTrainApp(MDApp):
|
||||
def is_valid_window_position(self, left, top, width, height):
|
||||
try:
|
||||
from screeninfo import get_monitors
|
||||
|
||||
monitors = get_monitors()
|
||||
for monitor in monitors:
|
||||
if (left >= monitor.x and left + width <= monitor.x + monitor.width and
|
||||
top >= monitor.y and top + height <= monitor.y + monitor.height):
|
||||
if (
|
||||
left >= monitor.x
|
||||
and left + width <= monitor.x + monitor.width
|
||||
and top >= monitor.y
|
||||
and top + height <= monitor.y + monitor.height
|
||||
):
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
return True # yolo
|
||||
return True # yolo
|
||||
|
||||
def build(self):
|
||||
self.icon = ICON # how you're supposed to set an icon
|
||||
@@ -905,7 +930,11 @@ class KaTrainApp(MDApp):
|
||||
win_size = [1300 * window_scale_fac, 1000 * window_scale_fac]
|
||||
self.gui.log(f"Setting window size to {win_size} and position to {[win_left, win_top]}", OUTPUT_DEBUG)
|
||||
Window.size = (win_size[0], win_size[1])
|
||||
if win_left is not None and win_top is not None and self.is_valid_window_position(win_left, win_top, win_size[0], win_size[1]):
|
||||
if (
|
||||
win_left is not None
|
||||
and win_top is not None
|
||||
and self.is_valid_window_position(win_left, win_top, win_size[0], win_size[1])
|
||||
):
|
||||
Window.left = win_left
|
||||
Window.top = win_top
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"engine": {
|
||||
"backend": "",
|
||||
"katago": "",
|
||||
"altcommand": "",
|
||||
"remote_url": "",
|
||||
"model": "katrain/models/kata1-b18c384nbt-s9996604416-d4316597426.bin.gz",
|
||||
"humanlike_model": "",
|
||||
"config": "katrain/KataGo/analysis_config.cfg",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
PROGRAM_NAME = "KaTrain"
|
||||
VERSION = "1.17.1"
|
||||
VERSION = "1.18.0"
|
||||
HOMEPAGE = "https://github.com/sanderland/katrain"
|
||||
CONFIG_MIN_VERSION = "1.17.0" # keep config files from this version
|
||||
ANALYSIS_FORMAT_VERSION = "1.0"
|
||||
|
||||
+22
-2
@@ -26,6 +26,25 @@ from katrain.core.lang import i18n
|
||||
from katrain.core.sgf_parser import Move
|
||||
from katrain.core.utils import find_package_resource, json_truncate_arrays
|
||||
|
||||
ENGINE_BACKENDS = ("local", "remote", "custom")
|
||||
|
||||
|
||||
def resolve_engine_backend(config) -> str:
|
||||
"""Which engine backend to use: 'local', 'remote' or 'custom'.
|
||||
|
||||
Uses the explicit `engine/backend` setting when present (set by the engine
|
||||
settings tabs). Falls back to deriving it from the legacy fields so older
|
||||
configs keep working: a remote_url means remote, an altcommand means custom.
|
||||
"""
|
||||
backend = (config.get("backend") or "").strip().lower()
|
||||
if backend in ENGINE_BACKENDS:
|
||||
return backend
|
||||
if (config.get("remote_url") or "").strip():
|
||||
return "remote"
|
||||
if (config.get("altcommand") or "").strip():
|
||||
return "custom"
|
||||
return "local"
|
||||
|
||||
|
||||
class BaseEngine: # some common elements between analysis and contribute engine
|
||||
|
||||
@@ -95,6 +114,7 @@ class KataGoEngine(BaseEngine):
|
||||
"""Starts and communicates with the KataGO analysis engine"""
|
||||
|
||||
PONDER_KEY = "_kt_continuous"
|
||||
ENGINE_TYPE = "local" # passed to the recovery popup so it can tailor its advice
|
||||
|
||||
def __init__(self, katrain, config):
|
||||
super().__init__(katrain, config)
|
||||
@@ -112,7 +132,7 @@ class KataGoEngine(BaseEngine):
|
||||
self.shell = False
|
||||
self.write_queue = queue.Queue()
|
||||
self.thread_lock = threading.Lock()
|
||||
if config.get("altcommand", ""):
|
||||
if resolve_engine_backend(config) == "custom":
|
||||
self.command = config["altcommand"]
|
||||
self.shell = True
|
||||
else:
|
||||
@@ -146,7 +166,7 @@ class KataGoEngine(BaseEngine):
|
||||
def on_error(self, message, code=None, allow_popup=True):
|
||||
self.katrain.log(message, OUTPUT_ERROR)
|
||||
if self.allow_recovery and allow_popup:
|
||||
self.katrain("engine_recovery_popup", message, code)
|
||||
self.katrain("engine_recovery_popup", message, code, self.ENGINE_TYPE)
|
||||
|
||||
def start(self):
|
||||
with self.thread_lock:
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
"""Remote KataGo Analysis Engine over WebSocket.
|
||||
|
||||
When `engine.remote_url` is set in the config, KaTrain runs queries
|
||||
against a remote KataGo Analysis Engine instead of spawning a local
|
||||
subprocess. The server is expected to expose the KataGo Analysis
|
||||
Engine JSON protocol over a WebSocket.
|
||||
|
||||
Generic transport — any KataGo-compatible server works. KaTrain has
|
||||
no awareness of who hosts the engine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import certifi
|
||||
from websocket import ( # provided by `websocket-client`
|
||||
ABNF,
|
||||
WebSocket,
|
||||
WebSocketException,
|
||||
WebSocketTimeoutException,
|
||||
create_connection,
|
||||
)
|
||||
|
||||
from katrain.core.constants import (
|
||||
OUTPUT_DEBUG,
|
||||
OUTPUT_ERROR,
|
||||
OUTPUT_EXTRA_DEBUG,
|
||||
OUTPUT_INFO,
|
||||
STATUS_INFO,
|
||||
)
|
||||
from katrain.core.engine import BaseEngine, KataGoEngine, resolve_engine_backend
|
||||
from katrain.core.lang import i18n
|
||||
from katrain.core.utils import json_truncate_arrays
|
||||
|
||||
|
||||
class RemoteKataGoEngine(KataGoEngine):
|
||||
"""KataGo engine that talks to a remote Analysis Engine server
|
||||
over a WebSocket instead of spawning a local subprocess.
|
||||
|
||||
Activated when `engine.remote_url` in the config is non-empty.
|
||||
The URL must be `ws://...` or `wss://...`.
|
||||
"""
|
||||
|
||||
ENGINE_TYPE = "remote" # recovery popup shows remote-specific advice (check URL, not executable)
|
||||
|
||||
READ_TIMEOUT_S = 120
|
||||
|
||||
# A dropped WebSocket is recoverable (server restart, flaky network),
|
||||
# unlike a crashed local subprocess. Reconnect transparently with
|
||||
# linear backoff before falling back to the recovery popup.
|
||||
RECONNECT_ATTEMPTS = 6
|
||||
RECONNECT_BACKOFF_S = 1.0
|
||||
RECONNECT_MAX_BACKOFF_S = 10.0
|
||||
|
||||
def __init__(self, katrain, config):
|
||||
# Bypass KataGoEngine.__init__'s subprocess setup; initialise
|
||||
# only the bookkeeping the rest of KaTrain reads.
|
||||
BaseEngine.__init__(self, katrain, config)
|
||||
self.allow_recovery = self.config.get("allow_recovery", True)
|
||||
self.queries = {}
|
||||
# query id -> JSON payload, kept so outstanding analyses can be
|
||||
# re-sent after a reconnect (the server loses them on disconnect).
|
||||
self.sent_payloads = {}
|
||||
self.ponder_query = None
|
||||
self.query_counter = 0
|
||||
self.katago_process = None # rest of the codebase checks this
|
||||
self.base_priority = 0
|
||||
self.override_settings = {"reportAnalysisWinratesAs": "BLACK"}
|
||||
self.write_queue = queue.Queue()
|
||||
self.thread_lock = threading.Lock()
|
||||
self.shell = False
|
||||
self.command = "<remote websocket>"
|
||||
|
||||
self.remote_url = (config.get("remote_url") or "").strip()
|
||||
if not self.remote_url:
|
||||
self.on_error(
|
||||
i18n._("Remote KataGo URL is empty"),
|
||||
"REMOTE-URL-MISSING",
|
||||
allow_popup=False,
|
||||
)
|
||||
return
|
||||
if not self.remote_url.startswith(("ws://", "wss://")):
|
||||
self.on_error(
|
||||
i18n._("Remote KataGo URL must start with ws:// or wss://"),
|
||||
"REMOTE-URL-INVALID",
|
||||
allow_popup=False,
|
||||
)
|
||||
return
|
||||
|
||||
self.ws: WebSocket | None = None
|
||||
self.ws_send_lock = threading.Lock()
|
||||
self.analysis_thread = None
|
||||
self.write_stdin_thread = None
|
||||
self.stderr_thread = None
|
||||
self._closing = False
|
||||
self._reported_dead = False
|
||||
self._reconnecting = False
|
||||
# Bumped on each (re)connect. I/O threads capture the value they
|
||||
# were started with so a stale thread blocked on a dead socket
|
||||
# can't trigger a reconnect that would tear down a newer one.
|
||||
self._conn_id = 0
|
||||
|
||||
self.start()
|
||||
|
||||
def _create_connection(self) -> WebSocket:
|
||||
# macOS's bundled Python may have no configured CA bundle,
|
||||
# so provide certifi explicitly for secure WebSockets.
|
||||
sslopt = {"ca_certs": certifi.where()} if self.remote_url.startswith("wss://") else None
|
||||
return create_connection(
|
||||
self.remote_url,
|
||||
timeout=self.READ_TIMEOUT_S,
|
||||
enable_multithread=True,
|
||||
sslopt=sslopt,
|
||||
)
|
||||
|
||||
def _start_threads(self):
|
||||
"""Bump the connection generation and launch fresh I/O threads
|
||||
bound to it. Must be called while holding thread_lock."""
|
||||
self._conn_id += 1
|
||||
conn_id = self._conn_id
|
||||
self.analysis_thread = threading.Thread(
|
||||
target=self._analysis_read_thread,
|
||||
args=(conn_id,),
|
||||
daemon=True,
|
||||
)
|
||||
self.write_stdin_thread = threading.Thread(
|
||||
target=self._write_stdin_thread,
|
||||
args=(conn_id,),
|
||||
daemon=True,
|
||||
)
|
||||
# Dummy stderr thread so callers that join all three don't
|
||||
# crash on None — remote engines have no stderr channel.
|
||||
self.stderr_thread = threading.Thread(
|
||||
target=lambda: None,
|
||||
daemon=True,
|
||||
)
|
||||
self.analysis_thread.start()
|
||||
self.write_stdin_thread.start()
|
||||
self.stderr_thread.start()
|
||||
|
||||
def start(self):
|
||||
with self.thread_lock:
|
||||
self.write_queue = queue.Queue()
|
||||
self._closing = False
|
||||
self._reported_dead = False
|
||||
self._reconnecting = False
|
||||
try:
|
||||
self.katrain.log(
|
||||
f"Connecting to remote KataGo at {self.remote_url}",
|
||||
OUTPUT_DEBUG,
|
||||
)
|
||||
self.ws = self._create_connection()
|
||||
except Exception as e:
|
||||
self.on_error(
|
||||
i18n._("Connecting to remote KataGo failed").format(
|
||||
url=self.remote_url,
|
||||
error=e,
|
||||
),
|
||||
code="REMOTE-CONNECT",
|
||||
)
|
||||
self.ws = None
|
||||
return
|
||||
|
||||
self._start_threads()
|
||||
|
||||
def _handle_disconnect(self, os_error="", conn_id=None):
|
||||
"""Called by the read/write threads when the WebSocket drops.
|
||||
Kicks off a single background reconnect attempt instead of
|
||||
immediately surfacing the (local-engine-oriented) recovery popup.
|
||||
|
||||
`conn_id` is the generation the calling thread was started with;
|
||||
a mismatch means a newer connection already exists and this is a
|
||||
stale thread, so we ignore it.
|
||||
"""
|
||||
with self.thread_lock:
|
||||
if self._closing or self._reconnecting:
|
||||
return
|
||||
if conn_id is not None and conn_id != self._conn_id:
|
||||
return
|
||||
self._reconnecting = True
|
||||
old_ws, self.ws = self.ws, None
|
||||
# Closing the dead socket unblocks the sibling thread still
|
||||
# parked in recv() so it exits promptly rather than hanging.
|
||||
if old_ws is not None:
|
||||
try:
|
||||
old_ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
threading.Thread(
|
||||
target=self._reconnect_thread,
|
||||
args=(os_error,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _reconnect_thread(self, os_error=""):
|
||||
"""Try to re-establish the WebSocket with linear backoff. On
|
||||
success, relaunch the I/O threads and re-send outstanding
|
||||
queries. Only if every attempt fails do we report the engine as
|
||||
dead (which opens the recovery popup)."""
|
||||
reconnected = False
|
||||
try:
|
||||
for attempt in range(1, self.RECONNECT_ATTEMPTS + 1):
|
||||
if self._closing:
|
||||
return
|
||||
delay = min(self.RECONNECT_BACKOFF_S * attempt, self.RECONNECT_MAX_BACKOFF_S)
|
||||
self.katrain.log(
|
||||
f"Remote KataGo disconnected ({os_error}); "
|
||||
f"reconnect attempt {attempt}/{self.RECONNECT_ATTEMPTS} in {delay:.0f}s",
|
||||
OUTPUT_INFO,
|
||||
)
|
||||
self._set_status(f"Reconnecting to remote KataGo (attempt {attempt}/{self.RECONNECT_ATTEMPTS})...")
|
||||
time.sleep(delay)
|
||||
if self._closing:
|
||||
return
|
||||
try:
|
||||
ws = self._create_connection()
|
||||
except Exception as e:
|
||||
os_error = str(e)
|
||||
continue
|
||||
|
||||
with self.thread_lock:
|
||||
if self._closing:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
self.ws = ws
|
||||
self._reported_dead = False
|
||||
self._start_threads()
|
||||
self.katrain.log("Reconnected to remote KataGo", OUTPUT_INFO)
|
||||
self._set_status("Reconnected to remote KataGo.")
|
||||
self._resend_outstanding()
|
||||
reconnected = True
|
||||
return
|
||||
finally:
|
||||
# Keep _reconnecting set across the failure report below so
|
||||
# external pollers (check_alive) stay quiet and can't latch
|
||||
# _reported_dead in a way that suppresses the popup. Only clear
|
||||
# it here on success/shutdown.
|
||||
if reconnected or self._closing:
|
||||
self._reconnecting = False
|
||||
|
||||
# Every attempt failed and we are not shutting down: report dead and
|
||||
# open the recovery popup. _report_dead is idempotent, so a later
|
||||
# poll from check_alive won't double-report.
|
||||
if not self._closing:
|
||||
self._report_dead(os_error, allow_popup=True)
|
||||
self._reconnecting = False
|
||||
|
||||
def _resend_outstanding(self):
|
||||
"""Re-send queries that were in flight when the connection
|
||||
dropped. Keyed off self.queries so anything cleared by a new
|
||||
game or an explicit terminate is not resurrected."""
|
||||
with self.thread_lock:
|
||||
payloads = [self.sent_payloads[qid] for qid in self.queries if qid in self.sent_payloads]
|
||||
if not payloads:
|
||||
return
|
||||
self.katrain.log(
|
||||
f"Re-sending {len(payloads)} outstanding queries after reconnect",
|
||||
OUTPUT_INFO,
|
||||
)
|
||||
for query in payloads:
|
||||
ws = self.ws
|
||||
if ws is None:
|
||||
return
|
||||
try:
|
||||
with self.ws_send_lock:
|
||||
ws.send(json.dumps(query))
|
||||
except Exception as e:
|
||||
self.katrain.log(f"Failed to re-send query after reconnect: {e}", OUTPUT_ERROR)
|
||||
return
|
||||
|
||||
def _set_status(self, message):
|
||||
try:
|
||||
self.katrain.controls.set_status(message, STATUS_INFO)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_new_game(self):
|
||||
# Parent clears self.queries; drop the payloads too so a later
|
||||
# reconnect doesn't resurrect a previous game's analyses.
|
||||
super().on_new_game()
|
||||
self.sent_payloads = {}
|
||||
|
||||
def shutdown(self, finish=False):
|
||||
self._closing = True
|
||||
ws = self.ws
|
||||
if finish and ws is not None:
|
||||
self.wait_to_finish()
|
||||
self.ws = None
|
||||
self.sent_payloads = {}
|
||||
if ws is not None:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
if finish is not None:
|
||||
for t in [self.write_stdin_thread, self.analysis_thread, self.stderr_thread]:
|
||||
if t and t.is_alive():
|
||||
t.join(timeout=2.0)
|
||||
|
||||
def wait_to_finish(self):
|
||||
while self.queries and self.ws is not None:
|
||||
time.sleep(0.1)
|
||||
|
||||
def _report_dead(self, os_error, allow_popup):
|
||||
"""Report the engine as disconnected exactly once. Guarded by
|
||||
thread_lock + _reported_dead so a poll from check_alive and the
|
||||
reconnect thread's final failure report can't double-fire or race."""
|
||||
with self.thread_lock:
|
||||
if self._reported_dead:
|
||||
return
|
||||
self._reported_dead = True
|
||||
self.ws = None
|
||||
self.on_error(
|
||||
i18n._("Remote KataGo engine disconnected").format(error=os_error),
|
||||
code="REMOTE-DISCONNECTED",
|
||||
allow_popup=allow_popup,
|
||||
)
|
||||
|
||||
def check_alive(self, os_error="", exception_if_dead=False, maybe_open_recovery=False):
|
||||
# An in-progress auto-reconnect is recovery, not death. Callers that
|
||||
# poll while the socket is briefly None (the AI move loops in ai.py
|
||||
# spin on check_alive every 10ms) must not flag the engine dead.
|
||||
# _reconnect_thread keeps _reconnecting set until it has reported a
|
||||
# genuine failure, so the popup is never suppressed nor premature.
|
||||
if self._reconnecting and not self._closing:
|
||||
return True
|
||||
ok = self.ws is not None and not self._closing
|
||||
if not ok and exception_if_dead:
|
||||
self._report_dead(os_error, allow_popup=maybe_open_recovery)
|
||||
return ok
|
||||
|
||||
def _read_stderr_thread(self):
|
||||
# Remote engines have no stderr channel; warnings come via the
|
||||
# `warning` field on responses. Override prevents the parent's
|
||||
# stderr thread from reading from a None process.
|
||||
return
|
||||
|
||||
def _write_stdin_thread(self, conn_id):
|
||||
"""Pop (query, callback, error_callback, next_move, node)
|
||||
tuples off write_queue, register the callback in self.queries
|
||||
so the read thread can match responses by id, then send the
|
||||
JSON query over the WebSocket. Ponder dedupe lives inside the
|
||||
lock so rapid Ponder presses don't queue duplicate analyses.
|
||||
|
||||
Bound to the connection generation `conn_id`: once a reconnect
|
||||
supersedes it, this thread stops and a fresh one takes over.
|
||||
"""
|
||||
ws = self.ws
|
||||
while ws is not None and not self._closing and conn_id == self._conn_id:
|
||||
try:
|
||||
query, callback, error_callback, next_move, node = self.write_queue.get(
|
||||
block=True,
|
||||
timeout=0.1,
|
||||
)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if self._closing or conn_id != self._conn_id:
|
||||
# Superseded mid-pop: hand the item back so the current
|
||||
# generation's writer sends it after reconnecting.
|
||||
self.write_queue.put((query, callback, error_callback, next_move, node))
|
||||
return
|
||||
with self.thread_lock:
|
||||
if "id" not in query:
|
||||
self.query_counter += 1
|
||||
query["id"] = f"QUERY:{self.query_counter}"
|
||||
|
||||
ponder = query.pop(self.PONDER_KEY, False)
|
||||
if ponder:
|
||||
pq = self.ponder_query or {}
|
||||
differences = {
|
||||
k: (pq.get(k), query.get(k))
|
||||
for k in (query.keys() | pq.keys()) - {"id", "maxVisits", "reportDuringSearchEvery"}
|
||||
if pq.get(k) != query.get(k)
|
||||
}
|
||||
if differences:
|
||||
self.stop_pondering()
|
||||
query["maxVisits"] = 10_000_000
|
||||
from katrain.core.constants import PONDERING_REPORT_DT
|
||||
|
||||
query["reportDuringSearchEvery"] = PONDERING_REPORT_DT
|
||||
self.ponder_query = query
|
||||
else:
|
||||
continue
|
||||
|
||||
terminate = query.get("action") == "terminate"
|
||||
if not terminate:
|
||||
self.queries[query["id"]] = (
|
||||
callback,
|
||||
error_callback,
|
||||
time.time(),
|
||||
next_move,
|
||||
node,
|
||||
)
|
||||
self.sent_payloads[query["id"]] = query
|
||||
tag = "ponder " if ponder else ("terminate " if terminate else "")
|
||||
self.katrain.log(
|
||||
f"Sending {tag}query {query['id']}: {json.dumps(query)}",
|
||||
OUTPUT_DEBUG,
|
||||
)
|
||||
try:
|
||||
payload = json.dumps(query)
|
||||
with self.ws_send_lock:
|
||||
ws.send(payload)
|
||||
except WebSocketException as e:
|
||||
self._handle_disconnect(os_error=str(e), conn_id=conn_id)
|
||||
return
|
||||
except Exception as e:
|
||||
self.katrain.log(
|
||||
f"Unexpected exception sending to remote KataGo: {e}",
|
||||
OUTPUT_ERROR,
|
||||
)
|
||||
traceback.print_exc()
|
||||
self._handle_disconnect(os_error=str(e), conn_id=conn_id)
|
||||
return
|
||||
|
||||
def _analysis_read_thread(self, conn_id):
|
||||
"""Read JSON responses from the WebSocket and dispatch to the
|
||||
matching callbacks in self.queries.
|
||||
|
||||
Bound to the connection generation `conn_id` and to the socket
|
||||
captured at start, so a thread left over from a previous
|
||||
connection never reads from (or tears down) a newer one.
|
||||
"""
|
||||
ws = self.ws
|
||||
while ws is not None and not self._closing and conn_id == self._conn_id:
|
||||
try:
|
||||
# recv_data(control_frame=True) lets us inspect non-text frames
|
||||
# (e.g. close, whose payload carries the status code + reason).
|
||||
opcode, data = ws.recv_data(control_frame=True)
|
||||
except WebSocketTimeoutException:
|
||||
continue
|
||||
except WebSocketException as e:
|
||||
if self._closing:
|
||||
return
|
||||
self._handle_disconnect(os_error=str(e), conn_id=conn_id)
|
||||
return
|
||||
except Exception as e:
|
||||
if self._closing:
|
||||
return
|
||||
self.katrain.log(
|
||||
f"Unexpected exception reading from remote KataGo: {e}",
|
||||
OUTPUT_ERROR,
|
||||
)
|
||||
traceback.print_exc()
|
||||
self._handle_disconnect(os_error=str(e), conn_id=conn_id)
|
||||
return
|
||||
|
||||
if opcode == ABNF.OPCODE_CLOSE:
|
||||
if not self._closing:
|
||||
# RFC 6455 close payload: 2-byte status code + UTF-8 reason.
|
||||
reason = "closed by remote"
|
||||
if data and len(data) >= 2:
|
||||
reason = data[2:].decode("utf-8", errors="replace").strip() or reason
|
||||
self._handle_disconnect(os_error=reason, conn_id=conn_id)
|
||||
return
|
||||
|
||||
if opcode not in (ABNF.OPCODE_TEXT, ABNF.OPCODE_BINARY):
|
||||
# ping/pong/continuation — nothing to dispatch.
|
||||
continue
|
||||
|
||||
if not data:
|
||||
continue
|
||||
|
||||
raw = data.decode("utf-8") if isinstance(data, bytes) else data
|
||||
|
||||
# A frame may contain multiple newline-delimited JSON
|
||||
# objects (matches KataGo's stdio framing).
|
||||
for line in str(raw).splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
self._dispatch_response_line(line)
|
||||
|
||||
def _dispatch_response_line(self, line: str) -> None:
|
||||
"""Parse one JSON response line and dispatch to the matching
|
||||
query callback. Warning text is mirrored to the UI status
|
||||
panel so users see notices like "visits capped" without
|
||||
reading the log."""
|
||||
try:
|
||||
analysis = json.loads(line)
|
||||
except json.JSONDecodeError as e:
|
||||
self.katrain.log(
|
||||
f"Bad JSON from remote KataGo: {e} (line: {line[:200]!r})",
|
||||
OUTPUT_ERROR,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if "id" not in analysis:
|
||||
self.katrain.log(
|
||||
f"Error without ID {analysis} received from remote KataGo",
|
||||
OUTPUT_ERROR,
|
||||
)
|
||||
return
|
||||
|
||||
query_id = analysis["id"]
|
||||
if query_id not in self.queries:
|
||||
if analysis.get("action") != "terminate":
|
||||
self.katrain.log(
|
||||
f"Query result {query_id} discarded -- recent new game or node reset?",
|
||||
OUTPUT_DEBUG,
|
||||
)
|
||||
return
|
||||
|
||||
callback, error_callback, start_time, next_move, _ = self.queries[query_id]
|
||||
|
||||
# Handled BEFORE the dispatch chain so analysis data
|
||||
# alongside a warning still reaches the callback.
|
||||
if "warning" in analysis:
|
||||
warning_text = str(analysis.get("warning"))
|
||||
self.katrain.log(
|
||||
f"Remote KataGo warning: {warning_text}",
|
||||
OUTPUT_INFO,
|
||||
)
|
||||
try:
|
||||
self.katrain.controls.set_status(warning_text, STATUS_INFO)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if "error" in analysis:
|
||||
del self.queries[query_id]
|
||||
self.sent_payloads.pop(query_id, None)
|
||||
if error_callback:
|
||||
error_callback(analysis)
|
||||
elif not (next_move and "Illegal move" in analysis["error"]):
|
||||
self.katrain.log(
|
||||
f"{analysis} received from remote KataGo",
|
||||
OUTPUT_ERROR,
|
||||
)
|
||||
elif "terminateId" in analysis:
|
||||
self.katrain.log(
|
||||
f"{analysis} received from remote KataGo",
|
||||
OUTPUT_DEBUG,
|
||||
)
|
||||
else:
|
||||
partial_result = analysis.get("isDuringSearch", False)
|
||||
if not partial_result:
|
||||
del self.queries[query_id]
|
||||
self.sent_payloads.pop(query_id, None)
|
||||
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'}] "
|
||||
f"KataGo analysis received: {len(analysis.get('moveInfos', []))} "
|
||||
f"candidate moves, "
|
||||
f"{analysis['rootInfo']['visits'] if results_exist else 'n/a'} visits",
|
||||
OUTPUT_DEBUG,
|
||||
)
|
||||
self.katrain.log(json_truncate_arrays(analysis), OUTPUT_EXTRA_DEBUG)
|
||||
try:
|
||||
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,
|
||||
)
|
||||
traceback.print_exc()
|
||||
|
||||
if getattr(self.katrain, "update_state", None):
|
||||
self.katrain.update_state()
|
||||
except Exception as e:
|
||||
self.katrain.log(
|
||||
f"Unexpected exception {e} processing remote KataGo output: {line[:200]!r}",
|
||||
OUTPUT_ERROR,
|
||||
)
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def make_engine(katrain, config):
|
||||
"""Return the engine matching the selected backend (see resolve_engine_backend):
|
||||
a RemoteKataGoEngine for the remote backend, otherwise a local-subprocess
|
||||
KataGoEngine (which itself handles the local vs custom-command distinction)."""
|
||||
if resolve_engine_backend(config) == "remote":
|
||||
return RemoteKataGoEngine(katrain, config)
|
||||
return KataGoEngine(katrain, config)
|
||||
+62
-27
@@ -24,6 +24,7 @@ from kivymd.uix.selectioncontrol import MDCheckbox
|
||||
from kivymd.uix.textfield import MDTextField
|
||||
|
||||
from katrain.core.ai import ai_rank_estimation, game_report
|
||||
from katrain.core.engine import resolve_engine_backend
|
||||
from katrain.core.constants import (
|
||||
AI_CONFIG_DEFAULT,
|
||||
AI_DEFAULT,
|
||||
@@ -39,7 +40,6 @@ from katrain.core.constants import (
|
||||
PLAYER_HUMAN,
|
||||
ADDITIONAL_MOVE_ORDER,
|
||||
)
|
||||
from katrain.core.engine import KataGoEngine
|
||||
from katrain.core.lang import i18n, rank_label
|
||||
from katrain.core.sgf_parser import Move
|
||||
from katrain.core.utils import PATHS, find_package_resource, evaluation_class
|
||||
@@ -455,11 +455,36 @@ class ConfigAIPopup(QuickConfigGui):
|
||||
class EngineRecoveryPopup(QuickConfigGui):
|
||||
error_message = StringProperty("")
|
||||
code = ObjectProperty(None)
|
||||
engine_type = StringProperty("local")
|
||||
recovery_message = StringProperty("")
|
||||
|
||||
def __init__(self, katrain, error_message, code):
|
||||
def __init__(self, katrain, error_message, code, engine_type="local"):
|
||||
super().__init__(katrain)
|
||||
self.error_message = str(error_message)
|
||||
self.code = code
|
||||
self.engine_type = engine_type or "local"
|
||||
self.recovery_message = self._build_message()
|
||||
|
||||
def _build_message(self):
|
||||
settings_link = "[color=#CCCC11][u][ref=engine_settings]" + i18n._("menu:settings") + "[/ref][/u][/color]"
|
||||
help_link = "[color=#CCCC11][u][ref=engine_help]" + i18n._("link_here") + "[/ref][/u][/color]"
|
||||
if self.engine_type == "remote":
|
||||
opening_key = "remote engine disconnected popup opening message"
|
||||
suggestion = i18n._("remote engine check url suggestion").format(link=settings_link)
|
||||
else:
|
||||
opening_key = "engine died popup opening message"
|
||||
suggestion = i18n._("change engine suggestion").format(link=settings_link)
|
||||
opening = i18n._(opening_key).format(code=self.code, error_message=self.error_message)
|
||||
help_text = i18n._("go to engine help page").format(link=help_link)
|
||||
return opening + "\n\n" + suggestion + "\n\n" + help_text
|
||||
|
||||
def retry(self):
|
||||
"""Rebuild the engine from current config and re-analyze. For a
|
||||
remote engine this reconnects; for a local one it respawns the
|
||||
subprocess. Recovers a transient failure without changing settings."""
|
||||
if self.popup:
|
||||
self.popup.dismiss()
|
||||
Clock.schedule_once(lambda _dt: self.katrain.restart_engine(), 0)
|
||||
|
||||
|
||||
class BaseConfigPopup(QuickConfigGui):
|
||||
@@ -481,23 +506,28 @@ class BaseConfigPopup(QuickConfigGui):
|
||||
|
||||
KATAGOS = {
|
||||
"win": {
|
||||
"OpenCL v1.16.0": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-opencl-windows-x64.zip",
|
||||
"Eigen AVX2 (Modern CPUs) v1.16.0": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-eigenavx2-windows-x64.zip",
|
||||
"Eigen (CPU, Non-optimized) v1.16.0": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-eigen-windows-x64.zip",
|
||||
"OpenCL v1.16.0 (bigger boards)": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-opencl-windows-x64+bs50.zip",
|
||||
"OpenCL v1.16.5": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-opencl-windows-x64.zip",
|
||||
"Eigen AVX2 (Modern CPUs) v1.16.5": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-eigenavx2-windows-x64.zip",
|
||||
"Eigen (CPU, Non-optimized) v1.16.5": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-eigen-windows-x64.zip",
|
||||
"OpenCL v1.16.5 (bigger boards)": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-opencl-windows-x64+bs50.zip",
|
||||
},
|
||||
"linux": {
|
||||
"OpenCL v1.16.0": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-opencl-linux-x64.zip",
|
||||
"Eigen AVX2 (Modern CPUs) v1.16.0": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-eigenavx2-linux-x64.zip",
|
||||
"Eigen (CPU, Non-optimized) v1.16.0": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-eigen-linux-x64.zip",
|
||||
"OpenCL v1.16.0 (bigger boards)": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-opencl-linux-x64+bs50.zip",
|
||||
"OpenCL v1.16.5": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-opencl-linux-x64.zip",
|
||||
"Eigen AVX2 (Modern CPUs) v1.16.5": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-eigenavx2-linux-x64.zip",
|
||||
"Eigen (CPU, Non-optimized) v1.16.5": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-eigen-linux-x64.zip",
|
||||
"OpenCL v1.16.5 (bigger boards)": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-opencl-linux-x64+bs50.zip",
|
||||
},
|
||||
"just-descriptions": {},
|
||||
}
|
||||
|
||||
def __init__(self, katrain):
|
||||
super().__init__(katrain)
|
||||
self.paths = [self.katrain.config("engine/model"), self.katrain.config("engine/humanlike_model"), "katrain/models", DATA_FOLDER]
|
||||
self.paths = [
|
||||
self.katrain.config("engine/model"),
|
||||
self.katrain.config("engine/humanlike_model"),
|
||||
"katrain/models",
|
||||
DATA_FOLDER,
|
||||
]
|
||||
self.katago_paths = [self.katrain.config("engine/katago"), DATA_FOLDER]
|
||||
self.last_clicked_download_models = 0
|
||||
|
||||
@@ -561,7 +591,9 @@ class BaseConfigPopup(QuickConfigGui):
|
||||
key=lambda descpath: ("Recommended" not in descpath[0], " - " not in descpath[0], descpath[0]),
|
||||
)
|
||||
humanlike_models_available_msg = i18n._("models available").format(num=len(humanlike_model_files))
|
||||
self.humanlike_model_files.values = [humanlike_models_available_msg] + [desc for desc, path in humanlike_model_files]
|
||||
self.humanlike_model_files.values = [humanlike_models_available_msg] + [
|
||||
desc for desc, path in humanlike_model_files
|
||||
]
|
||||
self.humanlike_model_files.value_keys = [""] + [path for desc, path in humanlike_model_files]
|
||||
self.humanlike_model_files.text = humanlike_models_available_msg
|
||||
|
||||
@@ -650,7 +682,9 @@ class BaseConfigPopup(QuickConfigGui):
|
||||
|
||||
for name, url in {**self.MODELS, **dist_models}.items():
|
||||
filename = os.path.split(url)[1]
|
||||
if not any(os.path.split(f)[1] == filename for f in self.model_files.values + self.humanlike_model_files.values):
|
||||
if not any(
|
||||
os.path.split(f)[1] == filename for f in self.model_files.values + self.humanlike_model_files.values
|
||||
):
|
||||
savepath = os.path.expanduser(os.path.join(DATA_FOLDER, filename))
|
||||
savepath_tmp = savepath + ".part"
|
||||
self.katrain.log(f"Downloading {name} from {url} to {savepath_tmp}", OUTPUT_INFO)
|
||||
@@ -759,14 +793,28 @@ class BaseConfigPopup(QuickConfigGui):
|
||||
|
||||
|
||||
class ConfigPopup(BaseConfigPopup):
|
||||
ENGINE_TAB_BUTTONS = {"local": "local_tab_button", "remote": "remote_tab_button", "custom": "custom_tab_button"}
|
||||
|
||||
def __init__(self, katrain):
|
||||
super().__init__(katrain)
|
||||
Clock.schedule_once(self.check_katas)
|
||||
Clock.schedule_once(self.select_engine_tab)
|
||||
MDApp.get_running_app().bind(language=self.check_models)
|
||||
MDApp.get_running_app().bind(language=self.check_katas)
|
||||
|
||||
def select_engine_tab(self, *_args):
|
||||
# The active tab is authoritative for which engine is used; pick it based on the current config.
|
||||
backend = resolve_engine_backend(self.katrain.config("engine"))
|
||||
self.engine_sm.current = backend
|
||||
getattr(self, self.ENGINE_TAB_BUTTONS[backend]).state = "down"
|
||||
|
||||
def update_config(self, save_to_file=True, close_popup=True):
|
||||
old_backend = self.katrain.config("engine/backend", "")
|
||||
backend = self.engine_sm.current
|
||||
self.katrain._config["engine"]["backend"] = backend
|
||||
updated = super().update_config(save_to_file=save_to_file, close_popup=close_popup)
|
||||
if backend != old_backend:
|
||||
updated.add("engine/backend")
|
||||
self.katrain.debug_level = self.katrain.config("general/debug_level", OUTPUT_INFO)
|
||||
|
||||
ignore = {"max_visits", "fast_visits", "max_time", "enable_ownership", "wide_root_noise"}
|
||||
@@ -774,21 +822,8 @@ class ConfigPopup(BaseConfigPopup):
|
||||
if detected_restart:
|
||||
|
||||
def restart_engine(_dt):
|
||||
self.katrain.controls.set_status("", STATUS_INFO)
|
||||
self.katrain.log(f"Restarting Engine after {detected_restart} settings change")
|
||||
self.katrain.controls.set_status(i18n._("restarting engine"), STATUS_INFO)
|
||||
|
||||
old_engine = self.katrain.engine # type: KataGoEngine
|
||||
old_proc = old_engine.katago_process
|
||||
if old_proc:
|
||||
old_engine.shutdown(finish=False)
|
||||
new_engine = KataGoEngine(self.katrain, self.katrain.config("engine"))
|
||||
self.katrain.engine = new_engine
|
||||
self.katrain.game.engines = {"B": new_engine, "W": new_engine}
|
||||
self.katrain.game.analyze_all_nodes(
|
||||
analyze_fast=True
|
||||
) # old engine was possibly broken, so make sure we redo any failures
|
||||
self.katrain.update_state()
|
||||
self.katrain.restart_engine()
|
||||
|
||||
Clock.schedule_once(restart_engine, 0)
|
||||
|
||||
|
||||
+45
-11
@@ -1,9 +1,9 @@
|
||||
from kivy.clock import Clock
|
||||
from kivymd.app import MDApp
|
||||
from kivy.core.audio import SoundLoader
|
||||
from kivy.utils import platform
|
||||
|
||||
cached_sounds = {}
|
||||
_audio_available = False
|
||||
|
||||
# prefer ffpyplayer on linux, then others, avoid gst and avoid or ffpyplayer on windows
|
||||
ranking = [("ffplay", 98 if platform in ["win", "macosx"] else -2), ("sdl", -1), ("gst", 99), ("", 0)]
|
||||
@@ -13,22 +13,56 @@ except Exception as e:
|
||||
print("Exception sorting sound loaders: ", e) # private vars, so could break with versions etc
|
||||
|
||||
|
||||
def play_sound(file, volume=1, cache=True):
|
||||
def _play(sound):
|
||||
if sound:
|
||||
sound.play()
|
||||
sound.seek(0)
|
||||
def preload_sounds(sound_dir):
|
||||
"""Preload all sounds before the SDL2 window is created.
|
||||
|
||||
SoundLoader.load() deadlocks when no audio output device is available,
|
||||
so we test audio in a subprocess first.
|
||||
"""
|
||||
global _audio_available
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
audio_files = [
|
||||
os.path.join(sound_dir, fn) for fn in os.listdir(sound_dir)
|
||||
if fn.endswith((".wav", ".ogg", ".mp3"))
|
||||
]
|
||||
if not audio_files:
|
||||
return
|
||||
|
||||
# Test if audio subsystem works by loading one sound in a subprocess
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-c",
|
||||
f"from kivy.core.audio import SoundLoader; SoundLoader.load({audio_files[0]!r})"],
|
||||
timeout=3, capture_output=True,
|
||||
)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
print(f"Warning: Audio unavailable ({e}). Sounds disabled.", file=sys.stderr)
|
||||
return
|
||||
|
||||
# Audio works, preload all sounds
|
||||
for path in audio_files:
|
||||
cached_sounds[os.path.basename(path)] = SoundLoader.load(path)
|
||||
_audio_available = True
|
||||
|
||||
|
||||
def play_sound(file, volume=1, cache=True):
|
||||
if not _audio_available:
|
||||
return
|
||||
|
||||
# the following import causes the creation of an SDL2 window, and if there
|
||||
# is no sound device, it deadlocks. We delay the import until we know audio
|
||||
# is available
|
||||
from kivymd.app import MDApp
|
||||
app = MDApp.get_running_app()
|
||||
if app and app.gui and app.gui.config("timer/sound"):
|
||||
sound = cached_sounds.get(file)
|
||||
if sound is None:
|
||||
sound = SoundLoader.load(file)
|
||||
if cache:
|
||||
cached_sounds[file] = sound
|
||||
if sound is not None:
|
||||
sound.volume = volume
|
||||
Clock.schedule_once(lambda _dt: _play(sound), 0)
|
||||
sound.play()
|
||||
sound.seek(0)
|
||||
|
||||
|
||||
def stop_sound(file):
|
||||
|
||||
Binary file not shown.
@@ -872,7 +872,7 @@ msgstr "如果你还有任何困难,请参考疑难排解页面 {link}"
|
||||
|
||||
#. Link in tags should not be touched.
|
||||
msgid "change engine suggestion"
|
||||
msgstr "首先,在 {link} 中,尝试下载且更换为不同版本的KataGo执行档是否能解决问题。"
|
||||
msgstr "请先点击「重试」以重新启动引擎。如果没有帮助,请尝试在 {link} 中下载 KataGo 版本并改用其他可执行文件。"
|
||||
|
||||
#. as in "click here" to go to this page,
|
||||
msgid "link_here"
|
||||
@@ -998,3 +998,57 @@ msgstr ""
|
||||
#. TODO
|
||||
msgid "engine:humanlike_model"
|
||||
msgstr "Path to human-like model"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url"
|
||||
msgstr "KataGo WebSocket URL"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url:hint"
|
||||
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL is empty"
|
||||
msgstr "Remote KataGo URL is empty"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL must start with ws:// or wss://"
|
||||
msgstr "Remote KataGo URL must start with ws:// or wss://"
|
||||
|
||||
#. TODO
|
||||
msgid "Connecting to remote KataGo failed"
|
||||
msgstr "Connecting to remote KataGo at {url} failed: {error}"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo engine disconnected"
|
||||
msgstr "Remote KataGo engine disconnected: {error}"
|
||||
|
||||
#. Shown instead of "engine died popup opening message" for remote
|
||||
#. engines.
|
||||
msgid "remote engine disconnected popup opening message"
|
||||
msgstr ""
|
||||
"与远程 KataGo 引擎的连接已断开,且无法自动重新建立。\n"
|
||||
"\n"
|
||||
"错误为:[color=#CC2222]{error_message}[/color]\n"
|
||||
"\n"
|
||||
"错误代码为:[color=#CC2222]{code}[/color]。"
|
||||
|
||||
#. Button to reconnect/restart the engine from the recovery popup.
|
||||
msgid "engine recovery: retry"
|
||||
msgstr "重试"
|
||||
|
||||
#. Link in tags should not be touched. Shown for remote engines.
|
||||
msgid "remote engine check url suggestion"
|
||||
msgstr "如果远程引擎之前能正常工作,请在其恢复后点击「重试」以重新连接。否则,请在 {link} 中再次检查远程引擎的网址。"
|
||||
|
||||
msgid "engine:tab:local"
|
||||
msgstr "本地引擎"
|
||||
|
||||
msgid "engine:tab:remote"
|
||||
msgstr "远程引擎"
|
||||
|
||||
msgid "engine:tab:custom"
|
||||
msgstr "自定义命令"
|
||||
|
||||
msgid "engine:backend:note"
|
||||
msgstr "所选标签页决定使用的引擎。"
|
||||
Binary file not shown.
@@ -940,8 +940,9 @@ msgstr ""
|
||||
#. - Link in tags should not be touched.
|
||||
msgid "change engine suggestion"
|
||||
msgstr ""
|
||||
"Versuchen sie erstmal KataGo Versionen runterzuladen und ob eine andere "
|
||||
"ausführbare Datei in {link} hilft. "
|
||||
"Drücken Sie zuerst auf Erneut versuchen, um die Engine neu zu starten. Falls"
|
||||
" das nicht hilft, laden Sie KataGo-Versionen herunter und wechseln Sie in "
|
||||
"{link} zu einer anderen ausführbaren Datei."
|
||||
|
||||
#. - as in "click here" to go to this page,
|
||||
msgid "link_here"
|
||||
@@ -1067,3 +1068,60 @@ msgstr ""
|
||||
#. TODO
|
||||
msgid "engine:humanlike_model"
|
||||
msgstr "Path to human-like model"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url"
|
||||
msgstr "KataGo WebSocket URL"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url:hint"
|
||||
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL is empty"
|
||||
msgstr "Remote KataGo URL is empty"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL must start with ws:// or wss://"
|
||||
msgstr "Remote KataGo URL must start with ws:// or wss://"
|
||||
|
||||
#. TODO
|
||||
msgid "Connecting to remote KataGo failed"
|
||||
msgstr "Connecting to remote KataGo at {url} failed: {error}"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo engine disconnected"
|
||||
msgstr "Remote KataGo engine disconnected: {error}"
|
||||
|
||||
#. Shown instead of "engine died popup opening message" for remote
|
||||
#. engines.
|
||||
msgid "remote engine disconnected popup opening message"
|
||||
msgstr ""
|
||||
"Die Verbindung zur entfernten KataGo-Engine ging verloren und konnte nicht automatisch wiederhergestellt werden.\n"
|
||||
"\n"
|
||||
"Der Fehler war: [color=#CC2222]{error_message}[/color]\n"
|
||||
"\n"
|
||||
"Der Fehlercode war: [color=#CC2222]{code}[/color]."
|
||||
|
||||
#. Button to reconnect/restart the engine from the recovery popup.
|
||||
msgid "engine recovery: retry"
|
||||
msgstr "Erneut versuchen"
|
||||
|
||||
#. Link in tags should not be touched. Shown for remote engines.
|
||||
msgid "remote engine check url suggestion"
|
||||
msgstr ""
|
||||
"Wenn die entfernte Engine zuvor funktioniert hat, drücken Sie auf Erneut "
|
||||
"versuchen, um die Verbindung wiederherzustellen, sobald sie wieder verfügbar"
|
||||
" ist. Andernfalls überprüfen Sie die URL der entfernten Engine in {link} ."
|
||||
|
||||
msgid "engine:tab:local"
|
||||
msgstr "Lokale Engine"
|
||||
|
||||
msgid "engine:tab:remote"
|
||||
msgstr "Remote-Engine"
|
||||
|
||||
msgid "engine:tab:custom"
|
||||
msgstr "Eigener Befehl"
|
||||
|
||||
msgid "engine:backend:note"
|
||||
msgstr "Der ausgewählte Reiter bestimmt die genutzte Engine."
|
||||
Binary file not shown.
@@ -653,6 +653,24 @@ msgstr ""
|
||||
"Full command used to start the engine. All other model options will be "
|
||||
"ignored."
|
||||
|
||||
msgid "engine:remote_url"
|
||||
msgstr "KataGo WebSocket URL"
|
||||
|
||||
msgid "engine:remote_url:hint"
|
||||
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
|
||||
|
||||
msgid "Remote KataGo URL is empty"
|
||||
msgstr "Remote KataGo URL is empty"
|
||||
|
||||
msgid "Remote KataGo URL must start with ws:// or wss://"
|
||||
msgstr "Remote KataGo URL must start with ws:// or wss://"
|
||||
|
||||
msgid "Connecting to remote KataGo failed"
|
||||
msgstr "Connecting to remote KataGo at {url} failed: {error}"
|
||||
|
||||
msgid "Remote KataGo engine disconnected"
|
||||
msgstr "Remote KataGo engine disconnected: {error}"
|
||||
|
||||
msgid "engine:max_visits"
|
||||
msgstr "Maximum number of visits in analysis"
|
||||
|
||||
@@ -753,8 +771,28 @@ msgstr ""
|
||||
#. Link in tags should not be touched.
|
||||
msgid "change engine suggestion"
|
||||
msgstr ""
|
||||
"First, try downloading KataGo versions and try if changing to a different "
|
||||
"executable helps in {link} ."
|
||||
"First, press Retry to restart the engine. If that does not help, try "
|
||||
"downloading KataGo versions and changing to a different executable in {link}"
|
||||
" ."
|
||||
|
||||
#. Shown instead of "engine died popup opening message" for remote engines.
|
||||
msgid "remote engine disconnected popup opening message"
|
||||
msgstr ""
|
||||
"The connection to the remote KataGo engine was lost and could not be re-established automatically.\n"
|
||||
"\n"
|
||||
"The error was: [color=#CC2222]{error_message}[/color]\n"
|
||||
"\n"
|
||||
"The error code was: [color=#CC2222]{code}[/color]."
|
||||
|
||||
#. Link in tags should not be touched. Shown for remote engines.
|
||||
msgid "remote engine check url suggestion"
|
||||
msgstr ""
|
||||
"If the remote engine has worked before, press Retry to reconnect once it is "
|
||||
"back. Otherwise, double-check the remote engine URL in {link} ."
|
||||
|
||||
#. Button to reconnect/restart the engine from the recovery popup.
|
||||
msgid "engine recovery: retry"
|
||||
msgstr "Retry"
|
||||
|
||||
#. Link in tags should not be touched (uses link_here msgid)
|
||||
msgid "go to engine help page"
|
||||
@@ -1073,3 +1111,15 @@ msgstr "Distance of wall"
|
||||
#. tsumego: ko is good enough?
|
||||
msgid "tsumego:ko"
|
||||
msgstr "Ko allowed?"
|
||||
|
||||
msgid "engine:tab:local"
|
||||
msgstr "Local engine"
|
||||
|
||||
msgid "engine:tab:remote"
|
||||
msgstr "Remote engine"
|
||||
|
||||
msgid "engine:tab:custom"
|
||||
msgstr "Custom command"
|
||||
|
||||
msgid "engine:backend:note"
|
||||
msgstr "The selected tab determines the engine used."
|
||||
Binary file not shown.
@@ -752,18 +752,18 @@ msgstr "Cache analysis in SGF"
|
||||
msgid "menu:save-as"
|
||||
msgstr "Save Game As..."
|
||||
|
||||
#. TODO
|
||||
# sgf load button
|
||||
#. TODO
|
||||
msgid "Load File"
|
||||
msgstr "Load File"
|
||||
|
||||
#. TODO
|
||||
# sgf save popup
|
||||
#. TODO
|
||||
msgid "save sgf title"
|
||||
msgstr "Save SGF File"
|
||||
|
||||
#. TODO
|
||||
# sgf save as button
|
||||
#. TODO
|
||||
msgid "Save File"
|
||||
msgstr "Save File"
|
||||
|
||||
@@ -1029,3 +1029,39 @@ msgstr "Ko allowed?"
|
||||
#. TODO - Menu option for tsumego frame around position
|
||||
msgid "analysis:tsumegoframe"
|
||||
msgstr "Tsumego Frame"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url"
|
||||
msgstr "KataGo WebSocket URL"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url:hint"
|
||||
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL is empty"
|
||||
msgstr "Remote KataGo URL is empty"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL must start with ws:// or wss://"
|
||||
msgstr "Remote KataGo URL must start with ws:// or wss://"
|
||||
|
||||
#. TODO
|
||||
msgid "Connecting to remote KataGo failed"
|
||||
msgstr "Connecting to remote KataGo at {url} failed: {error}"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo engine disconnected"
|
||||
msgstr "Remote KataGo engine disconnected: {error}"
|
||||
|
||||
msgid "engine:tab:local"
|
||||
msgstr "Motor local"
|
||||
|
||||
msgid "engine:tab:remote"
|
||||
msgstr "Motor remoto"
|
||||
|
||||
msgid "engine:tab:custom"
|
||||
msgstr "Comando personalizado"
|
||||
|
||||
msgid "engine:backend:note"
|
||||
msgstr "La pestaña seleccionada determina el motor usado."
|
||||
Binary file not shown.
@@ -929,8 +929,9 @@ msgstr ""
|
||||
|
||||
msgid "change engine suggestion"
|
||||
msgstr ""
|
||||
"L'exécutable et le modèle KataGo utilisés doivent être compatibles. Vous "
|
||||
"pouvez en télécharger et/ou en sélectionner d'autres dans les {link}. "
|
||||
"Commencez par appuyer sur Réessayer pour redémarrer le moteur. Si cela ne "
|
||||
"suffit pas, essayez de télécharger des versions de KataGo et de changer "
|
||||
"d'exécutable dans {link} ."
|
||||
|
||||
msgid "link_here"
|
||||
msgstr "ce lien"
|
||||
@@ -1036,3 +1037,60 @@ msgstr ""
|
||||
#. TODO
|
||||
msgid "engine:humanlike_model"
|
||||
msgstr "Path to human-like model"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url"
|
||||
msgstr "KataGo WebSocket URL"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url:hint"
|
||||
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL is empty"
|
||||
msgstr "Remote KataGo URL is empty"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL must start with ws:// or wss://"
|
||||
msgstr "Remote KataGo URL must start with ws:// or wss://"
|
||||
|
||||
#. TODO
|
||||
msgid "Connecting to remote KataGo failed"
|
||||
msgstr "Connecting to remote KataGo at {url} failed: {error}"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo engine disconnected"
|
||||
msgstr "Remote KataGo engine disconnected: {error}"
|
||||
|
||||
#. Shown instead of "engine died popup opening message" for remote
|
||||
#. engines.
|
||||
msgid "remote engine disconnected popup opening message"
|
||||
msgstr ""
|
||||
"La connexion au moteur KataGo distant a été perdue et n'a pas pu être rétablie automatiquement.\n"
|
||||
"\n"
|
||||
"L'erreur était : [color=#CC2222]{error_message}[/color]\n"
|
||||
"\n"
|
||||
"Le code d'erreur était : [color=#CC2222]{code}[/color]."
|
||||
|
||||
#. Button to reconnect/restart the engine from the recovery popup.
|
||||
msgid "engine recovery: retry"
|
||||
msgstr "Réessayer"
|
||||
|
||||
#. Link in tags should not be touched. Shown for remote engines.
|
||||
msgid "remote engine check url suggestion"
|
||||
msgstr ""
|
||||
"Si le moteur distant a déjà fonctionné, appuyez sur Réessayer pour vous "
|
||||
"reconnecter une fois qu'il est de nouveau disponible. Sinon, vérifiez l'URL "
|
||||
"du moteur distant dans {link} ."
|
||||
|
||||
msgid "engine:tab:local"
|
||||
msgstr "Moteur local"
|
||||
|
||||
msgid "engine:tab:remote"
|
||||
msgstr "Moteur distant"
|
||||
|
||||
msgid "engine:tab:custom"
|
||||
msgstr "Commande personnalisée"
|
||||
|
||||
msgid "engine:backend:note"
|
||||
msgstr "L'onglet sélectionné détermine le moteur utilisé."
|
||||
Binary file not shown.
@@ -916,7 +916,9 @@ msgstr "それでもだめなら、{link}して最新のトラブルシューテ
|
||||
|
||||
#. Link in tags should not be touched.
|
||||
msgid "change engine suggestion"
|
||||
msgstr "まずは、{link}から別バージョンのKataGoをダウンロードしてうまくいくか試してください。"
|
||||
msgstr ""
|
||||
"まず「再試行」を押してエンジンを再起動してください。解決しない場合は、{link} で KataGo "
|
||||
"のバージョンをダウンロードし、別の実行ファイルに変更してみてください。"
|
||||
|
||||
#. as in "click here" to go to this page,
|
||||
msgid "link_here"
|
||||
@@ -1039,3 +1041,59 @@ msgstr ""
|
||||
#. TODO
|
||||
msgid "engine:humanlike_model"
|
||||
msgstr "Path to human-like model"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url"
|
||||
msgstr "KataGo WebSocket URL"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url:hint"
|
||||
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL is empty"
|
||||
msgstr "Remote KataGo URL is empty"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL must start with ws:// or wss://"
|
||||
msgstr "Remote KataGo URL must start with ws:// or wss://"
|
||||
|
||||
#. TODO
|
||||
msgid "Connecting to remote KataGo failed"
|
||||
msgstr "Connecting to remote KataGo at {url} failed: {error}"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo engine disconnected"
|
||||
msgstr "Remote KataGo engine disconnected: {error}"
|
||||
|
||||
#. Shown instead of "engine died popup opening message" for remote
|
||||
#. engines.
|
||||
msgid "remote engine disconnected popup opening message"
|
||||
msgstr ""
|
||||
"リモート KataGo エンジンへの接続が失われ、自動的に再接続できませんでした。\n"
|
||||
"\n"
|
||||
"エラー: [color=#CC2222]{error_message}[/color]\n"
|
||||
"\n"
|
||||
"エラーコード: [color=#CC2222]{code}[/color]。"
|
||||
|
||||
#. Button to reconnect/restart the engine from the recovery popup.
|
||||
msgid "engine recovery: retry"
|
||||
msgstr "再試行"
|
||||
|
||||
#. Link in tags should not be touched. Shown for remote engines.
|
||||
msgid "remote engine check url suggestion"
|
||||
msgstr ""
|
||||
"リモートエンジンが以前は動作していた場合は、復旧後に「再試行」を押して再接続してください。そうでない場合は、{link} でリモートエンジンの URL "
|
||||
"を再確認してください。"
|
||||
|
||||
msgid "engine:tab:local"
|
||||
msgstr "ローカルエンジン"
|
||||
|
||||
msgid "engine:tab:remote"
|
||||
msgstr "リモートエンジン"
|
||||
|
||||
msgid "engine:tab:custom"
|
||||
msgstr "カスタムコマンド"
|
||||
|
||||
msgid "engine:backend:note"
|
||||
msgstr "選択したタブが使用するエンジンを決定します。"
|
||||
Binary file not shown.
@@ -867,7 +867,9 @@ msgid "go to engine help page"
|
||||
msgstr "아직 문제를 겪고 있으시다면, {link}에서 최신 문제 해결법을 찾아보십시오."
|
||||
|
||||
msgid "change engine suggestion"
|
||||
msgstr "우선, KataGo 버전들을 내려받아 보시고, {link}에서 다른 실행 파일로 바꾸면 괜찮아지는지 확인해 보십시오."
|
||||
msgstr ""
|
||||
"먼저 다시 시도를 눌러 엔진을 다시 시작하세요. 그래도 해결되지 않으면 {link} 에서 KataGo 버전을 내려받아 다른 실행 파일로 "
|
||||
"변경해 보세요."
|
||||
|
||||
msgid "link_here"
|
||||
msgstr "여기"
|
||||
@@ -966,3 +968,59 @@ msgstr ""
|
||||
#. TODO
|
||||
msgid "engine:humanlike_model"
|
||||
msgstr "Path to human-like model"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url"
|
||||
msgstr "KataGo WebSocket URL"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url:hint"
|
||||
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL is empty"
|
||||
msgstr "Remote KataGo URL is empty"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL must start with ws:// or wss://"
|
||||
msgstr "Remote KataGo URL must start with ws:// or wss://"
|
||||
|
||||
#. TODO
|
||||
msgid "Connecting to remote KataGo failed"
|
||||
msgstr "Connecting to remote KataGo at {url} failed: {error}"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo engine disconnected"
|
||||
msgstr "Remote KataGo engine disconnected: {error}"
|
||||
|
||||
#. Shown instead of "engine died popup opening message" for remote
|
||||
#. engines.
|
||||
msgid "remote engine disconnected popup opening message"
|
||||
msgstr ""
|
||||
"원격 KataGo 엔진과의 연결이 끊겼으며 자동으로 다시 연결하지 못했습니다.\n"
|
||||
"\n"
|
||||
"오류: [color=#CC2222]{error_message}[/color]\n"
|
||||
"\n"
|
||||
"오류 코드: [color=#CC2222]{code}[/color]."
|
||||
|
||||
#. Button to reconnect/restart the engine from the recovery popup.
|
||||
msgid "engine recovery: retry"
|
||||
msgstr "다시 시도"
|
||||
|
||||
#. Link in tags should not be touched. Shown for remote engines.
|
||||
msgid "remote engine check url suggestion"
|
||||
msgstr ""
|
||||
"원격 엔진이 이전에 작동한 적이 있다면, 다시 사용 가능해졌을 때 다시 시도를 눌러 재연결하세요. 그렇지 않으면 {link} 에서 원격 "
|
||||
"엔진 URL을 다시 확인하세요."
|
||||
|
||||
msgid "engine:tab:local"
|
||||
msgstr "로컬 엔진"
|
||||
|
||||
msgid "engine:tab:remote"
|
||||
msgstr "원격 엔진"
|
||||
|
||||
msgid "engine:tab:custom"
|
||||
msgstr "사용자 지정 명령"
|
||||
|
||||
msgid "engine:backend:note"
|
||||
msgstr "선택한 탭이 사용할 엔진을 결정합니다."
|
||||
Binary file not shown.
@@ -1002,3 +1002,63 @@ msgstr ""
|
||||
#. TODO
|
||||
msgid "engine:humanlike_model"
|
||||
msgstr "Path to human-like model"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url"
|
||||
msgstr "KataGo WebSocket URL"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url:hint"
|
||||
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL is empty"
|
||||
msgstr "Remote KataGo URL is empty"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL must start with ws:// or wss://"
|
||||
msgstr "Remote KataGo URL must start with ws:// or wss://"
|
||||
|
||||
#. TODO
|
||||
msgid "Connecting to remote KataGo failed"
|
||||
msgstr "Connecting to remote KataGo at {url} failed: {error}"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo engine disconnected"
|
||||
msgstr "Remote KataGo engine disconnected: {error}"
|
||||
|
||||
#. TODO - Shown instead of "engine died popup opening message" for remote
|
||||
#. engines.
|
||||
msgid "remote engine disconnected popup opening message"
|
||||
msgstr ""
|
||||
"The connection to the remote KataGo engine was lost and could not be re-established automatically.\n"
|
||||
"\n"
|
||||
"The error was: [color=#CC2222]{error_message}[/color]\n"
|
||||
"\n"
|
||||
"The error code was: [color=#CC2222]{code}[/color]."
|
||||
|
||||
#. TODO - Button to reconnect/restart the engine from the recovery popup.
|
||||
msgid "engine recovery: retry"
|
||||
msgstr "Retry"
|
||||
|
||||
#. TODO - Link in tags should not be touched. Shown for remote engines.
|
||||
msgid "remote engine check url suggestion"
|
||||
msgstr ""
|
||||
"If the remote engine has worked before, press Retry to reconnect once it is "
|
||||
"back. Otherwise, double-check the remote engine URL in {link} ."
|
||||
|
||||
#. TODO
|
||||
msgid "engine:tab:custom"
|
||||
msgstr "Custom command"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:backend:note"
|
||||
msgstr "The selected tab determines the engine used."
|
||||
|
||||
#. TODO
|
||||
msgid "engine:tab:remote"
|
||||
msgstr "Remote engine"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:tab:local"
|
||||
msgstr "Local engine"
|
||||
Binary file not shown.
@@ -733,8 +733,9 @@ msgstr ""
|
||||
#. Link in tags should not be touched.
|
||||
msgid "change engine suggestion"
|
||||
msgstr ""
|
||||
"İlk önce, KataGo sürümlerini indirmeyi deneyin ve {link} bağlantısındaki "
|
||||
"farklı yürütme dosyalarını kullanmayı deneyin."
|
||||
"Önce motoru yeniden başlatmak için Yeniden Dene'ye basın. Bu işe yaramazsa, "
|
||||
"{link} içinde KataGo sürümlerini indirip farklı bir yürütülebilir dosyaya "
|
||||
"geçmeyi deneyin."
|
||||
|
||||
#. Link in tags should not be touched (uses link_here msgid)
|
||||
msgid "go to engine help page"
|
||||
@@ -1102,3 +1103,60 @@ msgstr ""
|
||||
#. TODO
|
||||
msgid "engine:humanlike_model"
|
||||
msgstr "Path to human-like model"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url"
|
||||
msgstr "KataGo WebSocket URL"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url:hint"
|
||||
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL is empty"
|
||||
msgstr "Remote KataGo URL is empty"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL must start with ws:// or wss://"
|
||||
msgstr "Remote KataGo URL must start with ws:// or wss://"
|
||||
|
||||
#. TODO
|
||||
msgid "Connecting to remote KataGo failed"
|
||||
msgstr "Connecting to remote KataGo at {url} failed: {error}"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo engine disconnected"
|
||||
msgstr "Remote KataGo engine disconnected: {error}"
|
||||
|
||||
#. Shown instead of "engine died popup opening message" for remote
|
||||
#. engines.
|
||||
msgid "remote engine disconnected popup opening message"
|
||||
msgstr ""
|
||||
"Uzak KataGo motoruyla bağlantı kesildi ve otomatik olarak yeniden kurulamadı.\n"
|
||||
"\n"
|
||||
"Hata: [color=#CC2222]{error_message}[/color]\n"
|
||||
"\n"
|
||||
"Hata kodu: [color=#CC2222]{code}[/color]."
|
||||
|
||||
#. Button to reconnect/restart the engine from the recovery popup.
|
||||
msgid "engine recovery: retry"
|
||||
msgstr "Yeniden Dene"
|
||||
|
||||
#. Link in tags should not be touched. Shown for remote engines.
|
||||
msgid "remote engine check url suggestion"
|
||||
msgstr ""
|
||||
"Uzak motor daha önce çalıştıysa, tekrar erişilebilir olduğunda yeniden "
|
||||
"bağlanmak için Yeniden Dene'ye basın. Aksi takdirde, {link} içindeki uzak "
|
||||
"motor URL'sini tekrar kontrol edin."
|
||||
|
||||
msgid "engine:tab:local"
|
||||
msgstr "Yerel motor"
|
||||
|
||||
msgid "engine:tab:remote"
|
||||
msgstr "Uzak motor"
|
||||
|
||||
msgid "engine:tab:custom"
|
||||
msgstr "Özel komut"
|
||||
|
||||
msgid "engine:backend:note"
|
||||
msgstr "Seçilen sekme, kullanılacak motoru belirler."
|
||||
Binary file not shown.
@@ -898,7 +898,7 @@ msgstr "如果你還有任何困難,請參考疑難排解頁面 {link}"
|
||||
|
||||
#. Link in tags should not be touched.
|
||||
msgid "change engine suggestion"
|
||||
msgstr "首先,在 {link} 中,嘗試下載且更換為不同版本的KataGo執行檔是否能解決問題。"
|
||||
msgstr "請先按「重試」以重新啟動引擎。如果沒有幫助,請嘗試在 {link} 中下載 KataGo 版本並改用其他執行檔。"
|
||||
|
||||
#. as in "click here" to go to this page,
|
||||
msgid "link_here"
|
||||
@@ -1021,3 +1021,57 @@ msgstr ""
|
||||
#. TODO
|
||||
msgid "engine:humanlike_model"
|
||||
msgstr "Path to human-like model"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url"
|
||||
msgstr "KataGo WebSocket URL"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url:hint"
|
||||
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL is empty"
|
||||
msgstr "Remote KataGo URL is empty"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL must start with ws:// or wss://"
|
||||
msgstr "Remote KataGo URL must start with ws:// or wss://"
|
||||
|
||||
#. TODO
|
||||
msgid "Connecting to remote KataGo failed"
|
||||
msgstr "Connecting to remote KataGo at {url} failed: {error}"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo engine disconnected"
|
||||
msgstr "Remote KataGo engine disconnected: {error}"
|
||||
|
||||
#. Shown instead of "engine died popup opening message" for remote
|
||||
#. engines.
|
||||
msgid "remote engine disconnected popup opening message"
|
||||
msgstr ""
|
||||
"與遠端 KataGo 引擎的連線中斷,且無法自動重新建立。\n"
|
||||
"\n"
|
||||
"錯誤為:[color=#CC2222]{error_message}[/color]\n"
|
||||
"\n"
|
||||
"錯誤代碼為:[color=#CC2222]{code}[/color]。"
|
||||
|
||||
#. Button to reconnect/restart the engine from the recovery popup.
|
||||
msgid "engine recovery: retry"
|
||||
msgstr "重試"
|
||||
|
||||
#. Link in tags should not be touched. Shown for remote engines.
|
||||
msgid "remote engine check url suggestion"
|
||||
msgstr "如果遠端引擎先前曾正常運作,請在它恢復後按「重試」以重新連線。否則,請在 {link} 中再次確認遠端引擎的網址。"
|
||||
|
||||
msgid "engine:tab:local"
|
||||
msgstr "本機引擎"
|
||||
|
||||
msgid "engine:tab:remote"
|
||||
msgstr "遠端引擎"
|
||||
|
||||
msgid "engine:tab:custom"
|
||||
msgstr "自訂命令"
|
||||
|
||||
msgid "engine:backend:note"
|
||||
msgstr "所選分頁決定使用的引擎。"
|
||||
Binary file not shown.
@@ -751,8 +751,9 @@ msgstr ""
|
||||
#. Link in tags should not be touched.
|
||||
msgid "change engine suggestion"
|
||||
msgstr ""
|
||||
"Спочатку спробуйте завантажити інші версії KataGo і перевірте, чи допоможе "
|
||||
"зміна виконуваного файлу в {link}."
|
||||
"Спочатку натисніть «Повторити», щоб перезапустити рушій. Якщо це не "
|
||||
"допомогло, спробуйте завантажити версії KataGo та змінити виконуваний файл у"
|
||||
" {link} ."
|
||||
|
||||
#. Link in tags should not be touched (uses link_here msgid)
|
||||
msgid "go to engine help page"
|
||||
@@ -1086,3 +1087,60 @@ msgstr ""
|
||||
#. TODO
|
||||
msgid "engine:humanlike_model"
|
||||
msgstr "Path to human-like model"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url"
|
||||
msgstr "KataGo WebSocket URL"
|
||||
|
||||
#. TODO
|
||||
msgid "engine:remote_url:hint"
|
||||
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL is empty"
|
||||
msgstr "Remote KataGo URL is empty"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo URL must start with ws:// or wss://"
|
||||
msgstr "Remote KataGo URL must start with ws:// or wss://"
|
||||
|
||||
#. TODO
|
||||
msgid "Connecting to remote KataGo failed"
|
||||
msgstr "Connecting to remote KataGo at {url} failed: {error}"
|
||||
|
||||
#. TODO
|
||||
msgid "Remote KataGo engine disconnected"
|
||||
msgstr "Remote KataGo engine disconnected: {error}"
|
||||
|
||||
#. Shown instead of "engine died popup opening message" for remote
|
||||
#. engines.
|
||||
msgid "remote engine disconnected popup opening message"
|
||||
msgstr ""
|
||||
"З'єднання з віддаленим рушієм KataGo втрачено й не вдалося відновити автоматично.\n"
|
||||
"\n"
|
||||
"Помилка: [color=#CC2222]{error_message}[/color]\n"
|
||||
"\n"
|
||||
"Код помилки: [color=#CC2222]{code}[/color]."
|
||||
|
||||
#. Button to reconnect/restart the engine from the recovery popup.
|
||||
msgid "engine recovery: retry"
|
||||
msgstr "Повторити"
|
||||
|
||||
#. Link in tags should not be touched. Shown for remote engines.
|
||||
msgid "remote engine check url suggestion"
|
||||
msgstr ""
|
||||
"Якщо віддалений рушій раніше працював, натисніть «Повторити», щоб "
|
||||
"під'єднатися знову, коли він стане доступним. Інакше перевірте URL-адресу "
|
||||
"віддаленого рушія в {link} ."
|
||||
|
||||
msgid "engine:tab:local"
|
||||
msgstr "Локальний рушій"
|
||||
|
||||
msgid "engine:tab:remote"
|
||||
msgstr "Віддалений рушій"
|
||||
|
||||
msgid "engine:tab:custom"
|
||||
msgstr "Власна команда"
|
||||
|
||||
msgid "engine:backend:note"
|
||||
msgstr "Вибрана вкладка визначає використовуваний рушій."
|
||||
+201
-105
@@ -3,9 +3,11 @@
|
||||
#:import expanduser os.path.expanduser
|
||||
#:import abspath os.path.abspath
|
||||
#:import kivy_version kivy.__version__
|
||||
#:import NoTransition kivy.uix.screenmanager.NoTransition
|
||||
|
||||
#:import Theme katrain.gui.theme.Theme
|
||||
#:import LIGHT_GREY katrain.gui.theme.LIGHT_GREY
|
||||
#:import ORANGE katrain.gui.theme.ORANGE
|
||||
|
||||
#:set CP_SPACING Theme.CP_SPACING
|
||||
#:set CP_SMALL_SPACING Theme.CP_SMALL_SPACING
|
||||
@@ -92,131 +94,216 @@
|
||||
katago_files: katago_files
|
||||
download_progress_box: download_progress_box
|
||||
katago_download_progress_box: katago_download_progress_box
|
||||
engine_sm: engine_sm
|
||||
local_tab_button: local_tab_button
|
||||
remote_tab_button: remote_tab_button
|
||||
custom_tab_button: custom_tab_button
|
||||
|
||||
# Top Section - KataGo Controls
|
||||
# Top Section - Engine source header
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(50)
|
||||
spacing: dp(20)
|
||||
padding: dp(10)
|
||||
height: dp(36)
|
||||
padding: dp(10), 0
|
||||
|
||||
DescriptionLabel:
|
||||
text: i18n._('katago settings')
|
||||
font_size: sp(Theme.DESC_FONT_SIZE) * 1.5
|
||||
size_hint_x: 0.4
|
||||
halign: 'left'
|
||||
|
||||
AutoSizedRoundedRectangleButton:
|
||||
text: i18n._("download katago button")
|
||||
on_press: root.download_katas()
|
||||
size_hint: (0.3, 0.8)
|
||||
|
||||
AutoSizedRoundedRectangleButton:
|
||||
text: i18n._("download models button")
|
||||
on_press: root.download_models()
|
||||
size_hint: (0.3, 0.8)
|
||||
|
||||
# Main Content Area
|
||||
# Engine backend tabs, with the "tab is authoritative" note to their right
|
||||
MDBoxLayout:
|
||||
orientation: 'vertical'
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(6)
|
||||
padding: dp(8), 0
|
||||
|
||||
SizedRectangleToggleButton:
|
||||
id: local_tab_button
|
||||
size_hint: 0.22, 1
|
||||
group: 'engine_backend'
|
||||
allow_no_selection: False
|
||||
text: i18n._('engine:tab:local')
|
||||
on_release: engine_sm.current = 'local'
|
||||
|
||||
SizedRectangleToggleButton:
|
||||
id: remote_tab_button
|
||||
size_hint: 0.22, 1
|
||||
group: 'engine_backend'
|
||||
allow_no_selection: False
|
||||
text: i18n._('engine:tab:remote')
|
||||
on_release: engine_sm.current = 'remote'
|
||||
|
||||
SizedRectangleToggleButton:
|
||||
id: custom_tab_button
|
||||
size_hint: 0.22, 1
|
||||
group: 'engine_backend'
|
||||
allow_no_selection: False
|
||||
text: i18n._('engine:tab:custom')
|
||||
on_release: engine_sm.current = 'custom'
|
||||
|
||||
DescriptionLabel:
|
||||
size_hint_x: 0.4
|
||||
text: i18n._('engine:backend:note')
|
||||
color: ORANGE
|
||||
halign: 'left'
|
||||
valign: 'center'
|
||||
padding: dp(10), 0
|
||||
|
||||
# Engine backend content - one screen per tab
|
||||
ScreenManager:
|
||||
id: engine_sm
|
||||
size_hint: (1, 6)
|
||||
spacing: dp(15)
|
||||
padding: dp(10)
|
||||
transition: NoTransition()
|
||||
|
||||
# Model Section
|
||||
ConfigSectionGridLayout:
|
||||
cols: 2
|
||||
DescriptionLabel:
|
||||
text: i18n._("engine:model")
|
||||
size_hint: (0.33, 1)
|
||||
Screen:
|
||||
name: 'local'
|
||||
MDBoxLayout:
|
||||
orientation: 'vertical'
|
||||
spacing: dp(12)
|
||||
padding: dp(10)
|
||||
|
||||
AnchorLayout:
|
||||
LabelledPathInput:
|
||||
id: model_path
|
||||
input_property: "engine/model"
|
||||
on_text: root.check_models()
|
||||
# KataGo executable - download button sits under its label
|
||||
ConfigSectionGridLayout:
|
||||
cols: 2
|
||||
MDBoxLayout:
|
||||
orientation: 'vertical'
|
||||
size_hint_x: 0.33
|
||||
spacing: dp(6)
|
||||
DescriptionLabel:
|
||||
text: i18n._("engine:katago")
|
||||
AnchorLayout:
|
||||
size_hint_y: None
|
||||
height: dp(34)
|
||||
AutoSizedRoundedRectangleButton:
|
||||
text: i18n._("download katago button")
|
||||
on_press: root.download_katas()
|
||||
size_hint: None, None
|
||||
height: dp(32)
|
||||
MDBoxLayout:
|
||||
orientation: 'vertical'
|
||||
AnchorLayout:
|
||||
LabelledPathInput:
|
||||
id: katago_path
|
||||
input_property: "engine/katago"
|
||||
hint_text: i18n._("engine:katago:hint")
|
||||
on_text: root.check_katas()
|
||||
AnchorLayout:
|
||||
KeyValueSpinner:
|
||||
id: katago_files
|
||||
text: ''
|
||||
text_autoupdate: True
|
||||
size_hint_y: 0.7
|
||||
on_select: if self.selected_index != 0: katago_path.text = self.value_keys[self.selected_index]
|
||||
|
||||
Label:
|
||||
size_hint: (0.33, 1)
|
||||
# Model - download button sits under its label
|
||||
ConfigSectionGridLayout:
|
||||
cols: 2
|
||||
MDBoxLayout:
|
||||
orientation: 'vertical'
|
||||
size_hint_x: 0.33
|
||||
spacing: dp(6)
|
||||
DescriptionLabel:
|
||||
text: i18n._("engine:model")
|
||||
AnchorLayout:
|
||||
size_hint_y: None
|
||||
height: dp(34)
|
||||
AutoSizedRoundedRectangleButton:
|
||||
text: i18n._("download models button")
|
||||
on_press: root.download_models()
|
||||
size_hint: None, None
|
||||
height: dp(32)
|
||||
MDBoxLayout:
|
||||
orientation: 'vertical'
|
||||
AnchorLayout:
|
||||
LabelledPathInput:
|
||||
id: model_path
|
||||
input_property: "engine/model"
|
||||
on_text: root.check_models()
|
||||
AnchorLayout:
|
||||
KeyValueSpinner:
|
||||
id: model_files
|
||||
text: ''
|
||||
text_autoupdate: True
|
||||
size_hint_y: 0.7
|
||||
on_select: if self.selected_index != 0: model_path.text = self.value_keys[self.selected_index]
|
||||
|
||||
AnchorLayout:
|
||||
KeyValueSpinner:
|
||||
id: model_files
|
||||
text: ''
|
||||
text_autoupdate: True
|
||||
size_hint_y: 0.7
|
||||
on_select: if self.selected_index != 0: model_path.text = self.value_keys[self.selected_index]
|
||||
# Human-like model
|
||||
ConfigSectionGridLayout:
|
||||
cols: 2
|
||||
DescriptionLabel:
|
||||
text: i18n._("engine:humanlike_model")
|
||||
size_hint_x: 0.33
|
||||
MDBoxLayout:
|
||||
orientation: 'vertical'
|
||||
AnchorLayout:
|
||||
LabelledPathInput:
|
||||
id: humanlike_model_path
|
||||
input_property: "engine/humanlike_model"
|
||||
on_text: root.check_models()
|
||||
AnchorLayout:
|
||||
KeyValueSpinner:
|
||||
id: humanlike_model_files
|
||||
text: ''
|
||||
text_autoupdate: True
|
||||
size_hint_y: 0.7
|
||||
on_select: if self.selected_index != 0: humanlike_model_path.text = self.value_keys[self.selected_index]
|
||||
|
||||
# Humanlike Model Section
|
||||
ConfigSectionGridLayout:
|
||||
cols: 2
|
||||
DescriptionLabel:
|
||||
text: i18n._("engine:humanlike_model")
|
||||
size_hint: (0.33, 1)
|
||||
# KataGo config file
|
||||
ConfigSectionGridLayout:
|
||||
cols: 2
|
||||
DescriptionLabel:
|
||||
text: i18n._("engine:config")
|
||||
size_hint_x: 0.33
|
||||
AnchorLayout:
|
||||
LabelledPathInput:
|
||||
input_property: "engine/config"
|
||||
|
||||
AnchorLayout:
|
||||
LabelledPathInput:
|
||||
id: humanlike_model_path
|
||||
input_property: "engine/humanlike_model"
|
||||
on_text: root.check_models()
|
||||
Screen:
|
||||
name: 'remote'
|
||||
MDBoxLayout:
|
||||
orientation: 'vertical'
|
||||
spacing: dp(12)
|
||||
padding: dp(10)
|
||||
ConfigSectionGridLayout:
|
||||
cols: 2
|
||||
size_hint_y: None
|
||||
height: dp(70)
|
||||
DescriptionLabel:
|
||||
text: i18n._("engine:remote_url")
|
||||
size_hint_x: 0.33
|
||||
AnchorLayout:
|
||||
LabelledPathInput:
|
||||
check_path: False
|
||||
input_property: "engine/remote_url"
|
||||
hint_text: i18n._("engine:remote_url:hint")
|
||||
Widget:
|
||||
|
||||
Label:
|
||||
size_hint: (0.33, 1)
|
||||
|
||||
AnchorLayout:
|
||||
KeyValueSpinner:
|
||||
id: humanlike_model_files
|
||||
text: ''
|
||||
text_autoupdate: True
|
||||
size_hint_y: 0.7
|
||||
on_select: if self.selected_index != 0: humanlike_model_path.text = self.value_keys[self.selected_index]
|
||||
|
||||
# KataGo Engine Section
|
||||
ConfigSectionGridLayout:
|
||||
cols: 2
|
||||
DescriptionLabel:
|
||||
text: i18n._("engine:katago")
|
||||
size_hint: (0.33, 1)
|
||||
|
||||
AnchorLayout:
|
||||
LabelledPathInput:
|
||||
id: katago_path
|
||||
input_property: "engine/katago"
|
||||
hint_text: i18n._("engine:katago:hint")
|
||||
on_text: root.check_katas()
|
||||
|
||||
Label:
|
||||
size_hint: (0.33, 1)
|
||||
|
||||
AnchorLayout:
|
||||
KeyValueSpinner:
|
||||
id: katago_files
|
||||
text: ''
|
||||
text_autoupdate: True
|
||||
size_hint_y: 0.7
|
||||
on_select: if self.selected_index != 0: katago_path.text = self.value_keys[self.selected_index]
|
||||
|
||||
ConfigSectionGridLayout:
|
||||
rows: 2
|
||||
cols: 2
|
||||
DescriptionLabel:
|
||||
text: i18n._("engine:config")
|
||||
size_hint: 0.33, 1
|
||||
AnchorLayout:
|
||||
LabelledPathInput:
|
||||
input_property: "engine/config"
|
||||
DescriptionLabel:
|
||||
text: i18n._("engine:altcommand")
|
||||
size_hint: 0.33, 1
|
||||
AnchorLayout:
|
||||
LabelledPathInput:
|
||||
check_path: False
|
||||
input_property: "engine/altcommand"
|
||||
hint_text: i18n._("engine:altcommand:hint")
|
||||
Screen:
|
||||
name: 'custom'
|
||||
MDBoxLayout:
|
||||
orientation: 'vertical'
|
||||
spacing: dp(12)
|
||||
padding: dp(10)
|
||||
ConfigSectionGridLayout:
|
||||
cols: 2
|
||||
size_hint_y: None
|
||||
height: dp(70)
|
||||
DescriptionLabel:
|
||||
text: i18n._("engine:altcommand")
|
||||
size_hint_x: 0.33
|
||||
AnchorLayout:
|
||||
LabelledPathInput:
|
||||
check_path: False
|
||||
input_property: "engine/altcommand"
|
||||
hint_text: i18n._("engine:altcommand:hint")
|
||||
Widget:
|
||||
|
||||
# Settings Headers
|
||||
MDBoxLayout:
|
||||
size_hint_y: 0.5
|
||||
size_hint_y: None
|
||||
height: dp(34)
|
||||
orientation: 'horizontal'
|
||||
padding: dp(10)
|
||||
spacing: dp(20)
|
||||
@@ -874,14 +961,23 @@
|
||||
on_press: root.update_config(True)
|
||||
|
||||
<EngineRecoveryPopup>:
|
||||
orientation: 'vertical'
|
||||
Label:
|
||||
size_hint: 1, 0.85
|
||||
padding: CP_PADDING, CP_PADDING*3
|
||||
text: i18n._("engine died popup opening message").format(code=root.code,error_message=root.error_message) + "\n\n" + i18n._("change engine suggestion").format(link="[color=#CCCC11][u][ref=engine_settings]"+i18n._('menu:settings')+"[/ref][/u][/color]") + "\n\n" + i18n._("go to engine help page").format(link="[color=#CCCC11][u][ref=engine_help]"+i18n._('link_here')+"[/ref][/u][/color]")
|
||||
text: root.recovery_message
|
||||
text_size: self.size
|
||||
font_size: sp(Theme.DESC_FONT_SIZE)
|
||||
markup: True
|
||||
valign: 'top'
|
||||
on_ref_press: app.webbrowser('engine:help') if args[1]=='engine_help' else app.gui('config-popup')
|
||||
AnchorLayout:
|
||||
size_hint: 1, 0.15
|
||||
AutoSizedRoundedRectangleButton:
|
||||
padding_x: 15
|
||||
size_hint: None, 0.66
|
||||
text: i18n._("engine recovery: retry")
|
||||
on_release: root.retry()
|
||||
|
||||
|
||||
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "KaTrain"
|
||||
version = "1.17.1"
|
||||
version = "1.18.0"
|
||||
description = "Go/Baduk/Weiqi playing and teaching app with a variety of AIs"
|
||||
authors = [{ name = "Sander Land" }]
|
||||
requires-python = ">=3.9,<3.14"
|
||||
@@ -22,6 +22,8 @@ dependencies = [
|
||||
"urllib3>=2.2.2",
|
||||
"kivy>=2.3.1",
|
||||
"kivymd==0.104.1",
|
||||
"websocket-client>=1.9.0",
|
||||
"certifi>=2026.2.25",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Tests for the remote (WebSocket) KataGo engine's reconnect logic.
|
||||
|
||||
These use an in-process fake WebSocket so no network/server is needed:
|
||||
`create_connection` is monkeypatched to hand out controllable sockets
|
||||
whose recv()/send()/close() we drive from the test thread.
|
||||
"""
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from websocket import WebSocketException
|
||||
|
||||
from katrain.core import remote_engine
|
||||
from katrain.core.remote_engine import RemoteKataGoEngine
|
||||
|
||||
|
||||
class FakeWS:
|
||||
"""Minimal stand-in for a websocket-client connection."""
|
||||
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
self._recv = queue.Queue()
|
||||
self.closed = False
|
||||
|
||||
def recv_data(self, control_frame=True):
|
||||
item = self._recv.get() # blocks like a real recv
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return item
|
||||
|
||||
def send(self, payload):
|
||||
if self.closed:
|
||||
raise WebSocketException("send on closed socket")
|
||||
self.sent.append(payload)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
self._recv.put(WebSocketException("closed"))
|
||||
|
||||
def drop(self):
|
||||
"""Simulate the server/network dropping the connection."""
|
||||
self._recv.put(WebSocketException("connection lost"))
|
||||
|
||||
|
||||
class FakeControls:
|
||||
def set_status(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class FakeKatrain:
|
||||
"""Records calls (e.g. the engine_recovery_popup trigger)."""
|
||||
|
||||
def __init__(self):
|
||||
self.controls = FakeControls()
|
||||
self.calls = []
|
||||
self.update_state = lambda *a, **k: None
|
||||
|
||||
def log(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
self.calls.append(args)
|
||||
|
||||
|
||||
def wait_until(pred, timeout=5.0, interval=0.02):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end and not pred():
|
||||
time.sleep(interval)
|
||||
return pred()
|
||||
|
||||
|
||||
def popup_codes(katrain):
|
||||
return [args[2] for args in katrain.calls if args and args[0] == "engine_recovery_popup"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fast_backoff(monkeypatch):
|
||||
monkeypatch.setattr(RemoteKataGoEngine, "RECONNECT_BACKOFF_S", 0.01)
|
||||
monkeypatch.setattr(RemoteKataGoEngine, "RECONNECT_MAX_BACKOFF_S", 0.02)
|
||||
|
||||
|
||||
def test_reconnect_resends_outstanding_queries(monkeypatch, fast_backoff):
|
||||
created = []
|
||||
|
||||
def factory(*args, **kwargs):
|
||||
ws = FakeWS()
|
||||
created.append(ws)
|
||||
return ws
|
||||
|
||||
monkeypatch.setattr(remote_engine, "create_connection", factory)
|
||||
|
||||
katrain = FakeKatrain()
|
||||
engine = RemoteKataGoEngine(katrain, {"remote_url": "ws://test", "allow_recovery": True})
|
||||
try:
|
||||
assert wait_until(lambda: len(created) == 1)
|
||||
ws1 = created[0]
|
||||
|
||||
engine.send_query({"foo": "bar"}, lambda *a: None, None)
|
||||
assert wait_until(lambda: len(ws1.sent) == 1)
|
||||
assert wait_until(lambda: len(engine.queries) == 1)
|
||||
|
||||
# Connection drops while a query is outstanding.
|
||||
ws1.drop()
|
||||
|
||||
# A new connection is established transparently...
|
||||
assert wait_until(lambda: len(created) == 2)
|
||||
ws2 = created[1]
|
||||
assert wait_until(lambda: engine.ws is ws2)
|
||||
|
||||
# ...and the in-flight query is re-sent on it.
|
||||
assert wait_until(lambda: len(ws2.sent) >= 1)
|
||||
|
||||
# No local-engine recovery popup was shown.
|
||||
assert popup_codes(katrain) == []
|
||||
finally:
|
||||
engine.shutdown()
|
||||
|
||||
|
||||
def test_reconnect_failure_opens_recovery_popup(monkeypatch, fast_backoff):
|
||||
monkeypatch.setattr(RemoteKataGoEngine, "RECONNECT_ATTEMPTS", 2)
|
||||
first = FakeWS()
|
||||
n = {"count": 0}
|
||||
|
||||
def factory(*args, **kwargs):
|
||||
n["count"] += 1
|
||||
if n["count"] == 1:
|
||||
return first
|
||||
raise WebSocketException("connection refused")
|
||||
|
||||
monkeypatch.setattr(remote_engine, "create_connection", factory)
|
||||
|
||||
katrain = FakeKatrain()
|
||||
engine = RemoteKataGoEngine(katrain, {"remote_url": "ws://test", "allow_recovery": True})
|
||||
try:
|
||||
assert wait_until(lambda: engine.ws is first)
|
||||
|
||||
first.drop()
|
||||
|
||||
# All reconnect attempts fail -> fall back to the recovery popup,
|
||||
# tagged as a remote disconnect rather than a local crash.
|
||||
assert wait_until(lambda: popup_codes(katrain) == ["REMOTE-DISCONNECTED"])
|
||||
|
||||
# The popup is told this is a remote engine so it shows remote-
|
||||
# specific advice (check URL) instead of the local executable hints.
|
||||
popup_call = next(args for args in katrain.calls if args and args[0] == "engine_recovery_popup")
|
||||
assert popup_call[3] == "remote"
|
||||
finally:
|
||||
engine.shutdown()
|
||||
|
||||
|
||||
def test_check_alive_polling_during_reconnect_does_not_suppress_popup(monkeypatch, fast_backoff):
|
||||
# Reproduces the ai.py move loops, which spin on check_alive(exception_if_dead=True)
|
||||
# every ~10ms. While reconnecting the socket is briefly None; those polls must not
|
||||
# latch the engine "dead" and steal the recovery popup that fires on real failure.
|
||||
monkeypatch.setattr(RemoteKataGoEngine, "RECONNECT_ATTEMPTS", 4)
|
||||
first = FakeWS()
|
||||
n = {"count": 0}
|
||||
|
||||
def factory(*args, **kwargs):
|
||||
n["count"] += 1
|
||||
if n["count"] == 1:
|
||||
return first
|
||||
raise WebSocketException("connection refused")
|
||||
|
||||
monkeypatch.setattr(remote_engine, "create_connection", factory)
|
||||
|
||||
katrain = FakeKatrain()
|
||||
engine = RemoteKataGoEngine(katrain, {"remote_url": "ws://test", "allow_recovery": True})
|
||||
|
||||
stop = threading.Event()
|
||||
seen_alive_while_reconnecting = {"v": False}
|
||||
|
||||
def poller():
|
||||
while not stop.is_set():
|
||||
alive = engine.check_alive(exception_if_dead=True)
|
||||
if engine._reconnecting and alive:
|
||||
seen_alive_while_reconnecting["v"] = True
|
||||
time.sleep(0.001)
|
||||
|
||||
t = threading.Thread(target=poller, daemon=True)
|
||||
try:
|
||||
assert wait_until(lambda: engine.ws is first)
|
||||
t.start()
|
||||
first.drop()
|
||||
|
||||
# Despite aggressive polling, the popup still fires exactly once.
|
||||
assert wait_until(lambda: popup_codes(katrain) == ["REMOTE-DISCONNECTED"])
|
||||
time.sleep(0.05) # let the poller keep hammering after the report
|
||||
assert popup_codes(katrain) == ["REMOTE-DISCONNECTED"]
|
||||
# And check_alive reported "alive" during the reconnect window, not dead.
|
||||
assert seen_alive_while_reconnecting["v"]
|
||||
finally:
|
||||
stop.set()
|
||||
t.join(timeout=1)
|
||||
engine.shutdown()
|
||||
|
||||
|
||||
def test_new_game_clears_resend_backlog(monkeypatch, fast_backoff):
|
||||
created = []
|
||||
|
||||
def factory(*args, **kwargs):
|
||||
ws = FakeWS()
|
||||
created.append(ws)
|
||||
return ws
|
||||
|
||||
monkeypatch.setattr(remote_engine, "create_connection", factory)
|
||||
|
||||
katrain = FakeKatrain()
|
||||
engine = RemoteKataGoEngine(katrain, {"remote_url": "ws://test", "allow_recovery": True})
|
||||
try:
|
||||
ws1 = created[0]
|
||||
engine.send_query({"foo": "bar"}, lambda *a: None, None)
|
||||
assert wait_until(lambda: len(engine.sent_payloads) == 1)
|
||||
|
||||
# Starting a new game drops outstanding queries; they must not be
|
||||
# resurrected by a later reconnect.
|
||||
engine.on_new_game()
|
||||
assert engine.sent_payloads == {}
|
||||
|
||||
ws1.drop()
|
||||
assert wait_until(lambda: len(created) == 2)
|
||||
ws2 = created[1]
|
||||
assert wait_until(lambda: engine.ws is ws2)
|
||||
# The previous game's analysis query is not resurrected (a leftover
|
||||
# `terminate` command queued by on_new_game may still be sent).
|
||||
time.sleep(0.1)
|
||||
assert all("foo" not in payload for payload in ws2.sent)
|
||||
finally:
|
||||
engine.shutdown()
|
||||
Reference in new issue
Block a user