From f07e57cd032c4b087798f9d55d7da84addbec003 Mon Sep 17 00:00:00 2001 From: manuelseeger Date: Mon, 22 Jul 2024 14:17:31 +0200 Subject: [PATCH 1/8] Notify OBS that SC2 is showing loading screen --- .env.example | 3 ++- config.py | 1 + environment-cp311.yml | 1 + obs_client.py | 37 +++++++++++++++++++++++++++++++++++++ obs_tools/sc2client.py | 15 ++++++++++++++- obs_tools/types.py | 23 ++++++++++++++++++++++- 6 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 obs_client.py diff --git a/.env.example b/.env.example index e2848cf..80a553c 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ AICOACH_MONGO_DSN= AICOACH_ASSISTANT_ID= AICOACH_OPENAI_API_KEY= -AICOACH_OPENAI_ORG_ID= \ No newline at end of file +AICOACH_OPENAI_ORG_ID= +AICOACH_OBS_WS_PW= \ No newline at end of file diff --git a/config.py b/config.py index 0be0bbd..3ef8d60 100644 --- a/config.py +++ b/config.py @@ -99,6 +99,7 @@ class Config(BaseSettings): sc2_client_url: str = "http://127.0.0.1:6119" screenshot: str tessdata_dir: str + obs_ws_pw: str | None season: int diff --git a/environment-cp311.yml b/environment-cp311.yml index 4f934f5..a7fdbf6 100644 --- a/environment-cp311.yml +++ b/environment-cp311.yml @@ -44,3 +44,4 @@ dependencies: - pyodmongo #- RealtimeTTS # install manually as it downgrades openai - keyboard + - obsws-python diff --git a/obs_client.py b/obs_client.py new file mode 100644 index 0000000..f0ce758 --- /dev/null +++ b/obs_client.py @@ -0,0 +1,37 @@ +import obsws_python as obs +import click + +from time import sleep + +from obs_tools.sc2client import sc2client +from config import config +from obs_tools.types import UIInfo, Screen +from rich import print + +@click.command() +def main(): + with obs.ReqClient(host='localhost', port=4455, password=config.obs_ws_pw, timeout=3) as cl: + resp = cl.get_version() + print(f"OBS Version: {resp.obs_version}") + + while True: + ui = sc2client.get_screens() + + if len(ui.activeScreens) and Screen.loading in ui.activeScreens: + print(Screen.loading) + + data = { + "message": Screen.loading + } + + cl.call_vendor_request(vendor_name="AdvancedSceneSwitcher", request_type="AdvancedSceneSwitcherMessage", request_data=data) + sleep(5) + elif len(ui.activeScreens) == 0: + print("In game") + sleep(10) + else: + sleep(0.25) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/obs_tools/sc2client.py b/obs_tools/sc2client.py index 54d8da6..dfc93a0 100644 --- a/obs_tools/sc2client.py +++ b/obs_tools/sc2client.py @@ -9,7 +9,7 @@ from config import config -from .types import GameInfo, Result, ScanResult +from .types import GameInfo, Result, ScanResult, UIInfo, Screen log = logging.getLogger(f"{config.name}.{__name__}") @@ -43,6 +43,19 @@ def get_opponent_name(self, gameinfo=None) -> str: if player.name != config.student.name: return player.name return None + + def get_screens(self) -> UIInfo: + try: + response = requests.get(config.sc2_client_url + "/ui") + if response.status_code == 200: + try: + ui = UIInfo.model_validate_json(response.text) + return ui + except ValidationError as e: + log.warn(f"Invalid UI data: {e}") + except ConnectionError as e: + log.warn("Could not connect to SC2 game client, is SC2 running?") + return None def wait_for_gameinfo( self, timeout: int = 20, delay: float = 0.5, ongoing=False diff --git a/obs_tools/types.py b/obs_tools/types.py index f048d66..324536b 100644 --- a/obs_tools/types.py +++ b/obs_tools/types.py @@ -1,9 +1,26 @@ from enum import Enum from typing import List - +from enum import Enum from pydantic import BaseModel +class Screen(str, Enum): + loading = "ScreenLoading/ScreenLoading" + score = "ScreenScore/ScreenScore" + home = "ScreenHome/ScreenHome" + background = "ScreenBackgroundSC2/ScreenBackgroundSC2" + foreground = "ScreenForegroundSC2/ScreenForegroundSC2" + navigation = "ScreenNavigationSC2/ScreenNavigationSC2" + userprofile = "ScreenUserProfile/ScreenUserProfile" + multiplayer = "ScreenMultiplayer/ScreenMultiplayer" + single = "ScreenSingle/ScreenSingle" + collection = "ScreenCollection/ScreenCollection" + coopcampaign = "ScreenCoopCampaign/ScreenCoopCampaign" + custom = "ScreenCustom/ScreenCustom" + replay = "ScreenReplay/ScreenReplay" + battlelobby = "ScreenBattleLobby/ScreenBattleLobby" + + class ScanResult(BaseModel): mapname: str opponent: str @@ -82,3 +99,7 @@ def is_decided(self) -> bool: and len(self.players) > 0 and all(player.result != Result.undecided for player in self.players) ) + + +class UIInfo(BaseModel): + activeScreens: List[Screen] \ No newline at end of file From 223e8a8a0bc06b3c34ac165f408ff693e757a294 Mon Sep 17 00:00:00 2001 From: manuelseeger Date: Mon, 22 Jul 2024 14:22:39 +0200 Subject: [PATCH 2/8] Formatting --- obs_client.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/obs_client.py b/obs_client.py index f0ce758..1b86552 100644 --- a/obs_client.py +++ b/obs_client.py @@ -8,9 +8,12 @@ from obs_tools.types import UIInfo, Screen from rich import print + @click.command() def main(): - with obs.ReqClient(host='localhost', port=4455, password=config.obs_ws_pw, timeout=3) as cl: + with obs.ReqClient( + host="localhost", port=4455, password=config.obs_ws_pw, timeout=3 + ) as cl: resp = cl.get_version() print(f"OBS Version: {resp.obs_version}") @@ -19,12 +22,14 @@ def main(): if len(ui.activeScreens) and Screen.loading in ui.activeScreens: print(Screen.loading) - - data = { - "message": Screen.loading - } - cl.call_vendor_request(vendor_name="AdvancedSceneSwitcher", request_type="AdvancedSceneSwitcherMessage", request_data=data) + data = {"message": Screen.loading} + + cl.call_vendor_request( + vendor_name="AdvancedSceneSwitcher", + request_type="AdvancedSceneSwitcherMessage", + request_data=data, + ) sleep(5) elif len(ui.activeScreens) == 0: print("In game") @@ -33,5 +38,5 @@ def main(): sleep(0.25) -if __name__ == '__main__': - main() \ No newline at end of file +if __name__ == "__main__": + main() From c8b3e76f6f6ede4c602bb8593879f3a1f33007a5 Mon Sep 17 00:00:00 2001 From: manuelseeger Date: Mon, 22 Jul 2024 14:35:49 +0200 Subject: [PATCH 3/8] Formatting --- obs_client.py | 13 +++++++------ obs_tools/sc2client.py | 6 +++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/obs_client.py b/obs_client.py index 1b86552..885bbba 100644 --- a/obs_client.py +++ b/obs_client.py @@ -1,16 +1,17 @@ -import obsws_python as obs -import click - from time import sleep -from obs_tools.sc2client import sc2client -from config import config -from obs_tools.types import UIInfo, Screen +import click +import obsws_python as obs from rich import print +from config import config +from obs_tools.sc2client import sc2client +from obs_tools.types import Screen, UIInfo + @click.command() def main(): + """Monitor SC2 UI through client API and let OBS know when loading screen is active""" with obs.ReqClient( host="localhost", port=4455, password=config.obs_ws_pw, timeout=3 ) as cl: diff --git a/obs_tools/sc2client.py b/obs_tools/sc2client.py index dfc93a0..f6ace75 100644 --- a/obs_tools/sc2client.py +++ b/obs_tools/sc2client.py @@ -9,7 +9,7 @@ from config import config -from .types import GameInfo, Result, ScanResult, UIInfo, Screen +from .types import GameInfo, Result, ScanResult, Screen, UIInfo log = logging.getLogger(f"{config.name}.{__name__}") @@ -43,12 +43,12 @@ def get_opponent_name(self, gameinfo=None) -> str: if player.name != config.student.name: return player.name return None - + def get_screens(self) -> UIInfo: try: response = requests.get(config.sc2_client_url + "/ui") if response.status_code == 200: - try: + try: ui = UIInfo.model_validate_json(response.text) return ui except ValidationError as e: From bdc9d249f76b54871004347ec41c4736dfa7912a Mon Sep 17 00:00:00 2001 From: manuelseeger Date: Mon, 22 Jul 2024 14:36:48 +0200 Subject: [PATCH 4/8] Formatting --- obs_tools/types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/obs_tools/types.py b/obs_tools/types.py index 324536b..8efd11e 100644 --- a/obs_tools/types.py +++ b/obs_tools/types.py @@ -1,6 +1,6 @@ from enum import Enum from typing import List -from enum import Enum + from pydantic import BaseModel @@ -102,4 +102,4 @@ def is_decided(self) -> bool: class UIInfo(BaseModel): - activeScreens: List[Screen] \ No newline at end of file + activeScreens: List[Screen] From 7cbb23f990c755fb96cc995872f230a9b94b825b Mon Sep 17 00:00:00 2001 From: manuelseeger Date: Mon, 22 Jul 2024 15:22:36 +0200 Subject: [PATCH 5/8] Don't crash if SC2 is not running --- obs_client.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/obs_client.py b/obs_client.py index 885bbba..125fb53 100644 --- a/obs_client.py +++ b/obs_client.py @@ -21,6 +21,11 @@ def main(): while True: ui = sc2client.get_screens() + if ui is None: + print("SC2 not running?") + sleep(5) + continue + if len(ui.activeScreens) and Screen.loading in ui.activeScreens: print(Screen.loading) From 9190fb1308c9764393fa9407ea6914c1b6f3b2fd Mon Sep 17 00:00:00 2001 From: manuelseeger Date: Tue, 23 Jul 2024 12:13:39 +0200 Subject: [PATCH 6/8] Expand OBS web socket intergration, update dependencies --- Installation.md | 33 +++++++++++------------------ environment-cp311.yml | 2 +- obs_tools/sc2client.py | 5 +++-- obs_tools/types.py | 2 +- tests/integration/test_obs_tools.py | 6 +++--- tests/unit/test_sc2client.py | 17 +++++++++++++++ 6 files changed, 37 insertions(+), 28 deletions(-) create mode 100644 tests/unit/test_sc2client.py diff --git a/Installation.md b/Installation.md index 19f2d7e..5109bcd 100644 --- a/Installation.md +++ b/Installation.md @@ -3,7 +3,7 @@ Notes on how to setup dependencies; In general, create a new env with conda: ```sh -conda env create --name aicoach311 --file=environments-cp311.yml +conda env create --file=environments-cp311.yml ``` Python 3.11 is the only version that works with all dependencies at this point. @@ -19,33 +19,24 @@ import openwakeword openwakeword.utils.download_models() ``` -## Flash attention - -https://pypi.org/project/flash-attn/ - -Set MAX_JOBS=4 if less than 100Gb of RAM - ## pytorch with CUDA Needs a CUDA capabale NVidia GPU to run fast whisper. conda install pytorch torchvision torchaudio pytorch-cuda=12.1 -c pytorch -c nvidia -## tesseract - -Install tesseract with language data. (Windows: https://github.com/UB-Mannheim/tesseract/wiki). -If installed to non-default location adjust tessdata_dir in config. - -## RealtimeTTS - -https://github.com/KoljaB/RealtimeTTS?tab=readme-ov-file - -Install manually then reinstall openai as RealtimeTTS downgrades openai on installation. +## Flash attention -After installation, remove TTS (not maintained anymore) +https://pypi.org/project/flash-attn/ -`pip uninstall TTS` +Notes: +- Get C++ build tools: https://visualstudio.microsoft.com/visual-cpp-build-tools/ + - MSVC C++ 2022 build tools latest + - Windows 11 SDK +- Get ninja: ```pip install ninja``` +- Set MAX_JOBS=4 if less than 100Gb of RAM -and instead install coqui-tts +## tesseract -`pip install coqui-tts` +Install tesseract with language data. (Windows: https://github.com/UB-Mannheim/tesseract/wiki). +If installed to non-default location adjust tessdata_dir in config. diff --git a/environment-cp311.yml b/environment-cp311.yml index a7fdbf6..e882f93 100644 --- a/environment-cp311.yml +++ b/environment-cp311.yml @@ -42,6 +42,6 @@ dependencies: - SpeechRecognition #- flash_attn # https://pypi.org/project/flash-attn/ - pyodmongo - #- RealtimeTTS # install manually as it downgrades openai + - realtimetts[system,coqui] - keyboard - obsws-python diff --git a/obs_tools/sc2client.py b/obs_tools/sc2client.py index f6ace75..2adcff3 100644 --- a/obs_tools/sc2client.py +++ b/obs_tools/sc2client.py @@ -1,6 +1,7 @@ import logging import threading from time import sleep, time +from urllib.parse import urljoin import requests from blinker import signal @@ -24,7 +25,7 @@ class SC2Client: def get_gameinfo(self) -> GameInfo: try: - response = requests.get(config.sc2_client_url + "/game") + response = requests.get(urljoin(config.sc2_client_url, "/game")) if response.status_code == 200: try: game = GameInfo.model_validate_json(response.text) @@ -46,7 +47,7 @@ def get_opponent_name(self, gameinfo=None) -> str: def get_screens(self) -> UIInfo: try: - response = requests.get(config.sc2_client_url + "/ui") + response = requests.get(urljoin(config.sc2_client_url, "/ui")) if response.status_code == 200: try: ui = UIInfo.model_validate_json(response.text) diff --git a/obs_tools/types.py b/obs_tools/types.py index 8efd11e..174c3f1 100644 --- a/obs_tools/types.py +++ b/obs_tools/types.py @@ -102,4 +102,4 @@ def is_decided(self) -> bool: class UIInfo(BaseModel): - activeScreens: List[Screen] + activeScreens: set[Screen] diff --git a/tests/integration/test_obs_tools.py b/tests/integration/test_obs_tools.py index fc58e26..f4ec899 100644 --- a/tests/integration/test_obs_tools.py +++ b/tests/integration/test_obs_tools.py @@ -12,9 +12,7 @@ # SC2 must be running and a game must be in progress or have been played recently -# or use https://github.com/leigholiver/sc2apiemulator -# SC2 must not be running: -# docker run -d --rm -p6119:80 --name sc2api leigholiver/sc2api +# or use https://github.com/manuelseeger/sc2apiemulator def test_sc2client_get_opponent(): client = SC2Client() @@ -28,3 +26,5 @@ def test_sc2client_get_opponent(): print(f"Is barcode: {barcode}") assert opponent is not None + + diff --git a/tests/unit/test_sc2client.py b/tests/unit/test_sc2client.py new file mode 100644 index 0000000..eda07ea --- /dev/null +++ b/tests/unit/test_sc2client.py @@ -0,0 +1,17 @@ +from config import config +from obs_tools.sc2client import SC2Client +from obs_tools.types import GameInfo, Screen, UIInfo + + +def test_uiinfo_equality_loading(): + ui1 = UIInfo(activeScreens=[Screen.loading]) + ui2 = UIInfo(activeScreens=[Screen.loading]) + + assert ui1 == ui2 + + +def test_uiinfo_equality_menus(): + ui1 = UIInfo(activeScreens=[Screen.background, Screen.foreground, Screen.navigation, Screen.home ]) + ui2 = UIInfo(activeScreens=[Screen.home, Screen.background, Screen.foreground, Screen.navigation]) + + assert ui1 == ui2 \ No newline at end of file From d63e21f0d758167d56d211a9f637b39cd3c09490 Mon Sep 17 00:00:00 2001 From: manuelseeger Date: Tue, 23 Jul 2024 12:29:39 +0200 Subject: [PATCH 7/8] Formatting, improve GG check --- replays/sc2readerplugins/statistics.py | 3 ++- tests/integration/test_obs_tools.py | 2 -- tests/unit/test_sc2client.py | 24 ++++++++++++++++++------ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/replays/sc2readerplugins/statistics.py b/replays/sc2readerplugins/statistics.py index 8c2a477..cc0384e 100644 --- a/replays/sc2readerplugins/statistics.py +++ b/replays/sc2readerplugins/statistics.py @@ -27,7 +27,8 @@ def loserDoesGG(replay): loser_sids = [p.sid for p in replay.players if p.result == "Loss"] loser_messages = [m for m in replay.messages if m.pid in loser_sids] return any( - levenshtein(m.text.lower(), g) < 2 and m.text.lower() != "bg" + set((m.text.lower())) - set("g") == set() + or (levenshtein(m.text.lower(), g) < 2 and m.text.lower() != "bg") for g in GGS for m in loser_messages ) diff --git a/tests/integration/test_obs_tools.py b/tests/integration/test_obs_tools.py index f4ec899..0cce3a3 100644 --- a/tests/integration/test_obs_tools.py +++ b/tests/integration/test_obs_tools.py @@ -26,5 +26,3 @@ def test_sc2client_get_opponent(): print(f"Is barcode: {barcode}") assert opponent is not None - - diff --git a/tests/unit/test_sc2client.py b/tests/unit/test_sc2client.py index eda07ea..b53f2e5 100644 --- a/tests/unit/test_sc2client.py +++ b/tests/unit/test_sc2client.py @@ -1,6 +1,4 @@ -from config import config -from obs_tools.sc2client import SC2Client -from obs_tools.types import GameInfo, Screen, UIInfo +from obs_tools.types import Screen, UIInfo def test_uiinfo_equality_loading(): @@ -11,7 +9,21 @@ def test_uiinfo_equality_loading(): def test_uiinfo_equality_menus(): - ui1 = UIInfo(activeScreens=[Screen.background, Screen.foreground, Screen.navigation, Screen.home ]) - ui2 = UIInfo(activeScreens=[Screen.home, Screen.background, Screen.foreground, Screen.navigation]) + ui1 = UIInfo( + activeScreens=[ + Screen.background, + Screen.foreground, + Screen.navigation, + Screen.home, + ] + ) + ui2 = UIInfo( + activeScreens=[ + Screen.home, + Screen.background, + Screen.foreground, + Screen.navigation, + ] + ) - assert ui1 == ui2 \ No newline at end of file + assert ui1 == ui2 From 72b874b49e67e1314f685a513fffe94f7a4b1535 Mon Sep 17 00:00:00 2001 From: manuelseeger Date: Tue, 23 Jul 2024 12:48:00 +0200 Subject: [PATCH 8/8] Add more OBS WS events --- obs_client.py | 64 +++++++++++++++++++++++++++++++----------- obs_tools/sc2client.py | 51 ++++++++++++++++----------------- 2 files changed, 74 insertions(+), 41 deletions(-) diff --git a/obs_client.py b/obs_client.py index 125fb53..3999fca 100644 --- a/obs_client.py +++ b/obs_client.py @@ -1,47 +1,79 @@ from time import sleep import click -import obsws_python as obs +import obsws_python as obsws from rich import print from config import config from obs_tools.sc2client import sc2client -from obs_tools.types import Screen, UIInfo +from obs_tools.types import Screen +# we set this up as a standalone process so that OBS can run and react to SC2 UI changes without the need +# to run the rest of the project. +# This will send the currently visible screen(s) in SC2 menus to OBS via the AdvancedSceneSwitcher plugin +# If ingame, it will send "In game" to OBS +# AdvancedSceneSwitcher can use these messages in macro conditions @click.command() -def main(): +@click.option("--verbose", is_flag=True) +def main(verbose): """Monitor SC2 UI through client API and let OBS know when loading screen is active""" - with obs.ReqClient( + + menu_screens = set([Screen.background, Screen.foreground, Screen.navigation]) + + with obsws.ReqClient( host="localhost", port=4455, password=config.obs_ws_pw, timeout=3 - ) as cl: - resp = cl.get_version() + ) as obs: + resp = obs.get_version() print(f"OBS Version: {resp.obs_version}") + last_ui = None while True: - ui = sc2client.get_screens() + ui = sc2client.get_uiinfo() if ui is None: - print("SC2 not running?") + print(":warning: SC2 not running?") sleep(5) continue - if len(ui.activeScreens) and Screen.loading in ui.activeScreens: + if ui == last_ui: + # only notify OBS on changes + sleep(0.5) + continue + + if verbose: + print(ui.activeScreens) + + if len(ui.activeScreens) == 0: + print("In game") + data = {"message": "In game"} + obs.call_vendor_request( + vendor_name="AdvancedSceneSwitcher", + request_type="AdvancedSceneSwitcherMessage", + request_data=data, + ) + elif Screen.loading in ui.activeScreens: print(Screen.loading) data = {"message": Screen.loading} - - cl.call_vendor_request( + obs.call_vendor_request( + vendor_name="AdvancedSceneSwitcher", + request_type="AdvancedSceneSwitcherMessage", + request_data=data, + ) + elif menu_screens < ui.activeScreens: + menues = ui.activeScreens - menu_screens + print("In menues " + str(menues)) + data = {"message": "\n".join(sorted(menues))} + obs.call_vendor_request( vendor_name="AdvancedSceneSwitcher", request_type="AdvancedSceneSwitcherMessage", request_data=data, ) - sleep(5) - elif len(ui.activeScreens) == 0: - print("In game") - sleep(10) else: - sleep(0.25) + pass + + last_ui = ui if __name__ == "__main__": diff --git a/obs_tools/sc2client.py b/obs_tools/sc2client.py index 2adcff3..02fe017 100644 --- a/obs_tools/sc2client.py +++ b/obs_tools/sc2client.py @@ -25,15 +25,20 @@ class SC2Client: def get_gameinfo(self) -> GameInfo: try: - response = requests.get(urljoin(config.sc2_client_url, "/game")) - if response.status_code == 200: - try: - game = GameInfo.model_validate_json(response.text) - return game - except ValidationError as e: - log.warn(f"Invalid game data: {e}") - except ConnectionError as e: - log.warn("Could not connect to SC2 game client, is SC2 running?") + game = self._get_info("/game") + gameinfo = GameInfo.model_validate_json(game) + return gameinfo + except ValidationError as e: + log.warn(f"Invalid game data: {e}") + return None + + def get_uiinfo(self) -> UIInfo: + try: + ui = self._get_info("/ui") + uiinfo = UIInfo.model_validate_json(ui) + return uiinfo + except ValidationError as e: + log.warn(f"Invalid UI data: {e}") return None def get_opponent_name(self, gameinfo=None) -> str: @@ -45,15 +50,11 @@ def get_opponent_name(self, gameinfo=None) -> str: return player.name return None - def get_screens(self) -> UIInfo: + def _get_info(self, path) -> str: try: - response = requests.get(urljoin(config.sc2_client_url, "/ui")) + response = requests.get(urljoin(config.sc2_client_url, path)) if response.status_code == 200: - try: - ui = UIInfo.model_validate_json(response.text) - return ui - except ValidationError as e: - log.warn(f"Invalid UI data: {e}") + return response.text except ConnectionError as e: log.warn("Could not connect to SC2 game client, is SC2 running?") return None @@ -63,7 +64,6 @@ def wait_for_gameinfo( ) -> GameInfo: start_time = time() while time() - start_time < timeout: - gameinfo = self.get_gameinfo() if ongoing: gameinfo = self.get_ongoing_gameinfo() else: @@ -116,7 +116,7 @@ def scan_client_api(self): gameinfo = sc2client.get_ongoing_gameinfo() - if self.is_live_game(gameinfo): + if is_live_game(gameinfo): if gameinfo == self.last_gameinfo: # same ongoing game, just later in time if gameinfo.displayTime >= self.last_gameinfo.displayTime: @@ -131,13 +131,14 @@ def scan_client_api(self): loading_screen.send(self, scanresult=scanresult) sleep(1) - def is_live_game(self, gameinfo): - return ( - gameinfo - and gameinfo.displayTime > 0 - and gameinfo.players[0].result == Result.undecided - and not gameinfo.isReplay - ) + +def is_live_game(gameinfo: GameInfo) -> bool: + return ( + gameinfo + and gameinfo.displayTime > 0 + and gameinfo.players[0].result == Result.undecided + and not gameinfo.isReplay + ) if __name__ == "__main__":