├── src └── cli_chess │ ├── core │ ├── __init__.py │ ├── main │ │ ├── __init__.py │ │ ├── main_model.py │ │ └── main_presenter.py │ ├── game │ │ ├── online_game │ │ │ ├── __init__.py │ │ │ ├── watch_tv │ │ │ │ ├── __init__.py │ │ │ │ ├── watch_tv_presenter.py │ │ │ │ └── watch_tv_view.py │ │ │ ├── online_game_view.py │ │ │ └── online_game_presenter.py │ │ ├── offline_game │ │ │ ├── __init__.py │ │ │ ├── offline_game_view.py │ │ │ └── offline_game_model.py │ │ ├── __init__.py │ │ ├── game_metadata.py │ │ ├── game_model_base.py │ │ ├── game_options.py │ │ └── game_presenter_base.py │ └── api │ │ ├── __init__.py │ │ ├── api_manager.py │ │ ├── incoming_event_manger.py │ │ └── game_state_dispatcher.py │ ├── modules │ ├── __init__.py │ ├── about │ │ ├── __init__.py │ │ ├── about_presenter.py │ │ └── about_view.py │ ├── clock │ │ ├── __init__.py │ │ ├── clock_view.py │ │ └── clock_presenter.py │ ├── engine │ │ ├── __init__.py │ │ ├── binaries │ │ │ ├── fairy-stockfish_arm64_macos │ │ │ ├── fairy-stockfish_x86-64_linux │ │ │ ├── fairy-stockfish_x86-64_macos │ │ │ └── fairy-stockfish_x86-64_windows.exe │ │ ├── engine_presenter.py │ │ └── engine_model.py │ ├── player_info │ │ ├── __init__.py │ │ ├── player_info_presenter.py │ │ └── player_info_view.py │ ├── board │ │ ├── __init__.py │ │ └── board_view.py │ ├── premove │ │ ├── __init__.py │ │ ├── premove_presenter.py │ │ ├── premove_view.py │ │ └── premove_model.py │ ├── move_list │ │ ├── __init__.py │ │ ├── move_list_model.py │ │ ├── move_list_presenter.py │ │ └── move_list_view.py │ ├── token_manager │ │ ├── __init__.py │ │ ├── token_manager_presenter.py │ │ ├── token_manager_view.py │ │ └── token_manager_model.py │ ├── material_difference │ │ ├── __init__.py │ │ ├── material_difference_view.py │ │ ├── material_difference_presenter.py │ │ └── material_difference_model.py │ └── common.py │ ├── tests │ ├── __init__.py │ ├── utils │ │ ├── __init__.py │ │ └── test_event.py │ └── modules │ │ ├── __init__.py │ │ ├── board │ │ └── __init__.py │ │ ├── move_list │ │ ├── __init__.py │ │ ├── test_move_list_model.py │ │ └── test_move_list_presenter.py │ │ ├── token_manager │ │ ├── __init__.py │ │ ├── test_token_manager_presenter.py │ │ └── test_token_manager_model.py │ │ ├── material_difference │ │ ├── __init__.py │ │ └── test_material_difference_presenter.py │ │ └── test_common.py │ ├── menus │ ├── main_menu │ │ ├── __init__.py │ │ ├── main_menu_model.py │ │ ├── main_menu_presenter.py │ │ └── main_menu_view.py │ ├── settings_menu │ │ ├── __init__.py │ │ ├── settings_menu_model.py │ │ ├── settings_menu_presenter.py │ │ └── settings_menu_view.py │ ├── tv_channel_menu │ │ ├── __init__.py │ │ ├── tv_channel_menu_presenter.py │ │ ├── tv_channel_menu_view.py │ │ └── tv_channel_menu_model.py │ ├── online_games_menu │ │ ├── __init__.py │ │ ├── online_games_menu_model.py │ │ ├── online_games_menu_presenter.py │ │ └── online_games_menu_view.py │ ├── offline_games_menu │ │ ├── __init__.py │ │ ├── offline_games_menu_model.py │ │ ├── offline_games_menu_presenter.py │ │ └── offline_games_menu_view.py │ ├── program_settings_menu │ │ ├── __init__.py │ │ ├── program_settings_menu_view.py │ │ ├── program_settings_menu_presenter.py │ │ └── program_settings_menu_model.py │ ├── versus_menus │ │ ├── __init__.py │ │ ├── versus_menu_views.py │ │ ├── versus_menu_presenters.py │ │ └── versus_menu_models.py │ ├── __init__.py │ ├── menu_model.py │ ├── menu_common.py │ └── menu_presenter.py │ ├── __main__.py │ ├── __metadata__.py │ ├── __init__.py │ └── utils │ ├── __init__.py │ ├── logging.py │ ├── argparse.py │ ├── styles.py │ ├── event.py │ └── common.py ├── MANIFEST.in ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── enhancement.yml │ └── bug.yml └── workflows │ ├── pypi-publish.yml │ ├── ci.yml │ └── build-fairy-sf-binaries.yml ├── .gitignore ├── setup.py └── setup.cfg /src/cli_chess/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cli_chess/modules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cli_chess/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cli_chess/tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cli_chess/tests/modules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cli_chess/tests/modules/board/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cli_chess/tests/modules/move_list/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cli_chess/tests/modules/token_manager/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cli_chess/tests/modules/material_difference/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cli_chess/modules/about/__init__.py: -------------------------------------------------------------------------------- 1 | from .about_view import AboutView 2 | from .about_presenter import AboutPresenter 3 | -------------------------------------------------------------------------------- /src/cli_chess/modules/clock/__init__.py: -------------------------------------------------------------------------------- 1 | from .clock_view import ClockView 2 | from .clock_presenter import ClockPresenter 3 | -------------------------------------------------------------------------------- /src/cli_chess/modules/engine/__init__.py: -------------------------------------------------------------------------------- 1 | from .engine_model import EngineModel 2 | from .engine_presenter import EnginePresenter 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | prune **/__pycache__ 2 | global-exclude .coverage 3 | global-exclude *.py[co] 4 | include src/cli_chess/modules/engine/binaries/* 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /src/cli_chess/modules/player_info/__init__.py: -------------------------------------------------------------------------------- 1 | from .player_info_view import PlayerInfoView 2 | from .player_info_presenter import PlayerInfoPresenter 3 | -------------------------------------------------------------------------------- /src/cli_chess/core/main/__init__.py: -------------------------------------------------------------------------------- 1 | from .main_model import MainModel 2 | from .main_view import MainView 3 | from .main_presenter import MainPresenter 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.ini 2 | .coverage 3 | *.pyc 4 | *.egg-info 5 | *.spec 6 | *.eggs/ 7 | dist/ 8 | build/ 9 | __pycache__/ 10 | *venv/ 11 | .vscode/ 12 | -------------------------------------------------------------------------------- /src/cli_chess/modules/board/__init__.py: -------------------------------------------------------------------------------- 1 | from .board_model import BoardModel 2 | from .board_view import BoardView 3 | from .board_presenter import BoardPresenter 4 | -------------------------------------------------------------------------------- /src/cli_chess/modules/premove/__init__.py: -------------------------------------------------------------------------------- 1 | from .premove_model import PremoveModel 2 | from .premove_view import PremoveView 3 | from .premove_presenter import PremovePresenter 4 | -------------------------------------------------------------------------------- /src/cli_chess/modules/engine/binaries/fairy-stockfish_arm64_macos: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trevorbayless/cli-chess/HEAD/src/cli_chess/modules/engine/binaries/fairy-stockfish_arm64_macos -------------------------------------------------------------------------------- /src/cli_chess/modules/engine/binaries/fairy-stockfish_x86-64_linux: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trevorbayless/cli-chess/HEAD/src/cli_chess/modules/engine/binaries/fairy-stockfish_x86-64_linux -------------------------------------------------------------------------------- /src/cli_chess/modules/engine/binaries/fairy-stockfish_x86-64_macos: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trevorbayless/cli-chess/HEAD/src/cli_chess/modules/engine/binaries/fairy-stockfish_x86-64_macos -------------------------------------------------------------------------------- /src/cli_chess/modules/move_list/__init__.py: -------------------------------------------------------------------------------- 1 | from .move_list_model import MoveListModel 2 | from .move_list_view import MoveListView 3 | from .move_list_presenter import MoveListPresenter 4 | -------------------------------------------------------------------------------- /src/cli_chess/modules/engine/binaries/fairy-stockfish_x86-64_windows.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trevorbayless/cli-chess/HEAD/src/cli_chess/modules/engine/binaries/fairy-stockfish_x86-64_windows.exe -------------------------------------------------------------------------------- /src/cli_chess/core/game/online_game/__init__.py: -------------------------------------------------------------------------------- 1 | from .online_game_model import OnlineGameModel 2 | from .online_game_view import OnlineGameView 3 | from .online_game_presenter import OnlineGamePresenter 4 | -------------------------------------------------------------------------------- /src/cli_chess/menus/main_menu/__init__.py: -------------------------------------------------------------------------------- 1 | from .main_menu_model import MainMenuModel, MainMenuOptions 2 | from .main_menu_view import MainMenuView 3 | from .main_menu_presenter import MainMenuPresenter 4 | -------------------------------------------------------------------------------- /src/cli_chess/core/game/offline_game/__init__.py: -------------------------------------------------------------------------------- 1 | from .offline_game_model import OfflineGameModel 2 | from .offline_game_view import OfflineGameView 3 | from .offline_game_presenter import OfflineGamePresenter 4 | -------------------------------------------------------------------------------- /src/cli_chess/core/game/online_game/watch_tv/__init__.py: -------------------------------------------------------------------------------- 1 | from .watch_tv_model import WatchTVModel 2 | from .watch_tv_view import WatchTVView 3 | from .watch_tv_presenter import WatchTVPresenter, start_watching_tv 4 | -------------------------------------------------------------------------------- /src/cli_chess/modules/token_manager/__init__.py: -------------------------------------------------------------------------------- 1 | from .token_manager_model import TokenManagerModel 2 | from .token_manager_view import TokenManagerView 3 | from .token_manager_presenter import TokenManagerPresenter 4 | -------------------------------------------------------------------------------- /src/cli_chess/menus/settings_menu/__init__.py: -------------------------------------------------------------------------------- 1 | from .settings_menu_model import SettingsMenuModel, SettingsMenuOptions 2 | from .settings_menu_view import SettingsMenuView 3 | from .settings_menu_presenter import SettingsMenuPresenter 4 | -------------------------------------------------------------------------------- /src/cli_chess/menus/tv_channel_menu/__init__.py: -------------------------------------------------------------------------------- 1 | from .tv_channel_menu_model import TVChannelMenuModel, TVChannelMenuOptions 2 | from .tv_channel_menu_view import TVChannelMenuView 3 | from .tv_channel_menu_presenter import TVChannelMenuPresenter 4 | -------------------------------------------------------------------------------- /src/cli_chess/core/api/__init__.py: -------------------------------------------------------------------------------- 1 | from cli_chess.core.api.incoming_event_manger import IncomingEventManager 2 | from cli_chess.core.api.game_state_dispatcher import GameStateDispatcher 3 | from cli_chess.core.api.api_manager import required_token_scopes 4 | -------------------------------------------------------------------------------- /src/cli_chess/__main__.py: -------------------------------------------------------------------------------- 1 | from cli_chess.core.main import MainModel, MainPresenter 2 | 3 | 4 | def main() -> None: 5 | """Main entry point""" 6 | MainPresenter(MainModel()).run() 7 | 8 | 9 | if __name__ == "__main__": 10 | main() 11 | -------------------------------------------------------------------------------- /src/cli_chess/modules/material_difference/__init__.py: -------------------------------------------------------------------------------- 1 | from .material_difference_model import MaterialDifferenceModel 2 | from .material_difference_view import MaterialDifferenceView 3 | from .material_difference_presenter import MaterialDifferencePresenter 4 | -------------------------------------------------------------------------------- /src/cli_chess/menus/online_games_menu/__init__.py: -------------------------------------------------------------------------------- 1 | from .online_games_menu_model import OnlineGamesMenuModel, OnlineGamesMenuOptions 2 | from .online_games_menu_view import OnlineGamesMenuView 3 | from .online_games_menu_presenter import OnlineGamesMenuPresenter 4 | -------------------------------------------------------------------------------- /src/cli_chess/menus/offline_games_menu/__init__.py: -------------------------------------------------------------------------------- 1 | from .offline_games_menu_model import OfflineGamesMenuModel, OfflineGamesMenuOptions 2 | from .offline_games_menu_view import OfflineGamesMenuView 3 | from .offline_games_menu_presenter import OfflineGamesMenuPresenter 4 | -------------------------------------------------------------------------------- /src/cli_chess/menus/program_settings_menu/__init__.py: -------------------------------------------------------------------------------- 1 | from .program_settings_menu_model import ProgramSettingsMenuModel 2 | from .program_settings_menu_view import ProgramSettingsMenuView 3 | from .program_settings_menu_presenter import ProgramSettingsMenuPresenter 4 | -------------------------------------------------------------------------------- /src/cli_chess/menus/versus_menus/__init__.py: -------------------------------------------------------------------------------- 1 | from .versus_menu_models import VersusMenuModel, OfflineVsComputerMenuModel, OnlineVsComputerMenuModel, OnlineVsRandomOpponentMenuModel 2 | from .versus_menu_views import VersusMenuView 3 | from .versus_menu_presenters import OfflineVersusMenuPresenter, OnlineVersusMenuPresenter 4 | -------------------------------------------------------------------------------- /src/cli_chess/__metadata__.py: -------------------------------------------------------------------------------- 1 | __name__ = "cli-chess" 2 | __version__ = "1.4.3" 3 | __description__ = "A highly customizable way to play chess in your terminal" 4 | __url__ = "https://github.com/trevorbayless/cli-chess" 5 | __author__ = "Trevor Bayless" 6 | __author_email__ = "cli-chess@trb.simplelogin.com" 7 | __license__ = "GPL-3.0+" 8 | -------------------------------------------------------------------------------- /src/cli_chess/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | cli-chess 3 | ========= 4 | 5 | cli-chess is a highly customizable command line based chess program. 6 | It supports playing chess online using your Lichess.org account as 7 | well as offline against the Fairy-Stockfish chess engine. 8 | 9 | To contribute, report issues, or learn more about cli-chess visit: 10 | https://github.com/trevorbayless/cli-chess 11 | """ 12 | -------------------------------------------------------------------------------- /src/cli_chess/menus/__init__.py: -------------------------------------------------------------------------------- 1 | from .menu_common import MenuCategory, MenuOption, MultiValueMenuOption 2 | from .menu_model import MenuModel, MultiValueMenuModel 3 | from .menu_view import MenuView, MultiValueMenuView 4 | from .menu_presenter import MenuPresenter, MultiValueMenuPresenter 5 | from .main_menu import MainMenuPresenter 6 | from .offline_games_menu import OfflineGamesMenuPresenter 7 | from .versus_menus import OfflineVersusMenuPresenter 8 | -------------------------------------------------------------------------------- /src/cli_chess/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .common import AlertType, is_linux_os, is_windows_os, is_mac_os, str_to_bool, threaded, retry, open_url_in_browser, RequestSuccessfullySent 2 | from .config import force_recreate_configs, print_program_config 3 | from .event import Event, EventManager, EventTopics 4 | from .logging import log, redact_from_logs 5 | from .argparse import setup_argparse 6 | from .styles import default 7 | from .ui_common import AlertContainer 8 | -------------------------------------------------------------------------------- /src/cli_chess/core/game/__init__.py: -------------------------------------------------------------------------------- 1 | from .game_model_base import GameModelBase, PlayableGameModelBase 2 | from .game_view_base import GameViewBase, PlayableGameViewBase 3 | from .game_presenter_base import GamePresenterBase, PlayableGamePresenterBase 4 | from .online_game.online_game_presenter import start_online_game 5 | from .offline_game.offline_game_presenter import start_offline_game 6 | from .game_metadata import GameMetadata, PlayerMetadata, ClockMetadata 7 | -------------------------------------------------------------------------------- /src/cli_chess/menus/program_settings_menu/program_settings_menu_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.menus import MultiValueMenuView 3 | from typing import TYPE_CHECKING 4 | if TYPE_CHECKING: 5 | from cli_chess.menus.program_settings_menu import ProgramSettingsMenuPresenter 6 | 7 | 8 | class ProgramSettingsMenuView(MultiValueMenuView): 9 | def __init__(self, presenter: ProgramSettingsMenuPresenter): 10 | self.presenter = presenter 11 | super().__init__(self.presenter, container_width=40, column_width=28) 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for cli-chess 3 | labels: ['enhancement'] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for your ideas to further improve cli-chess! Before submitting, please [search existing enhancements](https://github.com/trevorbayless/cli-chess/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement) to confirm this is not a duplicate enhancement request. 9 | - type: textarea 10 | attributes: 11 | label: What feature would you like to see in cli-chess? 12 | validations: 13 | required: true 14 | -------------------------------------------------------------------------------- /src/cli_chess/modules/common.py: -------------------------------------------------------------------------------- 1 | UNICODE_PIECE_SYMBOLS = { 2 | "r": "♜", # \u265C 3 | "n": "♞", # \u265E 4 | "b": "♝", # \u265D 5 | "q": "♛", # \u265B 6 | "k": "♚", # \u265A 7 | "p": "♙", # \u2659 (avoid \u265F (♟) as it renders as emoji in most fonts) 8 | } 9 | 10 | 11 | def get_piece_unicode_symbol(symbol: str) -> str: 12 | """Returns the unicode symbol associated to the symbol passed in""" 13 | unicode_symbol = "" 14 | symbol = symbol.lower() if symbol else "" 15 | if symbol in UNICODE_PIECE_SYMBOLS: 16 | unicode_symbol = UNICODE_PIECE_SYMBOLS[symbol] 17 | 18 | return unicode_symbol 19 | -------------------------------------------------------------------------------- /src/cli_chess/tests/modules/test_common.py: -------------------------------------------------------------------------------- 1 | from cli_chess.modules.common import get_piece_unicode_symbol, UNICODE_PIECE_SYMBOLS 2 | from string import ascii_lowercase 3 | 4 | 5 | def test_get_piece_unicode_symbol(): 6 | alphabet = list(ascii_lowercase) 7 | for letter in alphabet: 8 | if letter in UNICODE_PIECE_SYMBOLS: 9 | assert get_piece_unicode_symbol(letter) == UNICODE_PIECE_SYMBOLS[letter] 10 | assert get_piece_unicode_symbol(letter.upper()) == UNICODE_PIECE_SYMBOLS[letter] 11 | else: 12 | assert get_piece_unicode_symbol(letter) == "" 13 | assert get_piece_unicode_symbol(letter.upper()) == "" 14 | assert get_piece_unicode_symbol("") == "" 15 | -------------------------------------------------------------------------------- /src/cli_chess/menus/offline_games_menu/offline_games_menu_model.py: -------------------------------------------------------------------------------- 1 | from cli_chess.menus import MenuModel, MenuOption, MenuCategory 2 | from enum import Enum 3 | 4 | 5 | class OfflineGamesMenuOptions(Enum): 6 | VS_COMPUTER = "Play vs Computer" 7 | 8 | 9 | class OfflineGamesMenuModel(MenuModel): 10 | def __init__(self): 11 | self.menu = self._create_menu() 12 | super().__init__(self.menu) 13 | 14 | @staticmethod 15 | def _create_menu() -> MenuCategory: 16 | """Create the menu options""" 17 | menu_options = [ 18 | MenuOption(OfflineGamesMenuOptions.VS_COMPUTER, "Play offline against the computer") 19 | ] 20 | 21 | return MenuCategory("Offline Games", menu_options) 22 | -------------------------------------------------------------------------------- /src/cli_chess/modules/about/about_presenter.py: -------------------------------------------------------------------------------- 1 | from cli_chess.modules.about import AboutView 2 | from cli_chess.utils.common import open_url_in_browser 3 | 4 | CLI_CHESS_GITHUB_URL = "https://github.com/trevorbayless/cli-chess/" 5 | CLI_CHESS_GITHUB_ISSUE_URL = CLI_CHESS_GITHUB_URL + "issues/new?assignees=&labels=bug&template=bug.yml" 6 | 7 | 8 | class AboutPresenter: 9 | def __init__(self): 10 | self.view = AboutView(self) 11 | 12 | @staticmethod 13 | def open_github_url() -> None: 14 | """Open the cli-chess GitHub URL""" 15 | open_url_in_browser(CLI_CHESS_GITHUB_URL) 16 | 17 | @staticmethod 18 | def open_github_issue_url() -> None: 19 | """Opens the cli-chess GitHub issue URL""" 20 | open_url_in_browser(CLI_CHESS_GITHUB_ISSUE_URL) 21 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python 18 | uses: actions/setup-python@v3 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install build 25 | - name: Build package 26 | run: python -m build 27 | - name: Publish package 28 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 29 | with: 30 | user: __token__ 31 | password: ${{ secrets.PYPI_API_TOKEN }} 32 | -------------------------------------------------------------------------------- /src/cli_chess/menus/tv_channel_menu/tv_channel_menu_presenter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.menus import MenuPresenter 3 | from cli_chess.menus.tv_channel_menu import TVChannelMenuView 4 | from cli_chess.core.game.online_game.watch_tv import start_watching_tv 5 | from typing import TYPE_CHECKING 6 | if TYPE_CHECKING: 7 | from cli_chess.menus.tv_channel_menu import TVChannelMenuModel 8 | 9 | 10 | class TVChannelMenuPresenter(MenuPresenter): 11 | def __init__(self, model: TVChannelMenuModel): 12 | self.model = model 13 | self.view = TVChannelMenuView(self) 14 | 15 | super().__init__(self.model, self.view) 16 | 17 | def handle_start_watching_tv(self) -> None: 18 | """Changes the view to start watching tv""" 19 | start_watching_tv(self.selection) 20 | -------------------------------------------------------------------------------- /src/cli_chess/modules/engine/engine_presenter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | if TYPE_CHECKING: 4 | from cli_chess.modules.engine import EngineModel 5 | from chess.engine import PlayResult 6 | 7 | 8 | class EnginePresenter: 9 | def __init__(self, model: EngineModel): 10 | self.model = model 11 | 12 | def start_engine(self) -> None: 13 | """Notifies the model to start the engine""" 14 | self.model.start_engine() 15 | 16 | def get_best_move(self) -> PlayResult: 17 | """Notify the engine to get the best move from the current position""" 18 | return self.model.get_best_move() 19 | 20 | def quit_engine(self) -> None: 21 | """Calls the model to notify the engine to quit""" 22 | self.model.quit_engine() 23 | -------------------------------------------------------------------------------- /src/cli_chess/menus/offline_games_menu/offline_games_menu_presenter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.menus.offline_games_menu import OfflineGamesMenuView 3 | from cli_chess.menus.versus_menus import OfflineVsComputerMenuModel, OfflineVersusMenuPresenter 4 | from cli_chess.menus import MenuPresenter 5 | from typing import TYPE_CHECKING 6 | if TYPE_CHECKING: 7 | from cli_chess.menus.offline_games_menu import OfflineGamesMenuModel 8 | 9 | 10 | class OfflineGamesMenuPresenter(MenuPresenter): 11 | def __init__(self, model: OfflineGamesMenuModel): 12 | self.model = model 13 | self.vs_computer_menu_presenter = OfflineVersusMenuPresenter(OfflineVsComputerMenuModel()) 14 | self.view = OfflineGamesMenuView(self) 15 | self.selection = self.model.get_menu_options()[0].option 16 | 17 | super().__init__(self.model, self.view) 18 | -------------------------------------------------------------------------------- /src/cli_chess/menus/settings_menu/settings_menu_model.py: -------------------------------------------------------------------------------- 1 | from cli_chess.menus import MenuModel, MenuOption, MenuCategory 2 | from enum import Enum 3 | 4 | 5 | class SettingsMenuOptions(Enum): 6 | LICHESS_AUTHENTICATION = "Authenticate with Lichess" 7 | PROGRAM_SETTINGS = "Program Settings" 8 | 9 | 10 | class SettingsMenuModel(MenuModel): 11 | def __init__(self): 12 | self.menu = self._create_menu() 13 | super().__init__(self.menu) 14 | 15 | @staticmethod 16 | def _create_menu() -> MenuCategory: 17 | """Create the menu options""" 18 | menu_options = [ 19 | MenuOption(SettingsMenuOptions.LICHESS_AUTHENTICATION, "Authenticate with Lichess by adding your API access token (required for playing online)"), # noqa: E501 20 | MenuOption(SettingsMenuOptions.PROGRAM_SETTINGS, "Customize cli-chess"), 21 | ] 22 | 23 | return MenuCategory("General Settings", menu_options) 24 | -------------------------------------------------------------------------------- /src/cli_chess/core/main/main_model.py: -------------------------------------------------------------------------------- 1 | from cli_chess.utils.logging import configure_logger 2 | from cli_chess.utils import setup_argparse 3 | from cli_chess.__metadata__ import __version__ 4 | from platform import python_version, system, release, machine 5 | 6 | 7 | class MainModel: 8 | """Model for the main presenter""" 9 | def __init__(self): 10 | self._start_loggers() 11 | self.startup_args = self._parse_args() 12 | 13 | @staticmethod 14 | def _start_loggers(): 15 | """Start the loggers""" 16 | log = configure_logger("cli-chess") 17 | log.info(f"cli-chess v{__version__} // python {python_version()}") 18 | log.info(f"System information: system={system()} // release={release()} // machine={machine()}") 19 | 20 | configure_logger("chess.engine") 21 | configure_logger("berserk") 22 | 23 | @staticmethod 24 | def _parse_args(): 25 | """Parse the args passed in at startup""" 26 | return setup_argparse().parse_args() 27 | -------------------------------------------------------------------------------- /src/cli_chess/menus/main_menu/main_menu_model.py: -------------------------------------------------------------------------------- 1 | from cli_chess.menus import MenuModel, MenuOption, MenuCategory 2 | from enum import Enum 3 | 4 | 5 | class MainMenuOptions(Enum): 6 | OFFLINE_GAMES = "Offline Games" 7 | ONLINE_GAMES = "Online Games" 8 | SETTINGS = "Settings" 9 | ABOUT = "About" 10 | 11 | 12 | class MainMenuModel(MenuModel): 13 | def __init__(self): 14 | self.menu = self._create_menu() 15 | super().__init__(self.menu) 16 | 17 | @staticmethod 18 | def _create_menu() -> MenuCategory: 19 | """Create the menu category with options""" 20 | menu_options = [ 21 | MenuOption(MainMenuOptions.OFFLINE_GAMES, "Play games offline"), 22 | MenuOption(MainMenuOptions.ONLINE_GAMES, "Play games online using Lichess.org"), 23 | MenuOption(MainMenuOptions.SETTINGS, "Modify cli-chess settings"), 24 | MenuOption(MainMenuOptions.ABOUT, ""), 25 | ] 26 | 27 | return MenuCategory("Main Menu", menu_options) 28 | -------------------------------------------------------------------------------- /src/cli_chess/menus/online_games_menu/online_games_menu_model.py: -------------------------------------------------------------------------------- 1 | from cli_chess.menus import MenuModel, MenuOption, MenuCategory 2 | from enum import Enum 3 | 4 | 5 | class OnlineGamesMenuOptions(Enum): 6 | CREATE_GAME = "Create a game" 7 | VS_COMPUTER_ONLINE = "Play vs Computer" 8 | WATCH_LICHESS_TV = "Watch Lichess TV" 9 | 10 | 11 | class OnlineGamesMenuModel(MenuModel): 12 | def __init__(self): 13 | self.menu = self._create_menu() 14 | super().__init__(self.menu) 15 | 16 | @staticmethod 17 | def _create_menu() -> MenuCategory: 18 | """Create the menu options""" 19 | menu_options = [ 20 | MenuOption(OnlineGamesMenuOptions.CREATE_GAME, "Create an online game against a random opponent"), 21 | MenuOption(OnlineGamesMenuOptions.VS_COMPUTER_ONLINE, "Play online against the computer"), 22 | MenuOption(OnlineGamesMenuOptions.WATCH_LICHESS_TV, "Watch top rated Lichess players compete live"), 23 | ] 24 | 25 | return MenuCategory("Online Games", menu_options) 26 | -------------------------------------------------------------------------------- /src/cli_chess/menus/settings_menu/settings_menu_presenter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.menus import MenuPresenter 3 | from cli_chess.menus.settings_menu import SettingsMenuView 4 | from cli_chess.menus.program_settings_menu import ProgramSettingsMenuModel, ProgramSettingsMenuPresenter 5 | from cli_chess.modules.token_manager import TokenManagerPresenter 6 | from cli_chess.modules.token_manager.token_manager_model import g_token_manager_model 7 | from typing import TYPE_CHECKING 8 | if TYPE_CHECKING: 9 | from cli_chess.menus.settings_menu import SettingsMenuModel 10 | 11 | 12 | class SettingsMenuPresenter(MenuPresenter): 13 | """Defines the settings menu""" 14 | def __init__(self, model: SettingsMenuModel): 15 | self.model = model 16 | self.token_manger_presenter = TokenManagerPresenter(g_token_manager_model) 17 | self.program_settings_menu_presenter = ProgramSettingsMenuPresenter(ProgramSettingsMenuModel()) 18 | self.view = SettingsMenuView(self) 19 | super().__init__(self.model, self.view) 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from re import findall 3 | 4 | metadata_file = open("src/cli_chess/__metadata__.py").read() 5 | metadata = dict(findall(r'__(\w*)__\s*=\s*"([^"]+)"', metadata_file)) 6 | 7 | dependencies = [ 8 | "chess>=1.9.4,<2.0.0", 9 | "berserk>=0.13.1,<0.14.0", 10 | "prompt-toolkit==3.0.47" # pin as breaking changes have been 11 | # introduced in previous patch versions 12 | # read PT changelog before bumping 13 | ] 14 | 15 | dev_dependencies = { 16 | 'dev': [ 17 | 'pytest>=7.2.1,<8.0.0', 18 | 'pytest-cov>=4.0.0,<5.0.0', 19 | 'pytest-socket>=0.6.0,<1.0.0', 20 | 'flake8>=5.0.4,<7.0.0', 21 | ] 22 | } 23 | 24 | setup( 25 | name=metadata['name'], 26 | version=metadata['version'], 27 | description=metadata['description'], 28 | author=metadata['author'], 29 | author_email=metadata['author_email'], 30 | url=metadata['url'], 31 | license=metadata['license'], 32 | install_requires=dependencies, 33 | setup_requires=dependencies, 34 | extras_require=dev_dependencies 35 | ) 36 | -------------------------------------------------------------------------------- /src/cli_chess/menus/menu_model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING, List 3 | if TYPE_CHECKING: 4 | from cli_chess.menus import MenuOption, MultiValueMenuOption, MenuCategory 5 | 6 | 7 | class MenuModel: 8 | def __init__(self, menu_category: MenuCategory): 9 | self.menu_category = menu_category 10 | self.category_options = menu_category.category_options 11 | 12 | def get_menu_category(self) -> MenuCategory: 13 | return self.menu_category 14 | 15 | def get_menu_options(self) -> List[MenuOption]: 16 | """Returns a list containing the menu option objects""" 17 | if not self.menu_category.category_options: 18 | raise ValueError("Missing menu options") 19 | else: 20 | return self.menu_category.category_options 21 | 22 | 23 | class MultiValueMenuModel(MenuModel): 24 | def __init__(self, menu_category: MenuCategory): 25 | super().__init__(menu_category) 26 | 27 | def get_menu_options(self) -> List[MultiValueMenuOption]: 28 | """Returns a list containing the menu option objects""" 29 | return super().get_menu_options() 30 | -------------------------------------------------------------------------------- /src/cli_chess/modules/premove/premove_presenter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.modules.premove import PremoveView 3 | from typing import TYPE_CHECKING 4 | if TYPE_CHECKING: 5 | from cli_chess.modules.premove import PremoveModel 6 | 7 | 8 | class PremovePresenter: 9 | def __init__(self, model: PremoveModel): 10 | self.model = model 11 | self.view = PremoveView(self) 12 | self.model.e_premove_model_updated.add_listener(self.update) 13 | 14 | def update(self, *args, **kwargs) -> None: 15 | """Updates the view based on specific model updates""" 16 | self.view.update(self.model.premove) 17 | 18 | def set_premove(self, move: str) -> None: 19 | if move: 20 | return self.model.set_premove(move) 21 | 22 | def pop_premove(self) -> str: 23 | """Returns the set premove, but also clears it after""" 24 | return self.model.pop_premove() 25 | 26 | def clear_premove(self) -> None: 27 | self.model.clear_premove() 28 | 29 | def is_premove_set(self) -> bool: 30 | """Returns True if a premove is set""" 31 | return bool(self.model.premove) 32 | -------------------------------------------------------------------------------- /src/cli_chess/menus/online_games_menu/online_games_menu_presenter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.menus import MenuPresenter 3 | from cli_chess.menus.online_games_menu import OnlineGamesMenuView 4 | from cli_chess.menus.versus_menus import OnlineVsComputerMenuModel, OnlineVsRandomOpponentMenuModel, OnlineVersusMenuPresenter 5 | from cli_chess.menus.tv_channel_menu import TVChannelMenuModel, TVChannelMenuPresenter 6 | from typing import TYPE_CHECKING 7 | if TYPE_CHECKING: 8 | from cli_chess.menus.online_games_menu import OnlineGamesMenuModel 9 | 10 | 11 | class OnlineGamesMenuPresenter(MenuPresenter): 12 | """Defines the online games menu""" 13 | def __init__(self, model: OnlineGamesMenuModel): 14 | self.model = model 15 | self.vs_random_opponent_menu_presenter = OnlineVersusMenuPresenter(OnlineVsRandomOpponentMenuModel(), is_vs_ai=False) 16 | self.vs_computer_menu_presenter = OnlineVersusMenuPresenter(OnlineVsComputerMenuModel(), is_vs_ai=True) 17 | self.tv_channel_menu_presenter = TVChannelMenuPresenter(TVChannelMenuModel()) 18 | self.view = OnlineGamesMenuView(self) 19 | super().__init__(self.model, self.view) 20 | -------------------------------------------------------------------------------- /src/cli_chess/modules/clock/clock_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from prompt_toolkit.layout import Window, FormattedTextControl, WindowAlign, D 3 | from prompt_toolkit.widgets import Box 4 | from typing import TYPE_CHECKING 5 | if TYPE_CHECKING: 6 | from cli_chess.modules.clock import ClockPresenter 7 | 8 | 9 | class ClockView: 10 | def __init__(self, presenter: ClockPresenter, initial_time_str: str): 11 | self.presenter = presenter 12 | self.time_str = initial_time_str 13 | self._clock_control = FormattedTextControl(text=lambda: self.time_str, style="class:clock") 14 | self._container = Box(Window(self._clock_control, align=WindowAlign.LEFT), padding=0, padding_right=1, height=D(max=1)) 15 | 16 | def update(self, time: str, is_ticking: bool) -> None: 17 | """Updates the clock using the data passed in""" 18 | self.time_str = time 19 | if is_ticking: 20 | self._clock_control.style = "class:clock.ticking" 21 | else: 22 | self._clock_control.style = "class:clock" 23 | 24 | def __pt_container__(self) -> Box: 25 | """Returns this views container""" 26 | return self._container 27 | -------------------------------------------------------------------------------- /src/cli_chess/core/api/api_manager.py: -------------------------------------------------------------------------------- 1 | from cli_chess.core.api.incoming_event_manger import IncomingEventManager 2 | from cli_chess.utils.logging import log 3 | from berserk import Client, TokenSession 4 | from typing import Optional 5 | 6 | required_token_scopes: set = {"board:play"} 7 | api_session: Optional[TokenSession] 8 | api_client: Optional[Client] 9 | api_iem: Optional[IncomingEventManager] 10 | api_ready = False 11 | 12 | 13 | def _start_api(token: str, base_url: str): 14 | """Handles creating a new API session, client, and IEM 15 | when the API token has been updated. This generally 16 | should only ever be called via the Token Manager on 17 | token verification. 18 | """ 19 | global api_session, api_client, api_iem, api_ready 20 | try: 21 | api_session = TokenSession(token) 22 | api_client = Client(api_session, base_url) 23 | api_iem = IncomingEventManager() 24 | api_iem.start() 25 | api_ready = True 26 | except Exception as e: 27 | log.exception(f"Failed to start api: {e}") 28 | 29 | 30 | def api_is_ready() -> bool: 31 | """Check the status of the api connection. Currently, 32 | this is used for toggling the online menu availability 33 | """ 34 | return api_ready 35 | -------------------------------------------------------------------------------- /src/cli_chess/menus/main_menu/main_menu_presenter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.menus import MenuPresenter 3 | from cli_chess.menus.main_menu import MainMenuView 4 | from cli_chess.menus.online_games_menu import OnlineGamesMenuModel, OnlineGamesMenuPresenter 5 | from cli_chess.menus.offline_games_menu import OfflineGamesMenuModel, OfflineGamesMenuPresenter 6 | from cli_chess.menus.settings_menu import SettingsMenuModel, SettingsMenuPresenter 7 | from cli_chess.modules.about import AboutPresenter 8 | from typing import TYPE_CHECKING 9 | if TYPE_CHECKING: 10 | from cli_chess.menus.main_menu import MainMenuModel 11 | 12 | 13 | class MainMenuPresenter(MenuPresenter): 14 | """Defines the Main Menu""" 15 | def __init__(self, model: MainMenuModel): 16 | self.model = model 17 | self.online_games_menu_presenter = OnlineGamesMenuPresenter(OnlineGamesMenuModel()) 18 | self.offline_games_menu_presenter = OfflineGamesMenuPresenter(OfflineGamesMenuModel()) 19 | self.settings_menu_presenter = SettingsMenuPresenter(SettingsMenuModel()) 20 | self.about_presenter = AboutPresenter() 21 | self.view = MainMenuView(self) 22 | self.selection = self.model.get_menu_options()[0].option 23 | 24 | super().__init__(self.model, self.view) 25 | -------------------------------------------------------------------------------- /src/cli_chess/menus/versus_menus/versus_menu_views.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.menus import MultiValueMenuView 3 | from cli_chess.utils.ui_common import handle_mouse_click, handle_bound_key_pressed 4 | from prompt_toolkit.key_binding import KeyBindings 5 | from prompt_toolkit.keys import Keys 6 | from prompt_toolkit.formatted_text import StyleAndTextTuples 7 | from typing import TYPE_CHECKING 8 | if TYPE_CHECKING: 9 | from cli_chess.menus.versus_menus.versus_menu_presenters import VersusMenuPresenter 10 | 11 | 12 | class VersusMenuView(MultiValueMenuView): 13 | def __init__(self, presenter: VersusMenuPresenter): 14 | self.presenter = presenter 15 | super().__init__(self.presenter, container_width=38, column_width=18) 16 | 17 | def get_function_bar_fragments(self) -> StyleAndTextTuples: 18 | return [ 19 | ("class:function-bar.key", "F1", handle_mouse_click(self.presenter.handle_start_game)), 20 | ("class:function-bar.label", f"{'Start game':<14}", handle_mouse_click(self.presenter.handle_start_game)), 21 | ] 22 | 23 | def get_function_bar_key_bindings(self) -> KeyBindings: 24 | """Creates the key bindings associated to the function bar fragments""" 25 | kb = KeyBindings() 26 | kb.add(Keys.F1)(handle_bound_key_pressed(self.presenter.handle_start_game)) 27 | return kb 28 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | long_description = file: README.md 3 | long_description_content_type = text/markdown 4 | keywords = chess, terminal, fairy-stockfish, stockfish, lichess, lichess.org, cli, san, uci 5 | license_files = LICENSE 6 | classifiers = 7 | License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) 8 | Operating System :: POSIX :: Linux 9 | Operating System :: Microsoft :: Windows 10 | Operating System :: MacOS 11 | Environment :: Console 12 | Programming Language :: Python :: 3 :: Only 13 | Programming Language :: Python :: 3.8 14 | Programming Language :: Python :: 3.9 15 | Programming Language :: Python :: 3.10 16 | Programming Language :: Python :: 3.11 17 | Topic :: Games/Entertainment :: Board Games 18 | Topic :: Games/Entertainment :: Turn Based Strategy 19 | Intended Audience :: End Users/Desktop 20 | Intended Audience :: Developers 21 | Natural Language :: English 22 | 23 | [options] 24 | packages = find: 25 | package_dir = = src 26 | python_requires = >= 3.8 27 | include_package_data = True 28 | zip_safe = False 29 | 30 | [options.packages.find] 31 | where = src 32 | 33 | [options.entry_points] 34 | console_scripts = 35 | cli-chess = cli_chess.__main__:main 36 | 37 | [flake8] 38 | max-line-length = 150 39 | per-file-ignores = 40 | */__init__.py: F401 41 | 42 | [tool:pytest] 43 | filterwarnings = error 44 | addopts = --disable-socket 45 | -------------------------------------------------------------------------------- /src/cli_chess/tests/modules/token_manager/test_token_manager_presenter.py: -------------------------------------------------------------------------------- 1 | from cli_chess.modules.token_manager import TokenManagerModel, TokenManagerPresenter 2 | from cli_chess.utils.config import LichessConfig 3 | from os import remove 4 | import pytest 5 | 6 | 7 | @pytest.fixture 8 | def model(monkeypatch, lichess_config): 9 | monkeypatch.setattr('cli_chess.modules.token_manager.token_manager_model.lichess_config', lichess_config) 10 | return TokenManagerModel() 11 | 12 | 13 | @pytest.fixture 14 | def presenter(model: TokenManagerModel): 15 | return TokenManagerPresenter(model) 16 | 17 | 18 | @pytest.fixture 19 | def lichess_config(): 20 | lichess_config = LichessConfig("unit_test_config.ini") 21 | yield lichess_config 22 | remove(lichess_config.full_filename) 23 | 24 | 25 | def mock_success_test_tokens(*args): # noqa 26 | return { 27 | 'lip_validToken': { 28 | 'scopes': 'board:play,challenge:read,challenge:write', 29 | 'userId': 'testUser', 30 | 'expires': None 31 | } 32 | } 33 | 34 | 35 | def test_update(model: TokenManagerModel, presenter: TokenManagerPresenter, lichess_config: LichessConfig): 36 | # Verify this method is listening to model updates 37 | assert presenter.update in model.e_token_manager_model_updated.listeners 38 | model.save_account_data(api_token="lip_validToken", account_data=mock_success_test_tokens()) 39 | assert presenter.view.lichess_username == model.linked_account 40 | -------------------------------------------------------------------------------- /src/cli_chess/modules/player_info/player_info_presenter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.modules.player_info import PlayerInfoView 3 | from cli_chess.utils import EventTopics 4 | from chess import Color 5 | from typing import TYPE_CHECKING 6 | if TYPE_CHECKING: 7 | from cli_chess.core.game import GameModelBase 8 | from cli_chess.core.game import PlayerMetadata 9 | 10 | 11 | class PlayerInfoPresenter: 12 | def __init__(self, model: GameModelBase): 13 | self.model = model 14 | 15 | orientation = self.model.board_model.get_board_orientation() 16 | self.view_upper = PlayerInfoView(self, self.model.game_metadata.players[not orientation]) 17 | self.view_lower = PlayerInfoView(self, self.model.game_metadata.players[orientation]) 18 | 19 | self.model.e_game_model_updated.add_listener(self.update) 20 | 21 | def update(self, *args, **kwargs) -> None: 22 | """Updates the view based on specific model updates""" 23 | if any(e in args for e in [EventTopics.GAME_START, EventTopics.GAME_END, EventTopics.BOARD_ORIENTATION_CHANGED]): 24 | orientation = self.model.board_model.get_board_orientation() 25 | self.view_upper.update(self.get_player_info(not orientation)) 26 | self.view_lower.update(self.get_player_info(orientation)) 27 | 28 | def get_player_info(self, color: Color) -> PlayerMetadata: 29 | """Returns the player metadata for the passed in color""" 30 | return self.model.game_metadata.players[color] 31 | -------------------------------------------------------------------------------- /src/cli_chess/modules/token_manager/token_manager_presenter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.modules.token_manager import TokenManagerView 3 | from cli_chess.utils.common import open_url_in_browser 4 | from cli_chess.core.api.api_manager import required_token_scopes 5 | from typing import TYPE_CHECKING 6 | if TYPE_CHECKING: 7 | from cli_chess.modules.token_manager import TokenManagerModel 8 | 9 | 10 | class TokenManagerPresenter: 11 | def __init__(self, model: TokenManagerModel): 12 | self.model = model 13 | self.view = TokenManagerView(self) 14 | self.model.e_token_manager_model_updated.add_listener(self.update) 15 | self.model.validate_existing_linked_account() 16 | 17 | def update(self): 18 | """Updates the token manager view""" 19 | self.view.lichess_username = self.model.linked_account 20 | 21 | def update_linked_account(self, api_token: str) -> bool: 22 | """Calls the model to test api token validity. If the token is 23 | deemed valid, it is saved to the configuration file 24 | """ 25 | return self.model.update_linked_account(api_token) 26 | 27 | def open_token_creation_url(self) -> None: 28 | """Open the URL to create a Lichess API token""" 29 | url = f"{self.model.base_url}/account/oauth/token/create?" 30 | 31 | for scope in required_token_scopes: 32 | url = url + f"scopes[]={scope}&" 33 | 34 | url = url + "description=cli-chess+token" 35 | open_url_in_browser(url) 36 | -------------------------------------------------------------------------------- /src/cli_chess/core/game/game_metadata.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from chess import Color, COLORS 3 | from typing import Optional 4 | 5 | 6 | @dataclass 7 | class PlayerMetadata: 8 | title: Optional[str] = None 9 | name: Optional[str] = None 10 | rating: Optional[str] = None 11 | rating_diff: Optional[int] = None 12 | is_provisional_rating: bool = False 13 | ai_level: Optional[str] = None 14 | 15 | 16 | @dataclass 17 | class ClockMetadata: 18 | units: str = "ms" 19 | time: Optional[int] = None 20 | increment: Optional[int] = None 21 | ticking: bool = False 22 | 23 | 24 | @dataclass 25 | class GameStatusMetadata: 26 | status: Optional[str] = None 27 | winner: Optional[str] = None 28 | 29 | 30 | class GameMetadata: 31 | def __init__(self): 32 | self.players = [PlayerMetadata(), PlayerMetadata()] 33 | self.clocks = [ClockMetadata(), ClockMetadata()] 34 | self.game_status = GameStatusMetadata() 35 | self.game_id: Optional[str] = None 36 | self.variant: Optional[str] = None 37 | self.my_color: Optional[Color] = None 38 | self.rated: bool = False 39 | self.speed: Optional[str] = None 40 | 41 | def set_clock_ticking(self, color: Optional[Color]): 42 | if color is None: 43 | for c in COLORS: 44 | self.clocks[c].ticking = False 45 | else: 46 | self.clocks[color].ticking = True 47 | self.clocks[not color].ticking = False 48 | 49 | def reset(self): 50 | self.__init__() 51 | -------------------------------------------------------------------------------- /src/cli_chess/core/game/online_game/watch_tv/watch_tv_presenter.py: -------------------------------------------------------------------------------- 1 | from cli_chess.core.game import GamePresenterBase 2 | from cli_chess.core.game.online_game.watch_tv import WatchTVModel, WatchTVView 3 | from cli_chess.menus.tv_channel_menu import TVChannelMenuOptions 4 | from cli_chess.utils.ui_common import change_views 5 | from cli_chess.utils import AlertType, EventTopics 6 | 7 | 8 | def start_watching_tv(channel: TVChannelMenuOptions) -> None: 9 | presenter = WatchTVPresenter(WatchTVModel(channel)) 10 | change_views(presenter.view, presenter.view.move_list_placeholder) # noqa 11 | 12 | 13 | class WatchTVPresenter(GamePresenterBase): 14 | def __init__(self, model: WatchTVModel): 15 | self.model = model 16 | super().__init__(model) 17 | 18 | self.model.start_watching() 19 | 20 | def _get_view(self) -> WatchTVView: 21 | """Sets and returns the view to use""" 22 | return WatchTVView(self) 23 | 24 | def update(self, *args, **kwargs) -> None: 25 | """Update method called on game model updates. Overrides base.""" 26 | super().update(*args, **kwargs) 27 | if EventTopics.GAME_SEARCH in args: 28 | self.view.alert.show_alert("Searching for TV game...", AlertType.NEUTRAL) 29 | if EventTopics.ERROR in args: 30 | self.view.alert.show_alert(kwargs.get('msg', "An unspecified TV error has occurred"), AlertType.ERROR) 31 | 32 | def exit(self) -> None: 33 | """Stops TV and returns to the main menu""" 34 | self.model.stop_watching() 35 | super().exit() 36 | -------------------------------------------------------------------------------- /src/cli_chess/core/game/offline_game/offline_game_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.core.game import PlayableGameViewBase 3 | from prompt_toolkit.layout import Container, HSplit, VSplit, VerticalAlign 4 | from prompt_toolkit.widgets import Box 5 | from typing import TYPE_CHECKING 6 | if TYPE_CHECKING: 7 | from cli_chess.core.game.offline_game import OfflineGamePresenter 8 | 9 | 10 | class OfflineGameView(PlayableGameViewBase): 11 | def __init__(self, presenter: OfflineGamePresenter): 12 | self.presenter = presenter 13 | super().__init__(presenter) 14 | 15 | def _create_container(self) -> Container: 16 | main_content = Box( 17 | HSplit([ 18 | VSplit([ 19 | self.board_output_container, 20 | Box(HSplit([ 21 | self.player_info_upper_container, 22 | self.material_diff_upper_container, 23 | self.move_list_container, 24 | self.material_diff_lower_container, 25 | self.player_info_lower_container, 26 | ]), padding=0, padding_top=1) 27 | ]), 28 | self.input_field_container, 29 | self.premove_container, 30 | self.alert, 31 | ]), 32 | padding=0 33 | ) 34 | function_bar = HSplit([ 35 | self._create_function_bar() 36 | ], align=VerticalAlign.BOTTOM) 37 | 38 | return HSplit([main_content, function_bar], key_bindings=self.get_key_bindings()) 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: '3.11' 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -e .[dev] 31 | 32 | - name: Run flake8 33 | run: | 34 | echo "$PWD" 35 | flake8 . --config=setup.cfg --count --show-source --statistics 36 | 37 | test: 38 | name: Test 39 | needs: lint 40 | runs-on: ${{ matrix.os }} 41 | strategy: 42 | matrix: 43 | os: [ubuntu-latest, windows-latest, macos-latest] 44 | python-version: ["3.8", "3.9", "3.10", "3.11"] 45 | permissions: 46 | contents: read 47 | 48 | steps: 49 | - name: Checkout 50 | uses: actions/checkout@v3 51 | 52 | - name: Set up Python ${{ matrix.python-version }} 53 | uses: actions/setup-python@v4 54 | with: 55 | python-version: ${{ matrix.python-version }} 56 | 57 | - name: Install dependencies 58 | run: | 59 | python -m pip install --upgrade pip 60 | pip install -e .[dev] 61 | 62 | - name: Run pytest 63 | run: | 64 | pytest 65 | -------------------------------------------------------------------------------- /src/cli_chess/menus/program_settings_menu/program_settings_menu_presenter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.menus.program_settings_menu import ProgramSettingsMenuView 3 | from cli_chess.menus import MultiValueMenuPresenter 4 | from cli_chess.utils.config import TerminalConfig 5 | from cli_chess.utils.common import COLOR_DEPTH_MAP 6 | from cli_chess.utils.ui_common import set_color_depth 7 | from typing import TYPE_CHECKING 8 | if TYPE_CHECKING: 9 | from cli_chess.menus.program_settings_menu import ProgramSettingsMenuModel 10 | 11 | 12 | class ProgramSettingsMenuPresenter(MultiValueMenuPresenter): 13 | """Defines the presenter for the program settings menu""" 14 | def __init__(self, model: ProgramSettingsMenuModel): 15 | self.model = model 16 | self.view = ProgramSettingsMenuView(self) 17 | super().__init__(self.model, self.view) 18 | 19 | def value_cycled_handler(self, selected_option: int): 20 | """A handler that's called when the value of the selected option changed""" 21 | menu_item = self.model.get_menu_options()[selected_option] 22 | selected_option = menu_item.option 23 | selected_value = menu_item.selected_value['name'] 24 | 25 | if selected_option == TerminalConfig.Keys.TERMINAL_COLOR_DEPTH: 26 | color_depth = list(COLOR_DEPTH_MAP.keys())[list(COLOR_DEPTH_MAP.values()).index(selected_value)] 27 | self.model.save_terminal_color_depth_setting(color_depth) 28 | set_color_depth(color_depth) 29 | else: 30 | self.model.save_selected_game_config_setting(selected_option, selected_value == "Yes") 31 | -------------------------------------------------------------------------------- /src/cli_chess/modules/premove/premove_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from prompt_toolkit.layout import Container, ConditionalContainer, VSplit, D, Window, FormattedTextControl, WindowAlign 3 | from prompt_toolkit.filters import Condition 4 | from prompt_toolkit.widgets import Box 5 | from typing import TYPE_CHECKING 6 | if TYPE_CHECKING: 7 | from cli_chess.modules.premove import PremovePresenter 8 | 9 | 10 | class PremoveView: 11 | def __init__(self, presenter: PremovePresenter): 12 | self.presenter = presenter 13 | self.premove = "" 14 | self._premove_control = FormattedTextControl(text=lambda: "Premove: " + self.premove, style="class:pre-move") 15 | self._container = self._create_container() 16 | 17 | def _create_container(self) -> Container: 18 | return ConditionalContainer( 19 | VSplit([ 20 | ConditionalContainer( 21 | Box(Window(self._premove_control, align=WindowAlign.LEFT, dont_extend_width=True), padding=0, padding_right=1), 22 | Condition(lambda: False if not self.premove else True) 23 | ) 24 | ], width=D(min=1), height=D(max=1), window_too_small=ConditionalContainer(Window(), False)), 25 | Condition(lambda: False if not self.premove else True) 26 | ) 27 | 28 | def update(self, premove: str) -> None: 29 | """Updates the pre-move text display with the pre-move passed in""" 30 | self.premove = premove if premove else "" 31 | 32 | def __pt_container__(self) -> Container: 33 | """Returns this views container""" 34 | return self._container 35 | -------------------------------------------------------------------------------- /src/cli_chess/core/game/online_game/online_game_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.core.game import PlayableGameViewBase 3 | from prompt_toolkit.layout import Container, HSplit, VSplit, VerticalAlign 4 | from prompt_toolkit.widgets import Box 5 | from typing import TYPE_CHECKING 6 | if TYPE_CHECKING: 7 | from cli_chess.core.game.online_game import OnlineGamePresenter 8 | 9 | 10 | class OnlineGameView(PlayableGameViewBase): 11 | def __init__(self, presenter: OnlineGamePresenter): 12 | self.presenter = presenter 13 | super().__init__(presenter) 14 | 15 | def _create_container(self) -> Container: 16 | main_content = Box( 17 | HSplit([ 18 | VSplit([ 19 | self.board_output_container, 20 | HSplit([ 21 | self.clock_upper, 22 | self.player_info_upper_container, 23 | self.material_diff_upper_container, 24 | self.move_list_container, 25 | self.material_diff_lower_container, 26 | self.player_info_lower_container, 27 | self.clock_lower 28 | ]) 29 | ]), 30 | self.input_field_container, 31 | self.premove_container, 32 | self.alert, 33 | ]), 34 | padding=0 35 | ) 36 | function_bar = HSplit([ 37 | self._create_function_bar() 38 | ], align=VerticalAlign.BOTTOM) 39 | 40 | return HSplit([main_content, function_bar], key_bindings=self.get_key_bindings()) 41 | -------------------------------------------------------------------------------- /src/cli_chess/core/main/main_presenter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.core.main.main_view import MainView 3 | from cli_chess.menus.main_menu import MainMenuModel, MainMenuPresenter 4 | from cli_chess.core.api.api_manager import required_token_scopes 5 | from cli_chess.modules.token_manager.token_manager_model import g_token_manager_model 6 | from cli_chess.utils import force_recreate_configs, print_program_config 7 | from typing import TYPE_CHECKING 8 | if TYPE_CHECKING: 9 | from cli_chess.core.main import MainModel 10 | 11 | 12 | class MainPresenter: 13 | def __init__(self, model: MainModel): 14 | self.model = model 15 | self._handle_startup_args() 16 | self.main_menu_presenter = MainMenuPresenter(MainMenuModel()) 17 | self.view = MainView(self) 18 | 19 | def _handle_startup_args(self): 20 | """Handles the arguments passed""" 21 | args = self.model.startup_args 22 | 23 | if args.print_config: 24 | print_program_config() 25 | exit(0) 26 | 27 | if args.reset_config: 28 | force_recreate_configs() 29 | print("Configuration successfully reset") 30 | exit(0) 31 | 32 | if args.base_url: 33 | g_token_manager_model.set_base_url(args.base_url) 34 | 35 | if args.token: 36 | if not g_token_manager_model.update_linked_account(args.token): 37 | print(f"Invalid API token or missing required scopes. Scopes required: {required_token_scopes}") 38 | exit(1) 39 | 40 | def run(self): 41 | """Starts the main application""" 42 | self.view.run() 43 | -------------------------------------------------------------------------------- /src/cli_chess/utils/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | log = logging.getLogger("cli-chess") 4 | log_redactions = [] 5 | 6 | 7 | def configure_logger(name: str, level=logging.DEBUG) -> logging.Logger: 8 | """Configures and returns a logger instance""" 9 | from cli_chess.utils.config import get_config_path 10 | 11 | log_file = f"{get_config_path()}" + f"{name}.log" 12 | log_format = "%(asctime)s.%(msecs)03d | %(levelname)-5s | %(name)s | %(module)s.%(funcName)s | %(message)s" 13 | time_format = "%m/%d/%Y %I:%M:%S" 14 | 15 | file_handler = logging.FileHandler(log_file, mode="w") 16 | file_handler.setFormatter(LoggingRedactor(log_format, time_format)) 17 | 18 | logger = logging.getLogger(name) 19 | logger.setLevel(level) 20 | logger.addHandler(file_handler) 21 | 22 | return logger 23 | 24 | 25 | def redact_from_logs(text: str = "") -> None: 26 | """Adds the passed in text to the log redaction list""" 27 | text = text.strip() 28 | if text and text not in log_redactions: 29 | log_redactions.append(text) 30 | 31 | 32 | class LoggingRedactor(logging.Formatter): 33 | """Log formatter that redacts matches from being logged. 34 | Loops through the `log_redactions` list and replaces the 35 | text before outputting it to the log (eg. API keys) 36 | """ 37 | @staticmethod 38 | def _filter(text): 39 | redacted_text = text 40 | for item in log_redactions: 41 | redacted_text = redacted_text.replace(item, "********") 42 | return redacted_text 43 | 44 | def format(self, log_record): 45 | text = logging.Formatter.format(self, log_record) 46 | return self._filter(text) 47 | -------------------------------------------------------------------------------- /src/cli_chess/core/game/online_game/watch_tv/watch_tv_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.core.game import GameViewBase 3 | from prompt_toolkit.layout import Container, Window, VSplit, HSplit, VerticalAlign, D 4 | from prompt_toolkit.widgets import Box 5 | from typing import TYPE_CHECKING 6 | if TYPE_CHECKING: 7 | from cli_chess.core.game.online_game.watch_tv import WatchTVPresenter 8 | 9 | 10 | class WatchTVView(GameViewBase): 11 | def __init__(self, presenter: WatchTVPresenter): 12 | self.presenter = presenter 13 | self.move_list_placeholder = Window(always_hide_cursor=True) 14 | super().__init__(presenter) 15 | 16 | def _create_container(self) -> Container: 17 | """Creates the container for the TV view""" 18 | main_content = Box( 19 | HSplit([ 20 | VSplit([ 21 | self.board_output_container, 22 | HSplit([ 23 | self.clock_upper, 24 | self.player_info_upper_container, 25 | self.material_diff_upper_container, 26 | Box(self.move_list_placeholder, height=D(min=1, max=4)), 27 | self.material_diff_lower_container, 28 | self.player_info_lower_container, 29 | self.clock_lower 30 | ]), 31 | ]), 32 | self.alert 33 | ]), 34 | padding=0 35 | ) 36 | function_bar = HSplit([ 37 | self._create_function_bar() 38 | ], align=VerticalAlign.BOTTOM) 39 | 40 | return HSplit([main_content, function_bar], key_bindings=self.get_key_bindings()) 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Report a bug you found in cli-chess 3 | labels: ['bug'] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for reporting an issue. Before submitting, please [search existing issues](https://github.com/trevorbayless/cli-chess/issues) to confirm this is not a duplicate issue. 9 | Additionally, please make sure your cli-chess is up to date and the issue occurs in the latest version before reporting. 10 | - type: textarea 11 | id: steps 12 | attributes: 13 | label: Steps to reproduce the bug 14 | description: Provide precise step by step instructions on how to reproduce the bug 15 | placeholder: | 16 | 1. Navigate to ... 17 | 2. Press ... 18 | 3. ... 19 | validations: 20 | required: true 21 | - type: input 22 | attributes: 23 | label: Operating system 24 | description: Specify the OS you are using (e.g. Ubuntu 22.10 ) 25 | validations: 26 | required: true 27 | - type: input 28 | attributes: 29 | label: Terminal and version 30 | description: Specify the terminal and version you are using (e.g. Alacritty 0.12.0) 31 | validations: 32 | required: true 33 | - type: input 34 | attributes: 35 | label: cli-chess version 36 | description: Run `cli-chess -v` to get the version number 37 | validations: 38 | required: true 39 | - type: textarea 40 | attributes: 41 | label: Additional information 42 | description: | 43 | Provide any additional information that will give more context for the issue you are encountering. 44 | Screenshots and/or files can be added by clicking this area and then pasting or dragging them in. 45 | validations: 46 | required: false 47 | -------------------------------------------------------------------------------- /src/cli_chess/modules/material_difference/material_difference_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from prompt_toolkit.layout import Container, ConditionalContainer, Window, HSplit, D 3 | from prompt_toolkit.filters import to_filter 4 | from prompt_toolkit.widgets import TextArea 5 | from typing import TYPE_CHECKING 6 | if TYPE_CHECKING: 7 | from cli_chess.modules.material_difference import MaterialDifferencePresenter 8 | 9 | 10 | class MaterialDifferenceView: 11 | def __init__(self, presenter: MaterialDifferencePresenter, initial_diff: str, show: bool = True): 12 | self.presenter = presenter 13 | self.show = show 14 | self._diff_text_area = TextArea(text=initial_diff, 15 | style="class:material-difference", 16 | width=D(min=1), 17 | height=D(max=1), 18 | read_only=True, 19 | focusable=False, 20 | multiline=False, 21 | wrap_lines=False) 22 | self._container = self._create_container() 23 | 24 | def _create_container(self): 25 | """Creates the container for the material difference. Handles providing 26 | an empty container if the view should be hidden. This allows for 27 | the container display formatting to remain consistent 28 | """ 29 | return HSplit([ 30 | ConditionalContainer(self._diff_text_area, to_filter(self.show)), 31 | ConditionalContainer(Window(height=D(max=1)), to_filter(not self.show)) 32 | ]) 33 | 34 | def update(self, difference: str) -> None: 35 | """Updates the view output with the passed in text""" 36 | self._diff_text_area.text = difference 37 | 38 | def __pt_container__(self) -> Container: 39 | """Returns this views container""" 40 | return self._container 41 | -------------------------------------------------------------------------------- /src/cli_chess/menus/tv_channel_menu/tv_channel_menu_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.menus import MenuView 3 | from cli_chess.utils.ui_common import handle_mouse_click, handle_bound_key_pressed 4 | from prompt_toolkit.layout import Container, VSplit, HSplit 5 | from prompt_toolkit.key_binding import KeyBindings 6 | from prompt_toolkit.keys import Keys 7 | from prompt_toolkit.formatted_text import StyleAndTextTuples 8 | from prompt_toolkit.widgets import Box 9 | from typing import TYPE_CHECKING 10 | if TYPE_CHECKING: 11 | from cli_chess.menus.tv_channel_menu import TVChannelMenuPresenter 12 | 13 | 14 | class TVChannelMenuView(MenuView): 15 | def __init__(self, presenter: TVChannelMenuPresenter): 16 | self.presenter = presenter 17 | super().__init__(self.presenter, container_width=20) 18 | self._tv_channels_menu_container = self._create_tv_channels_menu() 19 | 20 | def _create_tv_channels_menu(self) -> Container: 21 | """Creates the container for the tv channels menu""" 22 | return HSplit([ 23 | VSplit([ 24 | Box(self._container, padding=0, padding_right=1), 25 | ]), 26 | ]) 27 | 28 | def get_function_bar_fragments(self) -> StyleAndTextTuples: 29 | """Returns the tv menu function bar fragments""" 30 | return [ 31 | ("class:function-bar.key", "F1", handle_mouse_click(self.presenter.handle_start_watching_tv)), 32 | ("class:function-bar.label", f"{'Watch channel':<14}", handle_mouse_click(self.presenter.handle_start_watching_tv)), 33 | ] 34 | 35 | def get_function_bar_key_bindings(self) -> KeyBindings: 36 | """Returns the function bar key bindings to use for the tv menu""" 37 | bindings = KeyBindings() 38 | bindings.add(Keys.F1)(handle_bound_key_pressed(self.presenter.handle_start_watching_tv)) 39 | return bindings 40 | 41 | def __pt_container__(self) -> Container: 42 | return self._tv_channels_menu_container 43 | -------------------------------------------------------------------------------- /src/cli_chess/modules/clock/clock_presenter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.modules.clock import ClockView 3 | from cli_chess.utils import EventTopics 4 | from chess import Color 5 | from datetime import datetime, timezone 6 | from typing import TYPE_CHECKING 7 | 8 | if TYPE_CHECKING: 9 | from cli_chess.core.game import GameModelBase 10 | 11 | 12 | class ClockPresenter: 13 | def __init__(self, model: GameModelBase): 14 | self.model = model 15 | 16 | orientation = self.model.board_model.get_board_orientation() 17 | self.view_upper = ClockView(self, self.get_clock_display(not orientation)) 18 | self.view_lower = ClockView(self, self.get_clock_display(orientation)) 19 | 20 | self.model.e_game_model_updated.add_listener(self.update) 21 | 22 | def update(self, *args, **kwargs) -> None: 23 | """Updates the view based on specific model updates""" 24 | if (EventTopics.GAME_START in args or EventTopics.GAME_END in args or 25 | EventTopics.MOVE_MADE in args or EventTopics.BOARD_ORIENTATION_CHANGED in args): 26 | orientation = self.model.board_model.get_board_orientation() 27 | self.view_upper.update(self.get_clock_display(not orientation), self.model.game_metadata.clocks[not orientation].ticking) 28 | self.view_lower.update(self.get_clock_display(orientation), self.model.game_metadata.clocks[orientation].ticking) 29 | 30 | def get_clock_display(self, color: Color) -> str: 31 | """Returns the formatted clock display for the color passed in""" 32 | clock_data = self.model.game_metadata.clocks[color] 33 | time = clock_data.time 34 | 35 | if time is None: 36 | return "--:--" 37 | 38 | if not isinstance(time, datetime): 39 | if clock_data.units == "ms": 40 | time = datetime.fromtimestamp(time / 1000, timezone.utc) 41 | elif clock_data.units == "sec": 42 | time = datetime.fromtimestamp(time, timezone.utc) 43 | 44 | return time.strftime("%M:%S") if not time.hour else time.strftime("%H:%M:%S") 45 | -------------------------------------------------------------------------------- /src/cli_chess/menus/tv_channel_menu/tv_channel_menu_model.py: -------------------------------------------------------------------------------- 1 | from cli_chess.menus import MenuModel, MenuOption, MenuCategory 2 | from cli_chess.core.game.game_options import OnlinePublicGameOptions 3 | from types import MappingProxyType 4 | from enum import Enum 5 | 6 | tv_key_dict = MappingProxyType({ 7 | "Top Rated": "best", 8 | "Ultra Bullet": "ultraBullet", 9 | "Bullet": "bullet", 10 | "Blitz": "blitz", 11 | "Rapid": "rapid", 12 | "Classical": "classical", 13 | "Crazyhouse": "crazyhouse", 14 | "Chess960": "chess960", 15 | "King of the Hill": "kingOfTheHill", 16 | "Three-check": "threeCheck", 17 | "Antichess": "antichess", 18 | "Atomic": "atomic", 19 | "Horde": "horde", 20 | "Racing Kings": "racingKings", 21 | "Bot": "bot", 22 | "Computer": "computer" 23 | }) 24 | 25 | 26 | class TVChannelMenuOptions(Enum): 27 | TOP_RATED = "Top Rated" 28 | ULTRABULLET = "Ultra Bullet" 29 | BULLET = "Bullet" 30 | BLITZ = "Blitz" 31 | RAPID = "Rapid" 32 | CLASSICAL = "Classical" 33 | CRAZYHOUSE = "Crazyhouse" 34 | CHESS960 = "Chess960" 35 | KING_OF_THE_HILL = "King of the Hill" 36 | THREE_CHECK = "Three-check" 37 | ANTICHESS = "Antichess" 38 | ATOMIC = "Atomic" 39 | HORDE = "Horde" 40 | RACING_KINGS = "Racing Kings" 41 | BOT = "Bot" 42 | COMPUTER = "Computer" 43 | 44 | @property 45 | def variant(self) -> str: 46 | """Return the chess variant related to the enum""" 47 | variant = OnlinePublicGameOptions.variant_options_dict.get(self.value) 48 | if not variant: 49 | variant = "standard" 50 | return variant 51 | 52 | @property 53 | def key(self) -> str: 54 | """Returns the channel key used in api transactions""" 55 | return tv_key_dict.get(self.value) 56 | 57 | 58 | class TVChannelMenuModel(MenuModel): 59 | def __init__(self): 60 | self.menu = self._create_menu() 61 | super().__init__(self.menu) 62 | 63 | @staticmethod 64 | def _create_menu() -> MenuCategory: 65 | """Create the menu options""" 66 | menu_options = [] 67 | for channel in TVChannelMenuOptions: 68 | menu_options.append(MenuOption(channel, "")) 69 | return MenuCategory("TV Channels", menu_options) 70 | -------------------------------------------------------------------------------- /src/cli_chess/modules/board/board_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.utils.ui_common import repaint_ui 3 | from prompt_toolkit.layout import Window, FormattedTextControl, D 4 | from prompt_toolkit.formatted_text import HTML 5 | from prompt_toolkit.widgets import Box 6 | from typing import TYPE_CHECKING 7 | if TYPE_CHECKING: 8 | from cli_chess.modules.board import BoardPresenter 9 | 10 | 11 | class BoardView: 12 | def __init__(self, presenter: BoardPresenter, initial_board_output: list): 13 | self.presenter = presenter 14 | self.board_output = FormattedTextControl(HTML(self._build_output(initial_board_output))) 15 | self._container = self._create_container() 16 | 17 | def _create_container(self): 18 | """Create the Board container""" 19 | return Box(Window( 20 | self.board_output, 21 | always_hide_cursor=True, 22 | width=D(max=18, preferred=18), 23 | height=D(max=9, preferred=9) 24 | ), padding=1) 25 | 26 | def _build_output(self, board_output_list: list) -> str: 27 | """Returns a string containing the board output to be used for 28 | display. The string returned will contain HTML elements""" 29 | board_output_str = "" 30 | 31 | for square in board_output_list: 32 | square_style = f"{square['square_display_color']}.{square['piece_display_color']}" 33 | piece_str = square['piece_str'] 34 | piece_str += " " if square['piece_str'] else " " 35 | 36 | board_output_str += f"{square['rank_label']}" 37 | board_output_str += f"<{square_style}>{piece_str}" 38 | 39 | if square['is_end_of_rank']: 40 | board_output_str += "\n" 41 | 42 | file_labels = " " + self.presenter.get_file_labels() 43 | board_output_str += f"{file_labels}" 44 | 45 | return board_output_str 46 | 47 | def update(self, board_output_list: list): 48 | """Updates the board output with the passed in text""" 49 | self.board_output.text = HTML(self._build_output(board_output_list)) 50 | repaint_ui() 51 | 52 | def __pt_container__(self) -> Box: 53 | """Returns this container""" 54 | return self._container 55 | -------------------------------------------------------------------------------- /src/cli_chess/utils/argparse.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from cli_chess.__metadata__ import __name__, __version__, __description__ 3 | from cli_chess.utils.logging import log, redact_from_logs 4 | from cli_chess.utils.config import get_config_path 5 | from cli_chess.core.api import required_token_scopes 6 | 7 | 8 | class ArgumentParser(argparse.ArgumentParser): 9 | """The main ArgumentParser class (inherits argparse.ArgumentParser). 10 | This allows for parsed arguments strings to be added to the log 11 | redactor in order to safely log parsed arguments (e.g. redacting API keys) 12 | """ 13 | def parse_args(self, args=None, namespace=None): 14 | """Override default parse_args method to allow for parsed 15 | arguments to be added to the log redactor 16 | """ 17 | arguments = super().parse_args(args, namespace) 18 | if arguments.token: 19 | redact_from_logs(arguments.token) 20 | 21 | log.debug(f"Parsed arguments: {arguments}") 22 | return arguments 23 | 24 | 25 | def setup_argparse() -> ArgumentParser: 26 | """Sets up argparse and parses the arguments passed in at startup""" 27 | parser = ArgumentParser(description=f"{__name__}: {__description__}") 28 | parser.add_argument( 29 | "--token", 30 | metavar="API_TOKEN", 31 | help=f"Links your Lichess API token with cli-chess. Scopes required: {required_token_scopes}", 32 | type=str 33 | ) 34 | parser.add_argument( 35 | "--reset-config", 36 | help="Force resets the cli-chess configuration. Reverts the program to its default state.", 37 | action="store_true" 38 | ) 39 | parser.add_argument( 40 | "-v", "--version", 41 | action="version", 42 | version=f"cli-chess v{__version__}", 43 | ) 44 | 45 | debug_group = parser.add_argument_group("debugging") 46 | debug_group.description = f"Program settings and logs can be found here: {get_config_path()}" 47 | debug_group.add_argument( 48 | "--base-url", 49 | metavar="URL", 50 | help="Point cli-chess requests to a different URL (e.g. http://localhost:8080)", 51 | default="https://lichess.org", 52 | type=str 53 | ) 54 | debug_group.add_argument( 55 | "--print-config", 56 | help="Prints the cli-chess configuration file to the terminal and exits.", 57 | action="store_true" 58 | ) 59 | 60 | return parser 61 | -------------------------------------------------------------------------------- /src/cli_chess/menus/menu_common.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import List, Union 3 | 4 | 5 | class MenuCategory: 6 | """Defines a menu with a list of associated options""" 7 | def __init__(self, title: str, category_options: Union[List["MenuOption"], List["MultiValueMenuOption"]]): 8 | self.title = title 9 | self.category_options = category_options 10 | 11 | 12 | class MenuOption: 13 | """A menu option that has one action on enter or click (e.g. Start game)""" 14 | def __init__(self, option: Enum, description: str, enabled: bool = True, visible: bool = True, display_name=""): 15 | self.option = option 16 | self.option_name = display_name if display_name else self.option.value 17 | self.description = description 18 | self.enabled = enabled 19 | self.visible = visible 20 | 21 | 22 | class MultiValueMenuOption(MenuOption): 23 | """A menu option which has multiple values to choose from (e.g. Side to play as) """ 24 | def __init__(self, option: Enum, description: str, values: List[str], enabled: bool = True, visible: bool = True, display_name=""): 25 | super().__init__(option, description, enabled, visible, display_name) 26 | self.values = values 27 | self.selected_value = { 28 | "index": self.values.index(self.values[0]), 29 | "name": self.values[0] 30 | } 31 | 32 | def next_value(self): 33 | """Set the next value as selected""" 34 | if self.enabled and self.visible: 35 | try: 36 | self.selected_value["index"] = self.selected_value["index"] + 1 37 | self.selected_value["name"] = self.values[self.selected_value["index"]] 38 | except IndexError: 39 | self.selected_value["index"] = self.values.index(self.values[0]) 40 | self.selected_value["name"] = self.values[self.selected_value["index"]] 41 | 42 | def previous_value(self): 43 | """Set the previous value as selected""" 44 | if self.enabled and self.visible: 45 | try: 46 | self.selected_value["index"] = self.selected_value["index"] - 1 47 | self.selected_value["name"] = self.values[self.selected_value["index"]] 48 | except IndexError: 49 | self.selected_value["index"] = self.values.index(self.values[-1]) 50 | self.selected_value["name"] = self.values[self.selected_value["index"]] 51 | -------------------------------------------------------------------------------- /src/cli_chess/menus/offline_games_menu/offline_games_menu_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.menus import MenuView 3 | from cli_chess.menus.offline_games_menu import OfflineGamesMenuOptions 4 | from prompt_toolkit.layout import Container, ConditionalContainer, VSplit, HSplit 5 | from prompt_toolkit.filters import Condition, is_done 6 | from prompt_toolkit.widgets import Box 7 | from prompt_toolkit.formatted_text import StyleAndTextTuples 8 | from prompt_toolkit.key_binding import ConditionalKeyBindings 9 | from typing import TYPE_CHECKING 10 | if TYPE_CHECKING: 11 | from cli_chess.menus.offline_games_menu import OfflineGamesMenuPresenter 12 | 13 | 14 | class OfflineGamesMenuView(MenuView): 15 | def __init__(self, presenter: OfflineGamesMenuPresenter): 16 | self.presenter = presenter 17 | super().__init__(self.presenter, container_width=18) 18 | self._offline_games_menu_container = self._create_offline_games_menu() 19 | 20 | def _create_offline_games_menu(self) -> Container: 21 | """Creates the container for the offline games menu""" 22 | return HSplit([ 23 | VSplit([ 24 | Box(self._container, padding=0, padding_right=1), 25 | ConditionalContainer( 26 | Box(self.presenter.vs_computer_menu_presenter.view, padding=0, padding_right=1), 27 | filter=~is_done 28 | & Condition(lambda: self.presenter.selection == OfflineGamesMenuOptions.VS_COMPUTER) 29 | ), 30 | ]) 31 | ]) 32 | 33 | def get_function_bar_fragments(self) -> StyleAndTextTuples: 34 | """Returns the appropriate function bar fragments based on menu item selection""" 35 | fragments: StyleAndTextTuples = [] 36 | if self.presenter.selection == OfflineGamesMenuOptions.VS_COMPUTER: 37 | fragments = self.presenter.vs_computer_menu_presenter.view.get_function_bar_fragments() 38 | return fragments 39 | 40 | def get_function_bar_key_bindings(self) -> ConditionalKeyBindings: 41 | """Returns the appropriate function bar key bindings based on menu item selection""" 42 | return ConditionalKeyBindings( 43 | self.presenter.vs_computer_menu_presenter.view.get_function_bar_key_bindings(), 44 | filter=Condition(lambda: self.presenter.selection == OfflineGamesMenuOptions.VS_COMPUTER) 45 | ) 46 | 47 | def __pt_container__(self) -> Container: 48 | return self._offline_games_menu_container 49 | -------------------------------------------------------------------------------- /src/cli_chess/utils/styles.py: -------------------------------------------------------------------------------- 1 | light_piece_color = "white" 2 | dark_piece_color = "black" 3 | 4 | default = { 5 | # Game styling 6 | "rank-label": "fg:gray", 7 | "file-label": "fg:gray", 8 | 9 | "light-square": "bg:cadetblue", 10 | "light-square.light-piece": f"fg:{light_piece_color}", 11 | "light-square.dark-piece": f"fg:{dark_piece_color}", 12 | 13 | "dark-square": "bg:darkslateblue", 14 | "dark-square.light-piece": f"fg:{light_piece_color}", 15 | "dark-square.dark-piece": f"fg:{dark_piece_color}", 16 | 17 | "last-move": "bg:yellowgreen", 18 | "last-move.light-piece": f"fg:{light_piece_color}", 19 | "last-move.dark-piece": f"fg:{dark_piece_color}", 20 | 21 | "pre-move": "bg:darkorange", 22 | "pre-move.light-piece": f"fg:{light_piece_color}", 23 | "pre-move.dark-piece": f"fg:{dark_piece_color}", 24 | 25 | "in-check": "bg:red", 26 | "in-check.light-piece": f"fg:{light_piece_color}", 27 | "in-check.dark-piece": f"fg:{dark_piece_color}", 28 | 29 | "material-difference": "fg:gray", 30 | "move-list": "fg:gray", 31 | "move-input": "fg:white bold", 32 | 33 | "player-info": "fg:white", 34 | "player-info.title": "fg:darkorange bold", 35 | "player-info.title.bot": "fg:darkmagenta", 36 | "player-info.pos-rating-diff": "fg:darkgreen", 37 | "player-info.neg-rating-diff": "fg:darkred", 38 | 39 | "clock": "fg:white", 40 | "clock.ticking": "bg:green", 41 | 42 | # Program styling 43 | "menu": "bg:", 44 | "menu.category-title": "fg:black bg:limegreen", 45 | "menu.option": "fg:white", 46 | "menu.multi-value": "fg:orangered", 47 | "focused-selected": "fg:black bg:mediumturquoise noinherit", 48 | "unfocused-selected": "fg:black bg:white noinherit", 49 | "menu.multi-value focused-selected": "fg:orangered bold noinherit", 50 | "menu.multi-value unfocused-selected": "fg:orangered noinherit", 51 | 52 | "function-bar.key": "fg:white", 53 | "function-bar.label": "fg:black bg:mediumturquoise", 54 | "function-bar.spacer": "", 55 | 56 | "label": "fg:white", 57 | "label.dim": "fg:dimgray", 58 | "label.success": "fg:darkgreen", 59 | "label.error": "fg:darkred", 60 | "label.success.banner": "bg:darkgreen fg:white", 61 | "label.error.banner": "bg:darkred fg:white", 62 | "label.neutral.banner": "bg:slategray fg:white", 63 | 64 | "text-area.input": "fg:orangered bold", 65 | "text-area.input.placeholder": "italic", 66 | "text-area.prompt": "fg:white bg:darkcyan bold noinherit", 67 | 68 | "validation-toolbar": "fg:white bg:darkred", 69 | } 70 | -------------------------------------------------------------------------------- /src/cli_chess/utils/event.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from enum import Enum, auto 3 | from typing import Callable, List 4 | 5 | 6 | class EventTopics(Enum): 7 | MOVE_MADE = auto() 8 | BOARD_ORIENTATION_CHANGED = auto() 9 | GAME_PARAMS = auto() 10 | GAME_SEARCH = auto() 11 | GAME_START = auto() 12 | GAME_END = auto() 13 | ERROR = auto() 14 | 15 | 16 | class Event: 17 | """Event notification class. This class creates a singular event instance 18 | which listeners can subscribe to with a callable. The callable will be 19 | notified when the event is triggered (using notify()). Generally, this 20 | class should not be instantiated directly, but rather from the EventManager class. 21 | """ 22 | def __init__(self): 23 | self.listeners = [] 24 | 25 | def add_listener(self, listener: Callable) -> None: 26 | """Adds the passed in listener to the notification list""" 27 | if listener not in self.listeners: 28 | self.listeners.append(listener) 29 | 30 | def remove_listener(self, listener: Callable) -> None: 31 | """Removes the passed in listener from the notification list""" 32 | if listener in self.listeners: 33 | self.listeners.remove(listener) 34 | 35 | def remove_all_listeners(self) -> None: 36 | """Removes all listeners associated to this event""" 37 | self.listeners.clear() 38 | 39 | def notify(self, *args, **kwargs) -> None: 40 | """Notifies all listeners of the event""" 41 | for listener in self.listeners: 42 | listener(*args, **kwargs) 43 | 44 | 45 | class EventManager: 46 | """Event manager class. Models which use events should create 47 | events using this manager for easier event maintenance 48 | """ 49 | def __init__(self): 50 | self._event_list: List[Event] = [] 51 | 52 | def create_event(self) -> Event: 53 | """Creates and returns a new event for listeners to subscribe to""" 54 | e = Event() 55 | self._event_list.append(e) 56 | return e 57 | 58 | def purge_all_event_listeners(self) -> None: 59 | """For each event associated to this event manager 60 | this method will clear all listeners 61 | """ 62 | for event in self._event_list: 63 | event.remove_all_listeners() 64 | 65 | def purge_all_events(self) -> None: 66 | """Purges all events in the event list by removing 67 | all associated events and listeners 68 | """ 69 | self.purge_all_event_listeners() 70 | self._event_list.clear() 71 | -------------------------------------------------------------------------------- /src/cli_chess/modules/move_list/move_list_model.py: -------------------------------------------------------------------------------- 1 | from cli_chess.modules.board import BoardModel 2 | from cli_chess.utils import EventManager, log 3 | from chess import piece_symbol 4 | from typing import List 5 | 6 | 7 | class MoveListModel: 8 | def __init__(self, board_model: BoardModel) -> None: 9 | self.board_model = board_model 10 | self.board_model.e_board_model_updated.add_listener(self.update) 11 | self.move_list_data = [] 12 | 13 | self._event_manager = EventManager() 14 | self.e_move_list_model_updated = self._event_manager.create_event() 15 | self.update() 16 | 17 | def update(self, *args, **kwargs) -> None: # noqa 18 | """Updates the move list data using the latest move stack""" 19 | self.move_list_data.clear() 20 | 21 | # The move replay board is used to generate the move list output 22 | # by replaying the move stack of the actual game on the replay board 23 | move_replay_board = self.board_model.board.copy() 24 | move_replay_board.set_fen(self.board_model.initial_fen) 25 | 26 | for move in self.board_model.get_move_stack(): 27 | piece_type = None 28 | if bool(move): 29 | piece_type = move_replay_board.piece_type_at(move.from_square) if not move.drop else move.drop 30 | 31 | try: 32 | san_move = move_replay_board.san(move) 33 | self.move_list_data.append({ 34 | 'turn': move_replay_board.turn, 35 | 'move': san_move, 36 | 'piece_type': piece_type, 37 | 'piece_symbol': piece_symbol(piece_type) if bool(move) else None, 38 | 'is_castling': move_replay_board.is_castling(move), 39 | 'is_promotion': True if move.promotion else False, 40 | }) 41 | move_replay_board.push_san(san_move) 42 | except ValueError as e: 43 | log.error(f"Error creating move list: {e}") 44 | log.error(f"Move list data: {self.board_model.get_move_stack()}") 45 | self.move_list_data.clear() 46 | break 47 | 48 | self._notify_move_list_model_updated() 49 | 50 | def get_move_list_data(self) -> List[dict]: 51 | """Returns the move list data""" 52 | return self.move_list_data 53 | 54 | def _notify_move_list_model_updated(self) -> None: 55 | """Notifies listeners of move list model updates""" 56 | self.e_move_list_model_updated.notify() 57 | 58 | def cleanup(self) -> None: 59 | """Handles model cleanup tasks. This should only ever 60 | be run when this model is no longer needed. 61 | """ 62 | self._event_manager.purge_all_events() 63 | -------------------------------------------------------------------------------- /src/cli_chess/menus/settings_menu/settings_menu_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.menus import MenuView 3 | from cli_chess.menus.settings_menu import SettingsMenuOptions 4 | from prompt_toolkit.layout import Container, ConditionalContainer, VSplit, HSplit 5 | from prompt_toolkit.filters import Condition, is_done 6 | from prompt_toolkit.key_binding import ConditionalKeyBindings 7 | from prompt_toolkit.formatted_text import StyleAndTextTuples 8 | from prompt_toolkit.widgets import Box 9 | from typing import TYPE_CHECKING 10 | if TYPE_CHECKING: 11 | from cli_chess.menus.settings_menu import SettingsMenuPresenter 12 | 13 | 14 | class SettingsMenuView(MenuView): 15 | def __init__(self, presenter: SettingsMenuPresenter): 16 | self.presenter = presenter 17 | super().__init__(self.presenter, container_width=27) 18 | self._settings_menu_container = self._create_settings_menu() 19 | 20 | def _create_settings_menu(self) -> Container: 21 | """Creates the container for the settings menu""" 22 | return HSplit([ 23 | VSplit([ 24 | Box(self._container, padding=0, padding_right=1), 25 | ConditionalContainer( 26 | Box(self.presenter.token_manger_presenter.view, padding=0, padding_right=1), 27 | filter=~is_done 28 | & Condition(lambda: self.presenter.selection == SettingsMenuOptions.LICHESS_AUTHENTICATION) 29 | ), 30 | ConditionalContainer( 31 | Box(self.presenter.program_settings_menu_presenter.view, padding=0, padding_right=1), 32 | filter=~is_done 33 | & Condition(lambda: self.presenter.selection == SettingsMenuOptions.PROGRAM_SETTINGS) 34 | ), 35 | ]), 36 | ]) 37 | 38 | def get_function_bar_fragments(self) -> StyleAndTextTuples: 39 | """Returns the appropriate function bar fragments based on menu item selection""" 40 | fragments: StyleAndTextTuples = [] 41 | if self.presenter.selection == SettingsMenuOptions.LICHESS_AUTHENTICATION: 42 | fragments = self.presenter.token_manger_presenter.view.get_function_bar_fragments() 43 | return fragments 44 | 45 | def get_function_bar_key_bindings(self) -> ConditionalKeyBindings: # noqa: F821 46 | """Returns the appropriate function bar key bindings based on menu item selection""" 47 | return ConditionalKeyBindings( 48 | self.presenter.token_manger_presenter.view.get_function_bar_key_bindings(), 49 | filter=Condition(lambda: self.presenter.selection == SettingsMenuOptions.LICHESS_AUTHENTICATION) 50 | ) 51 | 52 | def __pt_container__(self) -> Container: 53 | return self._settings_menu_container 54 | -------------------------------------------------------------------------------- /src/cli_chess/modules/move_list/move_list_presenter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.modules.move_list import MoveListView 3 | from cli_chess.modules.common import get_piece_unicode_symbol 4 | from cli_chess.utils.config import game_config 5 | from chess import BLACK, PAWN 6 | from typing import TYPE_CHECKING, List 7 | if TYPE_CHECKING: 8 | from cli_chess.modules.move_list import MoveListModel 9 | 10 | 11 | class MoveListPresenter: 12 | def __init__(self, model: MoveListModel): 13 | self.model = model 14 | self.view = MoveListView(self) 15 | 16 | self.model.e_move_list_model_updated.add_listener(self.update) 17 | game_config.e_game_config_updated.add_listener(self.update) 18 | 19 | def update(self) -> None: 20 | """Update the move list output""" 21 | self.view.update(self.get_formatted_move_list()) 22 | 23 | def get_formatted_move_list(self) -> List[str]: 24 | """Returns a list containing the formatted moves""" 25 | formatted_move_list = [] 26 | move_list_data = self.model.get_move_list_data() 27 | use_unicode = game_config.get_boolean(game_config.Keys.SHOW_MOVE_LIST_IN_UNICODE) 28 | pad_unicode = game_config.get_boolean(game_config.Keys.PAD_UNICODE) 29 | 30 | for entry in move_list_data: 31 | move = self.get_move_as_unicode(entry, pad_unicode) if use_unicode else (entry['move']) 32 | 33 | if entry['turn'] == BLACK: 34 | if not formatted_move_list: # The list starts with a move from black 35 | formatted_move_list.append("...") 36 | 37 | formatted_move_list.append(move) 38 | return formatted_move_list 39 | 40 | @staticmethod 41 | def get_move_as_unicode(move_data: dict, pad_unicode=False) -> str: 42 | """Returns the passed in move data in unicode representation""" 43 | output = "" 44 | move = move_data.get('move') 45 | if move: 46 | output = move 47 | if move_data['piece_type'] and move_data['piece_type'] != PAWN and not move_data['is_castling']: 48 | piece_unicode_symbol = get_piece_unicode_symbol(move_data['piece_symbol']) 49 | 50 | if piece_unicode_symbol and pad_unicode: 51 | # Pad unicode symbol with a space (if pad_unicode is true) to help unicode/ascii character overlap 52 | piece_unicode_symbol = piece_unicode_symbol + " " 53 | 54 | output = piece_unicode_symbol + move[1:] 55 | 56 | if move_data['is_promotion']: 57 | eq_index = output.find("=") 58 | if eq_index != -1: 59 | promotion_unicode_symbol = get_piece_unicode_symbol(output[eq_index+1]) 60 | output = output[:eq_index+1] + promotion_unicode_symbol + output[eq_index+2:] 61 | 62 | return output if output else move 63 | -------------------------------------------------------------------------------- /src/cli_chess/modules/move_list/move_list_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from prompt_toolkit.widgets import TextArea, Box 3 | from prompt_toolkit.key_binding import KeyBindings 4 | from prompt_toolkit.keys import Keys 5 | from prompt_toolkit.layout import D 6 | from typing import TYPE_CHECKING, List 7 | if TYPE_CHECKING: 8 | from cli_chess.modules.move_list import MoveListPresenter 9 | 10 | 11 | class MoveListView: 12 | def __init__(self, presenter: MoveListPresenter): 13 | self.presenter = presenter 14 | self._move_list_output = TextArea(text="No moves...", 15 | style="class:move-list", 16 | line_numbers=True, 17 | multiline=True, 18 | wrap_lines=False, 19 | focus_on_click=False, 20 | scrollbar=False, 21 | read_only=True) 22 | self.key_bindings = self._create_key_bindings() 23 | self._container = self._create_container() 24 | 25 | def _create_container(self) -> Box: 26 | """Create the move list container""" 27 | return Box(self._move_list_output, height=D(max=4), padding=0) 28 | 29 | def update(self, formatted_move_list: List[str]): 30 | """Loops through the passed in move list 31 | and updates the move list display 32 | """ 33 | output = "" 34 | for i, move in enumerate(formatted_move_list): 35 | if i % 2 == 0 and i != 0: 36 | output += "\n" 37 | output += move.ljust(8) 38 | 39 | self._move_list_output.text = output if output else "No moves..." 40 | self._scroll_to_bottom() 41 | 42 | def _scroll_to_bottom(self) -> None: 43 | """Scrolls the move list to the bottom""" 44 | line_count = self._move_list_output.buffer.document.line_count 45 | self._move_list_output.buffer.preferred_column = 0 46 | self._move_list_output.buffer.cursor_down(line_count) 47 | 48 | def _create_key_bindings(self) -> KeyBindings: 49 | """Create the key bindings for the move list""" 50 | bindings = KeyBindings() 51 | 52 | @bindings.add(Keys.Up) 53 | def _(event): # noqa 54 | self._move_list_output.buffer.cursor_up() 55 | 56 | @bindings.add(Keys.Down) 57 | def _(event): # noqa 58 | self._move_list_output.buffer.cursor_down() 59 | 60 | @bindings.add(Keys.PageUp) 61 | def _(event): # noqa 62 | self._move_list_output.buffer.preferred_column = 0 63 | self._move_list_output.buffer.cursor_position = 0 64 | 65 | @bindings.add(Keys.PageDown) 66 | def _(event): # noqa 67 | self._scroll_to_bottom() 68 | 69 | return bindings 70 | 71 | def __pt_container__(self) -> Box: 72 | """Returns the move_list container""" 73 | return self._container 74 | -------------------------------------------------------------------------------- /src/cli_chess/core/api/incoming_event_manger.py: -------------------------------------------------------------------------------- 1 | from cli_chess.utils.event import Event, EventTopics 2 | from cli_chess.utils.logging import log 3 | from typing import Callable 4 | from enum import Enum, auto 5 | from types import MappingProxyType 6 | import threading 7 | 8 | 9 | class IEMEventTopics(Enum): 10 | CHALLENGE = auto() # A challenge sent by us or to us 11 | CHALLENGE_CANCELLED = auto() 12 | CHALLENGE_DECLINED = auto() 13 | NOT_IMPLEMENTED = auto() 14 | 15 | 16 | iem_type_to_event_dict = MappingProxyType({ 17 | "gameStart": EventTopics.GAME_START, 18 | "gameFinish": EventTopics.GAME_END, 19 | "challenge": IEMEventTopics.CHALLENGE, 20 | "challengeCanceled": IEMEventTopics.CHALLENGE_CANCELLED, 21 | "challengeDeclined": IEMEventTopics.CHALLENGE_DECLINED, 22 | }) 23 | 24 | 25 | class IncomingEventManager(threading.Thread): 26 | """Opens a stream and keeps track of Lichess incoming 27 | events (such as game start, game finish). 28 | """ 29 | 30 | def __init__(self): 31 | super().__init__(daemon=True) 32 | self.e_new_event_received = Event() 33 | self.my_games = [] 34 | 35 | def run(self) -> None: 36 | try: 37 | from cli_chess.core.api.api_manager import api_client 38 | except ImportError: 39 | # TODO: Clean this up so the error is displayed on the main screen 40 | log.error("Failed to import api_client") 41 | raise ImportError("API client not setup. Do you have an API token linked?") 42 | 43 | log.info("Started listening to Lichess incoming events") 44 | for event in api_client.board.stream_incoming_events(): 45 | data = None 46 | event_topic = iem_type_to_event_dict.get(event['type'], IEMEventTopics.NOT_IMPLEMENTED) 47 | log.debug(f"IEM event received: {event}") 48 | 49 | if event_topic is EventTopics.GAME_START: 50 | data = event['game'] 51 | self.my_games.append(data['gameId']) 52 | 53 | elif event_topic is EventTopics.GAME_END: 54 | try: 55 | data = event['game'] 56 | self.my_games.remove(data['gameId']) 57 | except ValueError: 58 | pass 59 | 60 | elif (event_topic is IEMEventTopics.CHALLENGE or 61 | event_topic is IEMEventTopics.CHALLENGE_CANCELLED or 62 | event_topic is IEMEventTopics.CHALLENGE_DECLINED): 63 | data = event['challenge'] 64 | 65 | self.e_new_event_received.notify(event_topic, data=data) 66 | 67 | def get_active_games(self) -> list: 68 | """Returns a list of games in progress for this account""" 69 | return self.my_games 70 | 71 | def add_event_listener(self, listener: Callable) -> None: 72 | """Subscribes the passed in method to IEM events""" 73 | self.e_new_event_received.add_listener(listener) 74 | 75 | def unsubscribe_from_events(self, listener: Callable) -> None: 76 | """Unsubscribes the passed in method to IEM events""" 77 | self.e_new_event_received.remove_listener(listener) 78 | -------------------------------------------------------------------------------- /src/cli_chess/modules/about/about_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.__metadata__ import __version__ 3 | from cli_chess.utils.ui_common import handle_mouse_click, handle_bound_key_pressed 4 | from prompt_toolkit.layout import Window, VSplit, HSplit, D, FormattedTextControl, Container 5 | from prompt_toolkit.key_binding import KeyBindings 6 | from prompt_toolkit.keys import Keys 7 | from prompt_toolkit.formatted_text import StyleAndTextTuples, ANSI, HTML 8 | from prompt_toolkit.widgets import Box 9 | from typing import TYPE_CHECKING 10 | if TYPE_CHECKING: 11 | from cli_chess.modules.about import AboutPresenter 12 | 13 | CLI_CHESS_LINES = ANSI("\x1b[48;2;236;60;45;38;2;236;60;45m▄▄▄▄▄▄▄▄\n" 14 | "\x1b[48;2;242;109;53;38;2;242;109;53m▄▄▄▄▄▄▄▄▄▄▄\n" 15 | "\x1b[48;2;249;200;133;38;2;249;200;133m▄▄▄▄▄▄▄▄▄▄▄▄▄▄\n" 16 | "\x1b[48;2;41;170;225;38;2;41;170;225m▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄") 17 | 18 | 19 | class AboutView: 20 | def __init__(self, presenter: AboutPresenter): 21 | self.presenter = presenter 22 | self._container = self._create_container() 23 | 24 | @staticmethod 25 | def _create_container() -> Container: 26 | """Creates the container for the token manager view""" 27 | return VSplit([ 28 | HSplit([ 29 | Window(FormattedTextControl(HTML("Author: Trevor Bayless"), style="class:label"), dont_extend_width=True, dont_extend_height=True), # noqa: E501 30 | Window(FormattedTextControl(HTML("License: GPL v3.0"), style="class:label"), dont_extend_width=True, dont_extend_height=True), 31 | Window(FormattedTextControl(HTML(f"Version: {__version__}"), style="class:label"), dont_extend_width=True, dont_extend_height=True), # noqa: E501 32 | ]), 33 | Box(Window(FormattedTextControl(CLI_CHESS_LINES)), padding=0, padding_left=2, width=D(min=1)) 34 | ], width=D(min=1, max=80)) 35 | 36 | def get_function_bar_fragments(self) -> StyleAndTextTuples: 37 | """Returns a set of function bar fragments to use if 38 | this module is hooked up with a function bar 39 | """ 40 | return [ 41 | ("class:function-bar.key", "F1", handle_mouse_click(self.presenter.open_github_url)), 42 | ("class:function-bar.label", f"{'Open Github':<15}", handle_mouse_click(self.presenter.open_github_url)), 43 | ("class:function-bar.spacer", " "), 44 | ("class:function-bar.key", "F2", handle_mouse_click(self.presenter.open_github_issue_url)), 45 | ("class:function-bar.label", f"{'Report a bug':<15}", handle_mouse_click(self.presenter.open_github_issue_url)), 46 | ] 47 | 48 | def get_function_bar_key_bindings(self) -> KeyBindings: 49 | """Returns a set of key bindings to use if this 50 | module is hooked up with a function bar 51 | """ 52 | kb = KeyBindings() 53 | kb.add(Keys.F1)(handle_bound_key_pressed(self.presenter.open_github_url)) 54 | kb.add(Keys.F2)(handle_bound_key_pressed(self.presenter.open_github_issue_url)) 55 | return kb 56 | 57 | def __pt_container__(self) -> Container: 58 | return self._container 59 | -------------------------------------------------------------------------------- /src/cli_chess/menus/menu_presenter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.utils.logging import log 3 | from cli_chess.utils.event import Event 4 | from enum import Enum 5 | from typing import TYPE_CHECKING, List 6 | if TYPE_CHECKING: 7 | from cli_chess.menus import MenuOption, MultiValueMenuOption, MenuCategory, MenuModel, MultiValueMenuModel, MenuView, MultiValueMenuView 8 | 9 | 10 | class MenuPresenter: 11 | def __init__(self, model: MenuModel, view: MenuView): 12 | self.model = model 13 | self.view = view 14 | self.selection = self.model.get_menu_options()[0].option 15 | self.e_selection_updated = Event() 16 | 17 | def get_menu_category(self) -> MenuCategory: 18 | """Get the menu category""" 19 | return self.model.get_menu_category() 20 | 21 | def get_menu_options(self) -> List[MenuOption]: 22 | """Returns all menu options regardless of their enabled/visibility state""" 23 | return self.model.get_menu_options() 24 | 25 | def get_visible_menu_options(self) -> List[MenuOption]: 26 | """Returns all menu options which are visible""" 27 | visible_options = [] 28 | for opt in self.get_menu_options(): 29 | if not opt.visible: 30 | continue 31 | else: 32 | visible_options.append(opt) 33 | return visible_options 34 | 35 | def select_handler(self, selected_option: int): 36 | """Called on menu item selection. Classes that inherit from 37 | this class should override this method if specific tasks 38 | need to execute when the selected option changes 39 | """ 40 | try: 41 | self.selection = self.model.get_menu_options()[selected_option].option 42 | log.debug(f"Menu selection: {self.selection}") 43 | self._notify_selection_updated(self.selection) 44 | 45 | except Exception as e: 46 | # Todo: Print error to view element 47 | log.exception(f"Exception caught: {e}") 48 | raise e 49 | 50 | def has_focus(self) -> bool: 51 | """Queries the view to determine if the menu has focus""" 52 | return self.view.has_focus() 53 | 54 | def _notify_selection_updated(self, selected_option: int) -> None: 55 | """Notifies listeners that the selection has been updated""" 56 | self.e_selection_updated.notify(selected_option) 57 | 58 | 59 | class MultiValueMenuPresenter(MenuPresenter): 60 | def __init__(self, model: MultiValueMenuModel, view: MultiValueMenuView): 61 | self.model = model 62 | self.view = view 63 | super().__init__(self.model, self.view) 64 | 65 | def value_cycled_handler(self, selected_option: Enum) -> None: 66 | """Called when the selected options value is cycled. Classes that inherit from 67 | this class should override this method if they need to 68 | be alerted when the selected option changes 69 | """ 70 | pass 71 | 72 | def get_menu_options(self) -> List[MultiValueMenuOption]: 73 | """Returns all menu options regardless of their enabled/visibility state""" 74 | return super().get_menu_options() 75 | 76 | def get_visible_menu_options(self) -> List[MultiValueMenuOption]: 77 | """Returns all menu options which are visible""" 78 | return super().get_visible_menu_options() 79 | -------------------------------------------------------------------------------- /src/cli_chess/modules/premove/premove_model.py: -------------------------------------------------------------------------------- 1 | from cli_chess.modules.board import BoardModel 2 | from cli_chess.utils import EventManager, EventTopics, log 3 | from chess import Move, InvalidMoveError, IllegalMoveError, AmbiguousMoveError 4 | 5 | 6 | class PremoveModel: 7 | def __init__(self, board_model: BoardModel) -> None: 8 | self.board_model = board_model 9 | self.board_model.e_board_model_updated.add_listener(self.update) 10 | self.premove = "" 11 | 12 | self._event_manager = EventManager() 13 | self.e_premove_model_updated = self._event_manager.create_event() 14 | 15 | def update(self, *args, **kwargs) -> None: # noqa 16 | """Updates the premove model based on board updates""" 17 | if EventTopics.GAME_END in args: 18 | self.clear_premove() 19 | 20 | def pop_premove(self) -> str: 21 | """Returns the set premove, but also clears it after""" 22 | premove = self.premove 23 | if premove: 24 | log.debug(f"Popping premove: {self.premove}") 25 | self.clear_premove() 26 | return premove 27 | 28 | def clear_premove(self) -> None: 29 | """Clears the set premove""" 30 | self.premove = "" 31 | self.board_model.clear_premove_highlight() 32 | self._notify_premove_model_updated() 33 | 34 | def set_premove(self, move: str = None) -> None: 35 | """Sets the passed in move as the premove to make. 36 | Raises an exception if the premove is invalid. 37 | """ 38 | try: 39 | premove = self._validate_premove(move) 40 | self.premove = move 41 | log.debug(f"Premove set to ({move})") 42 | self.board_model.set_premove_highlight(premove) 43 | except Exception as e: 44 | raise e 45 | 46 | self._notify_premove_model_updated() 47 | 48 | def _validate_premove(self, move: str = None) -> Move: 49 | """Checks if the premove passed in is valid in the context of game. 50 | Raises an exception if the premove is invalid. Returns the move 51 | in the format of chess.Move 52 | """ 53 | try: 54 | if not move: 55 | raise Warning("No move specified") 56 | 57 | if self.premove: 58 | raise Warning("You already have a premove set") 59 | 60 | tmp_premove_board = self.board_model.board.copy(stack=False) 61 | tmp_premove_board.turn = not tmp_premove_board.turn 62 | try: 63 | return tmp_premove_board.push_san(move.strip()) 64 | 65 | except Exception as e: 66 | if isinstance(e, InvalidMoveError): 67 | raise ValueError(f"Invalid premove: {move}") 68 | elif isinstance(e, IllegalMoveError): 69 | raise ValueError(f"Illegal premove: {move}") 70 | elif isinstance(e, AmbiguousMoveError): 71 | raise ValueError(f"Ambiguous premove: {move}") 72 | else: 73 | raise e 74 | except Exception: 75 | raise 76 | 77 | def _notify_premove_model_updated(self) -> None: 78 | """Notifies listeners of premove model updates""" 79 | self.e_premove_model_updated.notify() 80 | 81 | def cleanup(self) -> None: 82 | """Handles model cleanup tasks. This should only ever 83 | be run when this model is no longer needed. 84 | """ 85 | self._event_manager.purge_all_events() 86 | -------------------------------------------------------------------------------- /src/cli_chess/utils/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.utils.logging import log 3 | from platform import system 4 | from typing import Tuple, Type 5 | import threading 6 | import subprocess 7 | import enum 8 | import os 9 | 10 | VALID_COLOR_DEPTHS = ["DEPTH_24_BIT", "DEPTH_8_BIT", "DEPTH_4_BIT"] 11 | COLOR_DEPTH_MAP = { 12 | "DEPTH_24_BIT": "True color", 13 | "DEPTH_8_BIT": "256 colors", 14 | "DEPTH_4_BIT": "ANSI colors" 15 | } 16 | 17 | 18 | class RequestSuccessfullySent(Exception): 19 | """Custom exception to use upon success of a call""" 20 | pass 21 | 22 | 23 | class AlertType(enum.Enum): 24 | """General alert class which can be 25 | used for alert type classification 26 | """ 27 | SUCCESS = enum.auto() 28 | NEUTRAL = enum.auto() 29 | ERROR = enum.auto() 30 | 31 | def get_style(self, alert_type: AlertType) -> str: 32 | """Returns the associated style for the passed in alert type""" 33 | if alert_type is self.SUCCESS: 34 | return "class:label.success.banner" 35 | elif alert_type is self.NEUTRAL: 36 | return "class:label.neutral.banner" 37 | else: 38 | return "class:label.error.banner" 39 | 40 | 41 | def is_linux_os() -> bool: 42 | """Returns True if running on Linux""" 43 | return True if system() == "Linux" else False 44 | 45 | 46 | def is_windows_os() -> bool: 47 | """Returns True if running on Windows""" 48 | return True if system() == "Windows" else False 49 | 50 | 51 | def is_mac_os() -> bool: 52 | """Returns True if running on Mac""" 53 | return True if system() == "Darwin" else False 54 | 55 | 56 | def str_to_bool(s: str) -> bool: 57 | """Returns a boolean based on the passed in string""" 58 | return s.lower() in ("true", "yes", "1") 59 | 60 | 61 | def open_url_in_browser(url: str): 62 | """Open the passed in URL in the default web browser""" 63 | url = url.strip() 64 | if url: 65 | try: 66 | if is_windows_os(): 67 | os.startfile(url) 68 | else: 69 | cmd = 'open' if is_mac_os() else 'xdg-open' 70 | subprocess.Popen([cmd, url], 71 | close_fds=True, 72 | stdin=subprocess.DEVNULL, 73 | stdout=subprocess.DEVNULL, 74 | stderr=subprocess.DEVNULL, 75 | start_new_session=True) 76 | except Exception as e: 77 | log.error(f"Common: Error opening URL in browser: {e}") 78 | 79 | 80 | def threaded(fn): 81 | """Decorator for a threaded function""" 82 | def wrapper(*args, **kwargs): 83 | threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=True).start() 84 | return wrapper 85 | 86 | 87 | def retry(times: int, exceptions: Tuple[Type[Exception], ...]): 88 | """Decorator to retry a function. Retries the wrapped function 89 | (x) times if the exceptions listed in `exceptions` are thrown. 90 | Example exceptions parameter: exceptions=(ValueError, KeyError) 91 | """ 92 | def wrapper(func): 93 | def retry_fn(*args, **kwargs): 94 | attempt = 1 95 | while attempt <= times: 96 | try: 97 | return func(*args, **kwargs) 98 | except exceptions as e: 99 | log.error(f"Exception when attempting to run {func}. Attempt {attempt} of {times}. Exception = {e}") 100 | attempt += 1 101 | return func(*args, **kwargs) 102 | return retry_fn 103 | return wrapper 104 | -------------------------------------------------------------------------------- /src/cli_chess/menus/program_settings_menu/program_settings_menu_model.py: -------------------------------------------------------------------------------- 1 | from cli_chess.menus import MultiValueMenuModel, MultiValueMenuOption, MenuCategory 2 | from cli_chess.utils.config import game_config, terminal_config 3 | from cli_chess.utils.common import VALID_COLOR_DEPTHS, COLOR_DEPTH_MAP 4 | from cli_chess.utils.logging import log 5 | 6 | 7 | class ProgramSettingsMenuModel(MultiValueMenuModel): 8 | def __init__(self): 9 | self.menu = self._create_menu() 10 | super().__init__(self.menu) 11 | 12 | def _create_menu(self) -> MenuCategory: 13 | """Create the program settings menu options""" 14 | menu_options = [ 15 | MultiValueMenuOption(game_config.Keys.SHOW_BOARD_COORDINATES, "", self._get_available_game_config_options(game_config.Keys.SHOW_BOARD_COORDINATES), display_name="Show board coordinates"), # noqa: E501 16 | MultiValueMenuOption(game_config.Keys.SHOW_BOARD_HIGHLIGHTS, "", self._get_available_game_config_options(game_config.Keys.SHOW_BOARD_HIGHLIGHTS), display_name="Show board highlights"), # noqa: E501 17 | MultiValueMenuOption(game_config.Keys.BLINDFOLD_CHESS, "", self._get_available_game_config_options(game_config.Keys.BLINDFOLD_CHESS), display_name="Blindfold chess"), # noqa: E501 18 | MultiValueMenuOption(game_config.Keys.USE_UNICODE_PIECES, "", self._get_available_game_config_options(game_config.Keys.USE_UNICODE_PIECES), display_name="Use unicode pieces"), # noqa: E501 19 | MultiValueMenuOption(game_config.Keys.SHOW_MOVE_LIST_IN_UNICODE, "", self._get_available_game_config_options(game_config.Keys.SHOW_MOVE_LIST_IN_UNICODE), display_name="Show move list in unicode"), # noqa: E501 20 | MultiValueMenuOption(game_config.Keys.SHOW_MATERIAL_DIFF_IN_UNICODE, "", self._get_available_game_config_options(game_config.Keys.SHOW_MATERIAL_DIFF_IN_UNICODE), display_name="Unicode material difference"), # noqa: E501 21 | MultiValueMenuOption(game_config.Keys.PAD_UNICODE, "", self._get_available_game_config_options(game_config.Keys.PAD_UNICODE), display_name="Pad unicode (fix overlap)"), # noqa: E501 22 | MultiValueMenuOption(terminal_config.Keys.TERMINAL_COLOR_DEPTH, "", self._get_available_color_depth_options(), display_name="Terminal color depth"), # noqa: E501 23 | ] 24 | return MenuCategory("Program Settings", menu_options) 25 | 26 | @staticmethod 27 | def _get_available_game_config_options(key: game_config.Keys) -> list: 28 | """Returns a list of available game configuration options for the passed in key""" 29 | return ["Yes", "No"] if game_config.get_boolean(key) else ["No", "Yes"] 30 | 31 | @staticmethod 32 | def _get_available_color_depth_options() -> list: 33 | """Returns a list of friendly named color depth options. 34 | The currently set color depth will be the first in the list. 35 | """ 36 | cdl = VALID_COLOR_DEPTHS.copy() 37 | current_color_depth = terminal_config.get_value(terminal_config.Keys.TERMINAL_COLOR_DEPTH) 38 | cdl.insert(0, cdl.pop(cdl.index(current_color_depth))) 39 | for idx, depth in enumerate(cdl): 40 | cdl[idx] = COLOR_DEPTH_MAP[depth] 41 | return cdl 42 | 43 | @staticmethod 44 | def save_selected_game_config_setting(key: game_config.Keys, enabled: bool): 45 | """Saves the selected option in the game configuration""" 46 | game_config.set_value(key, str(enabled)) 47 | 48 | @staticmethod 49 | def save_terminal_color_depth_setting(depth: str): 50 | """Saves the selected option in the terminal configuration""" 51 | if depth in VALID_COLOR_DEPTHS: 52 | terminal_config.set_value(terminal_config.Keys.TERMINAL_COLOR_DEPTH, depth) 53 | else: 54 | log.error(f"Invalid color depth value: {depth}") 55 | -------------------------------------------------------------------------------- /src/cli_chess/modules/material_difference/material_difference_presenter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.modules.material_difference import MaterialDifferenceView 3 | from cli_chess.modules.common import get_piece_unicode_symbol 4 | from cli_chess.utils.config import game_config 5 | from chess import Color, PIECE_TYPES, PIECE_SYMBOLS, KING 6 | from typing import TYPE_CHECKING 7 | if TYPE_CHECKING: 8 | from cli_chess.modules.material_difference import MaterialDifferenceModel 9 | 10 | 11 | class MaterialDifferencePresenter: 12 | def __init__(self, model: MaterialDifferenceModel): 13 | self.model = model 14 | self.show_diff = self.model.board_model.get_variant_name() != "horde" 15 | self.is_crazyhouse = self.model.board_model.get_variant_name() == "crazyhouse" 16 | 17 | orientation = self.model.get_board_orientation() 18 | self.view_upper = MaterialDifferenceView(self, self.format_diff_output(not orientation), self.show_diff) 19 | self.view_lower = MaterialDifferenceView(self, self.format_diff_output(orientation), self.show_diff) 20 | 21 | self.model.e_material_difference_model_updated.add_listener(self.update) 22 | game_config.e_game_config_updated.add_listener(self.update) 23 | 24 | def update(self) -> None: 25 | """Updates the material differences for both sides""" 26 | orientation = self.model.get_board_orientation() 27 | self.view_upper.update(self.format_diff_output(not orientation)) 28 | self.view_lower.update(self.format_diff_output(orientation)) 29 | 30 | def format_diff_output(self, color: Color) -> str: 31 | """Returns the formatted difference of the color passed in as a string""" 32 | output = "" 33 | material_difference = self.model.get_material_difference(color) 34 | use_unicode = game_config.get_boolean(game_config.Keys.SHOW_MATERIAL_DIFF_IN_UNICODE) 35 | pad_unicode = game_config.get_boolean(game_config.Keys.PAD_UNICODE) 36 | 37 | if self.is_crazyhouse: 38 | return self._get_crazyhouse_pocket_output(color, use_unicode, pad_unicode) 39 | 40 | for piece_type in PIECE_TYPES: 41 | for count in range(material_difference[piece_type]): 42 | symbol = get_piece_unicode_symbol(PIECE_SYMBOLS[piece_type]) if use_unicode else PIECE_SYMBOLS[piece_type].upper() 43 | 44 | if symbol and use_unicode and pad_unicode: 45 | # Pad unicode symbol with a space (if pad_unicode is true) to help unicode/ascii character overlap 46 | symbol = symbol + " " 47 | 48 | output = symbol + output if piece_type != KING else output + symbol # Add king to end for 3check 49 | 50 | score = self.model.get_score(color) 51 | if score > 0: 52 | output += f"+{score}" 53 | 54 | return output 55 | 56 | def _get_crazyhouse_pocket_output(self, color: Color, use_unicode: bool, pad_unicode: bool) -> str: 57 | """Returns the formatted crazyhouse pocket for the color passed in as a string""" 58 | output = "" 59 | material_difference = self.model.get_material_difference(color) 60 | 61 | for piece_type in PIECE_TYPES: 62 | if piece_type == KING: 63 | continue 64 | 65 | piece_count = material_difference[piece_type] 66 | if piece_count > 0: 67 | symbol = get_piece_unicode_symbol(PIECE_SYMBOLS[piece_type]) if use_unicode else PIECE_SYMBOLS[piece_type].upper() 68 | 69 | if symbol and use_unicode and pad_unicode: 70 | # Pad unicode symbol with a space (if pad_unicode is true) to help unicode/ascii character overlap 71 | symbol = symbol + " " 72 | 73 | output = output + symbol + f"({piece_count}) " 74 | 75 | return output 76 | -------------------------------------------------------------------------------- /src/cli_chess/modules/player_info/player_info_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from prompt_toolkit.layout import Container, ConditionalContainer, VSplit, D, Window, FormattedTextControl, WindowAlign 3 | from prompt_toolkit.widgets import Box 4 | from prompt_toolkit.filters import Condition 5 | from typing import TYPE_CHECKING 6 | if TYPE_CHECKING: 7 | from cli_chess.modules.player_info import PlayerInfoPresenter 8 | from cli_chess.core.game import PlayerMetadata 9 | 10 | 11 | class PlayerInfoView: 12 | def __init__(self, presenter: PlayerInfoPresenter, player_info: PlayerMetadata): 13 | self.presenter = presenter 14 | self.player_title = "" 15 | self.player_name = "" 16 | self.player_rating = "" 17 | self.rating_diff = "" 18 | self.update(player_info) 19 | 20 | self._player_title_control = FormattedTextControl(text=lambda: self.player_title, style="class:player-info.title") 21 | self._player_name_control = FormattedTextControl(text=lambda: self.player_name, style="class:player-info") 22 | self._player_rating_control = FormattedTextControl(text=lambda: self.player_rating, style="class:player-info") 23 | self._rating_diff_control = FormattedTextControl(text=lambda: self.rating_diff, style="class:player-info") 24 | self._container = self._create_container() 25 | 26 | def _create_container(self) -> Container: 27 | return VSplit([ 28 | ConditionalContainer( 29 | Box(Window(self._player_title_control, align=WindowAlign.LEFT, dont_extend_width=True), padding=0, padding_right=1), 30 | Condition(lambda: False if not self.player_title else True) 31 | ), 32 | Box(Window(self._player_name_control, align=WindowAlign.LEFT, dont_extend_width=True), padding=0, padding_right=1), 33 | Box(Window(self._player_rating_control, align=WindowAlign.RIGHT, dont_extend_width=True), padding=0, padding_right=1), 34 | Box(Window(self._rating_diff_control, align=WindowAlign.RIGHT, dont_extend_width=True), padding=0, padding_right=1), 35 | 36 | ], width=D(min=1), height=D(max=1), window_too_small=ConditionalContainer(Window(), False)) 37 | 38 | def update(self, player_info: PlayerMetadata) -> None: 39 | """Updates the player info using the data passed in""" 40 | self._set_player_title(player_info.title) 41 | self._set_player_name(player_info.name) 42 | self._set_player_rating(player_info.rating, player_info.is_provisional_rating) 43 | self._set_rating_diff(player_info.rating_diff) 44 | 45 | def _set_player_title(self, title: str): 46 | title = title if title else "" 47 | if title == "BOT": 48 | self._player_title_control.style = "class:player-info.title.bot" 49 | 50 | self.player_title = title 51 | 52 | def _set_player_name(self, name: str): 53 | self.player_name = name if name else "" 54 | 55 | def _set_player_rating(self, rating: str, provisional: bool): 56 | rating = rating if rating else "" 57 | self.player_rating = (f"({rating})" if not provisional else f"({rating}?)") if rating else "" 58 | 59 | def _set_rating_diff(self, rating_diff: int): 60 | """Handles formatting and updating the rating diff control""" 61 | if rating_diff: 62 | if rating_diff < 0: 63 | self._rating_diff_control.style = "class:player-info.neg-rating-diff" 64 | self.rating_diff = str(rating_diff) 65 | elif rating_diff > 0: 66 | self._rating_diff_control.style = "class:player-info.pos-rating-diff" 67 | self.rating_diff = "+" + str(rating_diff) 68 | else: 69 | self._rating_diff_control.style = "class:player-info" 70 | self.rating_diff = "+-0" + str() 71 | else: 72 | self.rating_diff = "" 73 | 74 | def __pt_container__(self) -> Container: 75 | """Returns this views container""" 76 | return self._container 77 | -------------------------------------------------------------------------------- /src/cli_chess/menus/versus_menus/versus_menu_presenters.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.menus.versus_menus import VersusMenuView 3 | from cli_chess.menus import MultiValueMenuPresenter 4 | from cli_chess.core.game.game_options import GameOption, BaseGameOptions, OfflineGameOptions, OnlinePublicGameOptions, OnlineDirectChallengesGameOptions # noqa: E501 5 | from cli_chess.core.game import start_online_game, start_offline_game 6 | from cli_chess.utils import log 7 | from abc import ABC, abstractmethod 8 | from typing import TYPE_CHECKING, Type 9 | if TYPE_CHECKING: 10 | from cli_chess.menus.versus_menus import VersusMenuModel, OfflineVsComputerMenuModel 11 | 12 | 13 | class VersusMenuPresenter(MultiValueMenuPresenter, ABC): 14 | """Base presenter for the VsComputer menus""" 15 | def __init__(self, model: VersusMenuModel): 16 | self.model = model 17 | self.view = VersusMenuView(self) 18 | super().__init__(self.model, self.view) 19 | 20 | @abstractmethod 21 | def value_cycled_handler(self, selected_option: int): 22 | """A handler that's called when the value of the selected option changed""" 23 | pass 24 | 25 | @abstractmethod 26 | def handle_start_game(self) -> None: 27 | """Starts the game using the currently selected menu values""" 28 | pass 29 | 30 | def _create_dict_of_selected_values(self, game_options_cls: Type[BaseGameOptions]) -> dict: 31 | """Creates a dictionary of all selected values. Raises an Exception on failure.""" 32 | try: 33 | selections_dict = {} 34 | for index, menu_option in enumerate(self.get_visible_menu_options()): 35 | selections_dict[menu_option.option] = menu_option.selected_value['name'] 36 | return game_options_cls().create_game_parameters_dict(selections_dict) 37 | except Exception as e: 38 | log.error(f"Error creating dictionary of selections: {e}") 39 | raise 40 | 41 | 42 | class OfflineVersusMenuPresenter(VersusMenuPresenter): 43 | """Defines the presenter for the OfflineVsComputer menu""" 44 | def __init__(self, model: OfflineVsComputerMenuModel): 45 | self.model = model 46 | super().__init__(self.model) 47 | 48 | def value_cycled_handler(self, selected_option: int): 49 | """A handler that's called when the value of the selected option changed""" 50 | menu_item = self.model.get_menu_options()[selected_option] 51 | selected_option = menu_item.option 52 | selected_value = menu_item.selected_value['name'] 53 | 54 | if selected_option == GameOption.SPECIFY_ELO: 55 | self.model.show_elo_selection_option(selected_value == "Yes") 56 | 57 | def handle_start_game(self) -> None: 58 | """Starts the game using the currently selected menu values""" 59 | try: 60 | game_parameters = super()._create_dict_of_selected_values(OfflineGameOptions) 61 | start_offline_game(game_parameters) 62 | except Exception as e: 63 | log.error(e) 64 | raise 65 | 66 | 67 | class OnlineVersusMenuPresenter(VersusMenuPresenter): 68 | """Defines the presenter for the OnlineVsComputer menu""" 69 | def __init__(self, model: VersusMenuModel, is_vs_ai: bool): 70 | self.model = model 71 | self.is_vs_ai = is_vs_ai 72 | super().__init__(self.model) 73 | 74 | def value_cycled_handler(self, selected_option: int): 75 | """A handler that's called when the value of the selected option changed""" 76 | pass 77 | 78 | def handle_start_game(self) -> None: 79 | """Starts the game using the currently selected menu values""" 80 | try: 81 | game_parameters = super()._create_dict_of_selected_values(OnlinePublicGameOptions if not self.is_vs_ai else OnlineDirectChallengesGameOptions) # noqa: E501 82 | start_online_game(game_parameters, is_vs_ai=self.is_vs_ai) 83 | except Exception as e: 84 | log.error(e) 85 | raise 86 | -------------------------------------------------------------------------------- /src/cli_chess/tests/modules/token_manager/test_token_manager_model.py: -------------------------------------------------------------------------------- 1 | from cli_chess.modules.token_manager import TokenManagerModel 2 | from cli_chess.utils.config import LichessConfig 3 | from berserk import clients 4 | from os import remove 5 | from unittest.mock import Mock 6 | import pytest 7 | 8 | 9 | @pytest.fixture 10 | def model_listener(): 11 | return Mock() 12 | 13 | 14 | @pytest.fixture 15 | def model(model_listener: Mock, lichess_config: LichessConfig, monkeypatch): 16 | monkeypatch.setattr('cli_chess.modules.token_manager.token_manager_model.lichess_config', lichess_config) 17 | monkeypatch.setattr('cli_chess.core.api.api_manager._start_api', Mock()) 18 | model = TokenManagerModel() 19 | model.e_token_manager_model_updated.add_listener(model_listener) 20 | return model 21 | 22 | 23 | @pytest.fixture 24 | def lichess_config(): 25 | lichess_config = LichessConfig("unit_test_config.ini") 26 | yield lichess_config 27 | remove(lichess_config.full_filename) 28 | 29 | 30 | def mock_fail_test_tokens(*args): # noqa 31 | return {'lip_badToken': None} 32 | 33 | 34 | def mock_success_test_tokens(*args): # noqa 35 | return { 36 | 'lip_validToken': { 37 | 'scopes': 'board:play,challenge:read,challenge:write', 38 | 'userId': 'testUser', 39 | 'expires': None 40 | } 41 | } 42 | 43 | 44 | def test_update_linked_account(model: TokenManagerModel, lichess_config: LichessConfig, model_listener: Mock, monkeypatch): 45 | # Test with empty api token 46 | assert not model.update_linked_account(api_token="") 47 | model_listener.assert_not_called() 48 | 49 | # Test a mocked invalid lichess api token and 50 | # verify existing user account data is not overwritten 51 | monkeypatch.setattr(clients.OAuth, "test_tokens", mock_fail_test_tokens) 52 | lichess_config.set_value(lichess_config.Keys.API_TOKEN, "lip_validToken") 53 | assert not model.update_linked_account(api_token="lip_badToken") 54 | assert lichess_config.get_value(lichess_config.Keys.API_TOKEN) == "lip_validToken" 55 | model_listener.assert_not_called() 56 | 57 | # Test a mocked valid lichess api token 58 | monkeypatch.setattr(clients.OAuth, "test_tokens", mock_success_test_tokens) 59 | lichess_config.set_value(lichess_config.Keys.API_TOKEN, "") 60 | assert model.update_linked_account(api_token="lip_validToken") 61 | assert lichess_config.get_value(lichess_config.Keys.API_TOKEN) == "lip_validToken" 62 | model_listener.assert_called() 63 | 64 | 65 | def test_validate_token(model: TokenManagerModel, monkeypatch): 66 | # Test with empty API token 67 | assert model.validate_token(api_token="") is None 68 | 69 | # Test with invalid API token 70 | monkeypatch.setattr(clients.OAuth, "test_tokens", mock_fail_test_tokens) 71 | assert model.validate_token(api_token="lip_badToken") is None 72 | 73 | # Test with valid API token 74 | monkeypatch.setattr(clients.OAuth, "test_tokens", mock_success_test_tokens) 75 | assert model.validate_token(api_token="lip_validToken") == mock_success_test_tokens()['lip_validToken'] 76 | 77 | 78 | def test_save_account_data(model: TokenManagerModel, lichess_config: LichessConfig, model_listener: Mock): 79 | assert lichess_config.get_value(lichess_config.Keys.API_TOKEN) == "" 80 | model_listener.assert_not_called() 81 | 82 | model.save_account_data(api_token=" lip_validToken ", account_data=mock_success_test_tokens()) 83 | assert lichess_config.get_value(lichess_config.Keys.API_TOKEN) == "lip_validToken" 84 | model_listener.assert_called() 85 | 86 | 87 | def test_notify_token_manager_model_updated(model: TokenManagerModel, model_listener: Mock): 88 | # Test registered successful move listener is called 89 | model._notify_token_manager_model_updated() 90 | model_listener.assert_called() 91 | 92 | # Unregister listener and test it's not called 93 | model_listener.reset_mock() 94 | model.e_token_manager_model_updated.remove_listener(model_listener) 95 | model._notify_token_manager_model_updated() 96 | model_listener.assert_not_called() 97 | -------------------------------------------------------------------------------- /src/cli_chess/modules/engine/engine_model.py: -------------------------------------------------------------------------------- 1 | from cli_chess.modules.board import BoardModel 2 | from cli_chess.core.game.game_options import GameOption 3 | from cli_chess.utils import log, is_linux_os, is_windows_os, is_mac_os 4 | import chess.engine 5 | from os import path 6 | import platform 7 | from typing import Optional 8 | 9 | 10 | fairy_stockfish_mapped_skill_levels = { 11 | # These defaults are for the Fairy Stockfish engine 12 | # correlates levels 1-8 to a fairy-stockfish "equivalent" 13 | # This skill level mapping is to match Lichess' implementation 14 | 1: -9, 15 | 2: -5, 16 | 3: -1, 17 | 4: 3, 18 | 5: 7, 19 | 6: 11, 20 | 7: 16, 21 | 8: 20, 22 | } 23 | 24 | 25 | class EngineModel: 26 | def __init__(self, board_model: BoardModel, game_parameters: dict): 27 | self.engine: Optional[chess.engine.SimpleEngine] = None 28 | self.board_model = board_model 29 | self.game_parameters = game_parameters 30 | 31 | def start_engine(self): 32 | """Starts and configures the Fairy-Stockfish chess engine""" 33 | try: 34 | # Use SimpleEngine to allow engine assignment in initializer. Additionally, 35 | # by having this as a blocking call it stops multiple engines from being 36 | # able to be started if the start game button is spammed 37 | self.engine = chess.engine.SimpleEngine.popen_uci(path.dirname(path.realpath(__file__)) + "/binaries/" + self._get_engine_filename()) 38 | 39 | # Engine configuration 40 | skill_level = fairy_stockfish_mapped_skill_levels.get(self.game_parameters.get(GameOption.COMPUTER_SKILL_LEVEL)) 41 | limit_strength = self.game_parameters.get(GameOption.SPECIFY_ELO) 42 | uci_elo = self.game_parameters.get(GameOption.COMPUTER_ELO) 43 | engine_cfg = { 44 | 'Skill Level': skill_level if skill_level else 0, 45 | 'UCI_LimitStrength': True if limit_strength else False, 46 | 'UCI_Elo': uci_elo if uci_elo else 1350 47 | } 48 | self.engine.configure(engine_cfg) 49 | except Exception as e: 50 | msg = f"Error starting engine: {e}" 51 | log.error(msg) 52 | raise Warning(msg) 53 | 54 | def get_best_move(self) -> chess.engine.PlayResult: 55 | """Query the engine to get the best move""" 56 | # Keep track of the last move that was made. This allows checking 57 | # for if a takeback happened while the engine has been thinking 58 | try: 59 | last_move = (self.board_model.get_move_stack() or [None])[-1] 60 | result = self.engine.play(self.board_model.board, 61 | chess.engine.Limit(2)) 62 | 63 | # Check if the move stack has been altered, if so void this move 64 | if last_move != (self.board_model.get_move_stack() or [None])[-1]: 65 | result.move = None 66 | 67 | # Check to make sure the game is still in progress (opponent hasn't resigned) 68 | if self.board_model.get_game_over_result() is not None: 69 | result.move = None 70 | except Exception as e: 71 | log.error(f"{e}") 72 | if not self.engine: 73 | raise Warning("Engine is not running") 74 | raise 75 | 76 | log.debug(f"Returning {result}") 77 | return result 78 | 79 | def quit_engine(self) -> None: 80 | """Notify the engine to quit""" 81 | try: 82 | if self.engine: 83 | log.debug("Quitting engine") 84 | self.engine.quit() 85 | except Exception as e: 86 | log.error(f"Error quitting engine: {e}") 87 | 88 | @staticmethod 89 | def _get_engine_filename() -> str: 90 | """Returns the engines filename to use for opening""" 91 | binary_name = "fairy-stockfish_x86-64_" + ("linux" if is_linux_os() else ("windows" if is_windows_os() else "macos")) 92 | if is_mac_os() and platform.machine() == "arm64": 93 | binary_name = "fairy-stockfish_arm64_macos" 94 | return binary_name 95 | -------------------------------------------------------------------------------- /src/cli_chess/core/game/game_model_base.py: -------------------------------------------------------------------------------- 1 | from cli_chess.modules.board import BoardModel 2 | from cli_chess.modules.move_list import MoveListModel 3 | from cli_chess.modules.material_difference import MaterialDifferenceModel 4 | from cli_chess.modules.premove import PremoveModel 5 | from cli_chess.utils import EventManager, log 6 | from .game_metadata import GameMetadata 7 | from chess import Color, WHITE, COLOR_NAMES 8 | from random import getrandbits 9 | from abc import ABC, abstractmethod 10 | 11 | 12 | class GameModelBase: 13 | def __init__(self, orientation: Color = WHITE, variant="standard", fen="", side_confirmed=True): 14 | self.game_metadata = GameMetadata() 15 | 16 | self.board_model = BoardModel(orientation, variant, fen, side_confirmed) 17 | self.move_list_model = MoveListModel(self.board_model) 18 | self.material_diff_model = MaterialDifferenceModel(self.board_model) 19 | 20 | self._event_manager = EventManager() 21 | self.e_game_model_updated = self._event_manager.create_event() 22 | self.board_model.e_board_model_updated.add_listener(self.update) 23 | 24 | # Keep track of all associated models to handle bulk cleanup on exit 25 | self._assoc_models = [self.board_model, self.move_list_model, self.material_diff_model] 26 | 27 | log.debug(f"Created {type(self).__name__} (id={id(self)})") 28 | 29 | def update(self, *args, **kwargs) -> None: 30 | """Called automatically as part of an event listener. This method 31 | listens to subscribed model update events and if deemed necessary 32 | triages and notifies listeners of the event. 33 | """ 34 | self._notify_game_model_updated(*args, **kwargs) 35 | 36 | def cleanup(self) -> None: 37 | """Cleans up after this model by clearing all associated models event listeners. 38 | This should only ever be run when the models are no longer needed. 39 | """ 40 | self._event_manager.purge_all_events() 41 | 42 | # Notify associated models to clean up 43 | for model in self._assoc_models: 44 | try: 45 | model.cleanup() 46 | log.debug(f"Finished cleaning up after {type(model).__name__} (id={id(model)})") 47 | except AttributeError: 48 | log.error(f"{type(model).__name__} does not have a cleanup method") 49 | 50 | def _notify_game_model_updated(self, *args, **kwargs) -> None: 51 | """Notify listeners that the model has updated""" 52 | self.e_game_model_updated.notify(*args, **kwargs) 53 | 54 | 55 | class PlayableGameModelBase(GameModelBase, ABC): 56 | def __init__(self, play_as_color: str, variant="standard", fen="", side_confirmed=True): 57 | self.my_color = self._get_side_to_play_as(play_as_color) 58 | self.game_in_progress = False 59 | 60 | super().__init__(orientation=self.my_color, variant=variant, fen=fen, side_confirmed=side_confirmed) 61 | self.premove_model = PremoveModel(self.board_model) 62 | self._assoc_models = self._assoc_models + [self.premove_model] 63 | 64 | def is_my_turn(self) -> bool: 65 | """Return True if it's our turn""" 66 | return self.board_model.get_turn() == self.my_color 67 | 68 | @staticmethod 69 | def _get_side_to_play_as(color: str) -> Color: 70 | """Returns a chess.Color based on the color string passed in. If the color string 71 | is unmatched, a random value of chess.WHITE or chess.BLACK will be returned 72 | """ 73 | if color.lower() in COLOR_NAMES: 74 | return Color(COLOR_NAMES.index(color)) 75 | else: # Get random color to play as 76 | return Color(getrandbits(1)) 77 | 78 | @abstractmethod 79 | def make_move(self, move: str) -> None: 80 | pass 81 | 82 | @abstractmethod 83 | def set_premove(self, move) -> None: 84 | pass 85 | 86 | @abstractmethod 87 | def propose_takeback(self) -> None: 88 | pass 89 | 90 | @abstractmethod 91 | def offer_draw(self) -> None: 92 | pass 93 | 94 | @abstractmethod 95 | def resign(self) -> None: 96 | pass 97 | -------------------------------------------------------------------------------- /src/cli_chess/menus/online_games_menu/online_games_menu_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.menus import MenuView 3 | from cli_chess.menus.online_games_menu import OnlineGamesMenuOptions 4 | from prompt_toolkit.layout import Container, ConditionalContainer, VSplit, HSplit 5 | from prompt_toolkit.filters import Condition, is_done 6 | from prompt_toolkit.key_binding import merge_key_bindings, ConditionalKeyBindings 7 | from prompt_toolkit.formatted_text import StyleAndTextTuples 8 | from prompt_toolkit.widgets import Box 9 | from typing import TYPE_CHECKING 10 | if TYPE_CHECKING: 11 | from cli_chess.menus.online_games_menu import OnlineGamesMenuPresenter 12 | 13 | 14 | class OnlineGamesMenuView(MenuView): 15 | def __init__(self, presenter: OnlineGamesMenuPresenter): 16 | self.presenter = presenter 17 | super().__init__(self.presenter, container_width=20) 18 | self._online_games_menu_container = self._create_online_games_menu() 19 | 20 | def _create_online_games_menu(self) -> Container: 21 | """Creates the container for the online games menu""" 22 | return HSplit([ 23 | VSplit([ 24 | Box(self._container, padding=0, padding_right=1), 25 | ConditionalContainer( 26 | Box(self.presenter.vs_random_opponent_menu_presenter.view, padding=0, padding_right=1), 27 | filter=~is_done 28 | & Condition(lambda: self.presenter.selection == OnlineGamesMenuOptions.CREATE_GAME) 29 | ), 30 | ConditionalContainer( 31 | Box(self.presenter.vs_computer_menu_presenter.view, padding=0, padding_right=1), 32 | filter=~is_done 33 | & Condition(lambda: self.presenter.selection == OnlineGamesMenuOptions.VS_COMPUTER_ONLINE) 34 | ), 35 | ConditionalContainer( 36 | Box(self.presenter.tv_channel_menu_presenter.view, padding=0, padding_right=1), 37 | filter=~is_done 38 | & Condition(lambda: self.presenter.selection == OnlineGamesMenuOptions.WATCH_LICHESS_TV) 39 | ), 40 | ]), 41 | ]) 42 | 43 | def get_function_bar_fragments(self) -> StyleAndTextTuples: 44 | """Returns the appropriate function bar fragments based on menu item selection""" 45 | fragments: StyleAndTextTuples = [] 46 | if self.presenter.selection == OnlineGamesMenuOptions.CREATE_GAME: 47 | fragments = self.presenter.vs_random_opponent_menu_presenter.view.get_function_bar_fragments() 48 | if self.presenter.selection == OnlineGamesMenuOptions.VS_COMPUTER_ONLINE: 49 | fragments = self.presenter.vs_computer_menu_presenter.view.get_function_bar_fragments() 50 | if self.presenter.selection == OnlineGamesMenuOptions.WATCH_LICHESS_TV: 51 | fragments = self.presenter.tv_channel_menu_presenter.view.get_function_bar_fragments() 52 | return fragments 53 | 54 | def get_function_bar_key_bindings(self) -> "_MergedKeyBindings": # noqa: F821 55 | """Returns the appropriate function bar key bindings based on menu item selection""" 56 | vs_random_opponent_kb = ConditionalKeyBindings( 57 | self.presenter.vs_random_opponent_menu_presenter.view.get_function_bar_key_bindings(), 58 | filter=Condition(lambda: self.presenter.selection == OnlineGamesMenuOptions.CREATE_GAME) 59 | ) 60 | 61 | vs_ai_kb = ConditionalKeyBindings( 62 | self.presenter.vs_computer_menu_presenter.view.get_function_bar_key_bindings(), 63 | filter=Condition(lambda: self.presenter.selection == OnlineGamesMenuOptions.VS_COMPUTER_ONLINE) 64 | ) 65 | 66 | tv_kb = ConditionalKeyBindings( 67 | self.presenter.tv_channel_menu_presenter.view.get_function_bar_key_bindings(), 68 | filter=Condition(lambda: self.presenter.selection == OnlineGamesMenuOptions.WATCH_LICHESS_TV) 69 | ) 70 | 71 | return merge_key_bindings([vs_random_opponent_kb, vs_ai_kb, tv_kb]) 72 | 73 | def __pt_container__(self) -> Container: 74 | return self._online_games_menu_container 75 | -------------------------------------------------------------------------------- /src/cli_chess/tests/utils/test_event.py: -------------------------------------------------------------------------------- 1 | from cli_chess.utils import Event, EventManager 2 | from unittest.mock import Mock 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | def listener1(): 8 | return Mock() 9 | 10 | 11 | @pytest.fixture 12 | def listener2(): 13 | return Mock() 14 | 15 | 16 | @pytest.fixture 17 | def event(listener1): 18 | event = Event() 19 | event.add_listener(listener1) 20 | return event 21 | 22 | 23 | @pytest.fixture 24 | def event_manager(): 25 | event_manger = EventManager() 26 | event_manger.create_event().add_listener(listener1) 27 | return event_manger 28 | 29 | 30 | class TestEvent: 31 | def test_add_listener(self, event: Event, listener1: Mock, listener2: Mock): 32 | event.add_listener(listener2) 33 | assert listener2 in event.listeners 34 | 35 | event.add_listener(listener1) 36 | assert event.listeners.count(listener1) == 1 37 | 38 | def test_remove_listener(self, event: Event, listener1: Mock, listener2: Mock): 39 | assert listener2 not in event.listeners 40 | event.remove_listener(listener2) 41 | assert listener1 in event.listeners 42 | 43 | event.add_listener(listener2) 44 | event.remove_listener(listener1) 45 | assert listener1 not in event.listeners 46 | assert listener2 in event.listeners 47 | 48 | def test_notify(self, event: Event, listener1: Mock, listener2: Mock): 49 | listener1.assert_not_called() 50 | listener2.assert_not_called() 51 | 52 | event.notify() 53 | listener1.assert_called() 54 | listener2.assert_not_called() 55 | 56 | # Test notification after adding a listener 57 | listener1.reset_mock() 58 | event.add_listener(listener2) 59 | event.notify() 60 | listener1.assert_called() 61 | listener2.assert_called() 62 | 63 | # Test notification after removing a listener 64 | listener1.reset_mock() 65 | listener2.reset_mock() 66 | event.remove_listener(listener1) 67 | event.notify() 68 | listener1.assert_not_called() 69 | listener2.assert_called() 70 | 71 | # Try notifying without any listeners 72 | listener1.reset_mock() 73 | listener2.reset_mock() 74 | event.listeners.clear() 75 | assert not event.listeners 76 | event.notify() 77 | listener1.assert_not_called() 78 | listener2.assert_not_called() 79 | 80 | 81 | class TestEventManager: 82 | def test_create_event(self, event_manager: EventManager, listener1: Mock): 83 | initial_len = len(event_manager._event_list) 84 | event = event_manager.create_event() 85 | event.add_listener(listener1) 86 | assert len(event_manager._event_list) - initial_len == 1 87 | assert isinstance(event_manager._event_list[-1], Event) 88 | 89 | def test_purge_all_event_listeners(self, event_manager: EventManager, listener2: Mock): 90 | event_manager.create_event().add_listener(listener2) 91 | assert len(event_manager._event_list) == 2 92 | 93 | # Verify events in the manager have listeners associated 94 | for event in event_manager._event_list: 95 | assert len(event.listeners) == 1 96 | 97 | # Purge listeners and verifying listeners are cleared but events still exist 98 | event_manager.purge_all_event_listeners() 99 | for event in event_manager._event_list: 100 | assert len(event.listeners) == 0 101 | assert len(event_manager._event_list) == 2 102 | 103 | def test_purge_all_events(self, event_manager: EventManager, listener2: Mock): 104 | """Purges all events in the event list by removing 105 | all associated events and listeners 106 | """ 107 | test_event = event_manager.create_event() 108 | test_event.add_listener(listener2) 109 | assert len(event_manager._event_list) == 2 110 | 111 | # Test purging everything 112 | event_manager.purge_all_events() 113 | assert len(event_manager._event_list) == 0 114 | 115 | # Test firing a previously linked event 116 | test_event.notify() 117 | listener2.assert_not_called() 118 | -------------------------------------------------------------------------------- /src/cli_chess/tests/modules/move_list/test_move_list_model.py: -------------------------------------------------------------------------------- 1 | from cli_chess.modules.move_list import MoveListModel 2 | from cli_chess.modules.board import BoardModel 3 | from chess import WHITE, BLACK, PIECE_SYMBOLS, KING, QUEEN, BISHOP, PAWN 4 | from unittest.mock import Mock 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def model_listener(): 10 | return Mock() 11 | 12 | 13 | @pytest.fixture() 14 | def model(model_listener: Mock): 15 | model = MoveListModel(BoardModel()) 16 | model.e_move_list_model_updated.add_listener(model_listener) 17 | return model 18 | 19 | 20 | def test_update(model: MoveListModel, model_listener: Mock): 21 | # Verify this method is listening to board model updates 22 | assert model.update in model.board_model.e_board_model_updated.listeners 23 | 24 | model.board_model.set_fen("2bqkbnr/P2ppppp/8/8/8/8/1PPPPPPP/RNBQK2R w KQk - 0 1") 25 | assert len(model.move_list_data) == 0 26 | 27 | # Test castling 28 | model.board_model.make_move("O-O") 29 | move_data = { 30 | 'turn': WHITE, 31 | 'move': 'O-O', 32 | 'piece_type': KING, 33 | 'piece_symbol': PIECE_SYMBOLS[KING], 34 | 'is_castling': True, 35 | 'is_promotion': False 36 | } 37 | assert model.move_list_data == [move_data] 38 | 39 | # Test invalid move 40 | with pytest.raises(ValueError): 41 | model.board_model.make_move("Ke7") 42 | assert model.move_list_data == [move_data] 43 | 44 | # Test black turn 45 | model.board_model.make_move("c8b7") 46 | assert model.move_list_data[-1] == { 47 | 'turn': BLACK, 48 | 'move': 'Bb7', 49 | 'piece_type': BISHOP, 50 | 'piece_symbol': PIECE_SYMBOLS[BISHOP], 51 | 'is_castling': False, 52 | 'is_promotion': False 53 | } 54 | 55 | # Test promotion 56 | model.board_model.make_move("a8Q") 57 | assert model.move_list_data[-1] == { 58 | 'turn': WHITE, 59 | 'move': 'a8=Q', 60 | 'piece_type': PAWN, 61 | 'piece_symbol': PIECE_SYMBOLS[PAWN], 62 | 'is_castling': False, 63 | 'is_promotion': True 64 | } 65 | 66 | # Test a null move 67 | model.board_model.make_move("0000") 68 | assert model.move_list_data[-1] == { 69 | 'turn': BLACK, 70 | 'move': '--', 71 | 'piece_type': None, 72 | 'piece_symbol': None, 73 | 'is_castling': False, 74 | 'is_promotion': False 75 | } 76 | 77 | # Test crazyhouse drop piece 78 | model = MoveListModel(BoardModel(variant="crazyhouse", fen="kr6/1Q6/8/8/8/8/8/K7 b - - 0 1")) 79 | model.board_model.make_move("Rb7") 80 | model.board_model.make_move("Ka2") 81 | model.board_model.make_move("Q@a7") 82 | assert model.move_list_data[-1] == { 83 | 'turn': BLACK, 84 | 'move': 'Q@a7#', 85 | 'piece_type': QUEEN, 86 | 'piece_symbol': PIECE_SYMBOLS[QUEEN], 87 | 'is_castling': False, 88 | 'is_promotion': False 89 | } 90 | 91 | # Verify the move list model update notification is sent to listeners 92 | model_listener.assert_called() 93 | 94 | 95 | def test_get_move_list_data(model: MoveListModel): 96 | assert len(model.get_move_list_data()) == 0 97 | model.board_model.set_fen("1n6/NpP5/1P1PP1b1/k2pR3/2pK4/6r1/1p3P2/8 b - - 0 1") 98 | model.board_model.make_move("Be4") 99 | assert model.get_move_list_data() == [{ 100 | 'turn': BLACK, 101 | 'move': 'Be4', 102 | 'piece_type': BISHOP, 103 | 'piece_symbol': 'b', 104 | 'is_castling': False, 105 | 'is_promotion': False 106 | }] 107 | assert model.get_move_list_data() == model.move_list_data 108 | model.board_model.takeback(BLACK) 109 | assert model.get_move_list_data() == [] 110 | 111 | 112 | def test_notify_move_list_model_updated(model: MoveListModel, model_listener: Mock): 113 | # Test registered successful move listener is called 114 | model._notify_move_list_model_updated() 115 | model_listener.assert_called() 116 | 117 | # Unregister listener and test it's not called 118 | model_listener.reset_mock() 119 | model.e_move_list_model_updated.remove_listener(model_listener) 120 | model._notify_move_list_model_updated() 121 | model_listener.assert_not_called() 122 | -------------------------------------------------------------------------------- /src/cli_chess/modules/token_manager/token_manager_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.utils.ui_common import handle_mouse_click, handle_bound_key_pressed 3 | from prompt_toolkit.layout import Window, ConditionalContainer, VSplit, HSplit, D 4 | from prompt_toolkit.key_binding import KeyBindings 5 | from prompt_toolkit.keys import Keys 6 | from prompt_toolkit.formatted_text import StyleAndTextTuples 7 | from prompt_toolkit.widgets import Label, Box, TextArea, ValidationToolbar 8 | from prompt_toolkit.validation import Validator 9 | from prompt_toolkit.filters import Condition 10 | from prompt_toolkit.application import get_app 11 | from typing import TYPE_CHECKING 12 | if TYPE_CHECKING: 13 | from cli_chess.modules.token_manager import TokenManagerPresenter 14 | 15 | 16 | class TokenManagerView: 17 | def __init__(self, presenter: TokenManagerPresenter): 18 | self.presenter = presenter 19 | self.container_width = 40 20 | self.lichess_username = "" 21 | self._token_input = self._create_token_input_area() 22 | self._container = self._create_container() 23 | 24 | def _create_token_input_area(self): 25 | """Creates and returns the TextArea used for token input""" 26 | validator = Validator.from_callable( 27 | self.presenter.update_linked_account, 28 | error_message="Invalid token or missing scopes", 29 | move_cursor_to_end=True, 30 | ) 31 | 32 | return TextArea( 33 | validator=validator, 34 | accept_handler=lambda x: True, 35 | style="class:text-area.input", 36 | focus_on_click=True, 37 | wrap_lines=True, 38 | multiline=False, 39 | width=D(max=self.container_width), 40 | height=D(max=1), 41 | ) 42 | 43 | def _create_container(self) -> HSplit: 44 | """Creates the container for the token manager view""" 45 | return HSplit([ 46 | Label(f"{'Authenticate with Lichess':<{self.container_width}}", style="class:menu.category-title", wrap_lines=False), 47 | VSplit([ 48 | Label("API Token: ", style="bold", dont_extend_width=True), 49 | ConditionalContainer( 50 | TextArea("Input token and press enter", style="class:text-area.input.placeholder", focus_on_click=True), 51 | filter=Condition(lambda: not self.has_focus()) & Condition(lambda: len(self._token_input.text) == 0) 52 | ), 53 | ConditionalContainer(self._token_input, Condition(lambda: self.has_focus()) | Condition(lambda: len(self._token_input.text) > 0)), 54 | ], height=D(max=1)), 55 | ValidationToolbar(), 56 | Box(Window(), height=D(max=1)), 57 | VSplit([ 58 | Label("Linked account: ", dont_extend_width=True), 59 | ConditionalContainer(Label("None", style="class:label.error bold italic"), Condition(lambda: not self.lichess_username)), 60 | ConditionalContainer(Label(text=lambda: self.lichess_username, style="class:label.success bold"), Condition(lambda: self.lichess_username != "")), # noqa: E501 61 | ], height=D(max=1)), 62 | ], width=D(max=self.container_width), height=D(preferred=8)) 63 | 64 | def get_function_bar_fragments(self) -> StyleAndTextTuples: 65 | """Returns a set of function bar fragments to use if 66 | this module is hooked up with a function bar 67 | """ 68 | return [ 69 | ("class:function-bar.key", "F1", handle_mouse_click(self.presenter.open_token_creation_url)), 70 | ("class:function-bar.label", f"{'Open token creation URL':<25}", handle_mouse_click(self.presenter.open_token_creation_url)), 71 | ] 72 | 73 | def get_function_bar_key_bindings(self) -> KeyBindings: 74 | """Returns a set of key bindings to use if this 75 | module is hooked up with a function bar 76 | """ 77 | kb = KeyBindings() 78 | kb.add(Keys.F1)(handle_bound_key_pressed(self.presenter.open_token_creation_url)) 79 | return kb 80 | 81 | def has_focus(self): 82 | """Returns true if this container has focus""" 83 | has_focus = get_app().layout.has_focus(self._container) 84 | if has_focus: 85 | get_app().layout.focus(self._token_input) 86 | return has_focus 87 | 88 | def __pt_container__(self) -> HSplit: 89 | return self._container 90 | -------------------------------------------------------------------------------- /src/cli_chess/menus/versus_menus/versus_menu_models.py: -------------------------------------------------------------------------------- 1 | from cli_chess.menus import MultiValueMenuModel, MultiValueMenuOption, MenuCategory 2 | from cli_chess.core.game.game_options import GameOption, OfflineGameOptions, OnlinePublicGameOptions, OnlineDirectChallengesGameOptions 3 | 4 | 5 | class VersusMenuModel(MultiValueMenuModel): 6 | def __init__(self, menu: MenuCategory): 7 | self.menu = menu 8 | super().__init__(self.menu) 9 | 10 | 11 | class OfflineVsComputerMenuModel(VersusMenuModel): 12 | def __init__(self): 13 | self.menu = self._create_menu() 14 | super().__init__(self.menu) 15 | 16 | @staticmethod 17 | def _create_menu() -> MenuCategory: 18 | """Create the offline menu options""" 19 | menu_options = [ 20 | MultiValueMenuOption(GameOption.VARIANT, "Choose the variant to play", [option for option in OfflineGameOptions.variant_options_dict]), # noqa: E501 21 | MultiValueMenuOption(GameOption.SPECIFY_ELO, "Would you like the computer to play as a specific Elo?", ["No", "Yes"]), 22 | MultiValueMenuOption(GameOption.COMPUTER_SKILL_LEVEL, "Choose the skill level of the computer", [option for option in OfflineGameOptions.skill_level_options_dict]), # noqa: E501 23 | MultiValueMenuOption(GameOption.COMPUTER_ELO, "Choose the Elo of the computer", list(range(500, 2850, 25)), visible=False), 24 | MultiValueMenuOption(GameOption.COLOR, "Choose the side you would like to play as", [option for option in OfflineGameOptions.color_options]), # noqa: E501 25 | ] 26 | return MenuCategory("Play Offline vs Computer", menu_options) 27 | 28 | def show_elo_selection_option(self, show: bool): 29 | """Show/hide the Computer Elo option. Enabling the 'Specify Elo' selection 30 | Will disable the 'Computer SKill' Level option as only of these can be set 31 | """ 32 | # Todo: Figure out a cleaner way so a loop isn't required 33 | for i, opt in enumerate(self.menu.category_options): 34 | if opt.option == GameOption.COMPUTER_ELO: 35 | opt.visible = show 36 | if opt.option == GameOption.COMPUTER_SKILL_LEVEL: 37 | opt.visible = not show 38 | 39 | 40 | class OnlineVsComputerMenuModel(VersusMenuModel): 41 | def __init__(self): 42 | self.menu = self._create_menu() 43 | super().__init__(self.menu) 44 | 45 | @staticmethod 46 | def _create_menu() -> MenuCategory: 47 | """Create the online menu options""" 48 | menu_options = [ 49 | MultiValueMenuOption(GameOption.VARIANT, "Choose the variant to play", [option for option in OnlineDirectChallengesGameOptions.variant_options_dict]), # noqa: E501 50 | MultiValueMenuOption(GameOption.TIME_CONTROL, "Choose the time control", [option for option in OnlineDirectChallengesGameOptions.time_control_options_dict]), # noqa: E501 51 | MultiValueMenuOption(GameOption.COMPUTER_SKILL_LEVEL, "Choose the skill level of the computer", [option for option in OnlineDirectChallengesGameOptions.skill_level_options_dict]), # noqa: E501 52 | MultiValueMenuOption(GameOption.COLOR, "Choose the side you would like to play as", [option for option in OnlineDirectChallengesGameOptions.color_options]), # noqa: E501 53 | ] 54 | return MenuCategory("Play Online vs Computer", menu_options) 55 | 56 | 57 | class OnlineVsRandomOpponentMenuModel(VersusMenuModel): 58 | def __init__(self): 59 | self.menu = self._create_menu() 60 | super().__init__(self.menu) 61 | 62 | @staticmethod 63 | def _create_menu() -> MenuCategory: 64 | """Create the online menu options""" 65 | menu_options = [ 66 | MultiValueMenuOption(GameOption.VARIANT, "Choose the variant to play", [option for option in OnlinePublicGameOptions.variant_options_dict]), # noqa: E501 67 | MultiValueMenuOption(GameOption.TIME_CONTROL, "Choose the time control", [option for option in OnlinePublicGameOptions.time_control_options_dict]), # noqa: E501 68 | MultiValueMenuOption(GameOption.RATED, "Choose if you'd like to play a casual or rated game", [option for option in OnlinePublicGameOptions.rated_options_dict]), # noqa: E501 69 | MultiValueMenuOption(GameOption.COLOR, "The side you play for an online public game is determined by Lichess", [option for option in OnlinePublicGameOptions.color_options]), # noqa: E501 70 | ] 71 | return MenuCategory("Play Online vs Random Opponent", menu_options) 72 | -------------------------------------------------------------------------------- /src/cli_chess/tests/modules/material_difference/test_material_difference_presenter.py: -------------------------------------------------------------------------------- 1 | from cli_chess.modules.material_difference import MaterialDifferenceModel, MaterialDifferencePresenter 2 | from cli_chess.modules.board import BoardModel 3 | from cli_chess.utils.config import GameConfig 4 | from chess import WHITE, BLACK 5 | from os import remove 6 | from unittest.mock import Mock 7 | import pytest 8 | 9 | 10 | @pytest.fixture 11 | def model(): 12 | return MaterialDifferenceModel(BoardModel()) 13 | 14 | 15 | @pytest.fixture 16 | def presenter(model: MaterialDifferenceModel, game_config: GameConfig, monkeypatch): 17 | monkeypatch.setattr('cli_chess.modules.material_difference.material_difference_presenter.game_config', game_config) 18 | return MaterialDifferencePresenter(model) 19 | 20 | 21 | @pytest.fixture 22 | def game_config(): 23 | game_config = GameConfig("unit_test_config.ini") 24 | yield game_config 25 | remove(game_config.full_filename) 26 | 27 | 28 | def test_update(model: MaterialDifferenceModel, presenter: MaterialDifferencePresenter, game_config: GameConfig): 29 | # Verify the update method is listening to model updates 30 | assert presenter.update in model.e_material_difference_model_updated.listeners 31 | 32 | # Verify the update method is listening to game configuration updates 33 | assert presenter.update in game_config.e_game_config_updated.listeners 34 | 35 | # Verify the presenter update function is calling the material difference 36 | # views (white/black) and with the proper data 37 | model.board_model.set_fen("6Q1/1P2P3/4n1B1/2b3n1/KNP3p1/5k2/2pq3P/1N6 w - - 0 1") 38 | presenter.view_upper.update = Mock() 39 | presenter.view_lower.update = Mock() 40 | view_upper_data = presenter.format_diff_output(BLACK) 41 | view_lower_data = presenter.format_diff_output(WHITE) 42 | presenter.update() 43 | presenter.view_upper.update.assert_called_with(view_upper_data) 44 | presenter.view_lower.update.assert_called_with(view_lower_data) 45 | 46 | 47 | def test_format_diff_output(model: MaterialDifferenceModel, presenter: MaterialDifferencePresenter, game_config: GameConfig): 48 | assert presenter.format_diff_output(WHITE) == "" 49 | assert presenter.format_diff_output(BLACK) == "" 50 | game_config.set_value(game_config.Keys.PAD_UNICODE, "no") 51 | 52 | # Test white advantage 53 | game_config.set_value(game_config.Keys.SHOW_MATERIAL_DIFF_IN_UNICODE, "yes") 54 | model.board_model.set_fen("1Q1B1K2/p3p3/p1PN4/p3q3/3N3k/pB6/P2r4/8 w - - 0 1") 55 | assert presenter.format_diff_output(WHITE) == "♝♝♞♞+4" 56 | assert presenter.format_diff_output(BLACK) == "♜♙♙♙" 57 | 58 | game_config.set_value(game_config.Keys.SHOW_MATERIAL_DIFF_IN_UNICODE, "no") 59 | assert presenter.format_diff_output(WHITE) == "BBNN+4" 60 | assert presenter.format_diff_output(BLACK) == "RPPP" 61 | 62 | # Test black advantage 63 | game_config.set_value(game_config.Keys.SHOW_MATERIAL_DIFF_IN_UNICODE, "yes") 64 | model.board_model.set_fen("3n4/1p4P1/1b1Kp3/1p1P2P1/1P1P2pk/1p6/pr6/8 w - - 0 1") 65 | assert presenter.format_diff_output(WHITE) == "" 66 | assert presenter.format_diff_output(BLACK) == "♜♝♞♙+12" 67 | 68 | game_config.set_value(game_config.Keys.SHOW_MATERIAL_DIFF_IN_UNICODE, "no") 69 | assert presenter.format_diff_output(WHITE) == "" 70 | assert presenter.format_diff_output(BLACK) == "RBNP+12" 71 | 72 | # Test no advantage 73 | game_config.set_value(game_config.Keys.SHOW_MATERIAL_DIFF_IN_UNICODE, "yes") 74 | model.board_model.set_fen("r1bqk2r/pppp1ppp/2n5/2b1p3/4P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4") 75 | assert presenter.format_diff_output(WHITE) == "♞" 76 | assert presenter.format_diff_output(BLACK) == "♝" 77 | 78 | game_config.set_value(game_config.Keys.SHOW_MATERIAL_DIFF_IN_UNICODE, "no") 79 | assert presenter.format_diff_output(WHITE) == "N" 80 | assert presenter.format_diff_output(BLACK) == "B" 81 | 82 | # Test 3check output 83 | model = MaterialDifferenceModel(BoardModel(fen="8/1P2N2P/1P5N/5p2/bB1k4/K1n4P/4B1pr/6R1 b - - 0 1", variant="3check")) 84 | presenter = MaterialDifferencePresenter(model) 85 | game_config.set_value(game_config.Keys.SHOW_MATERIAL_DIFF_IN_UNICODE, "yes") 86 | assert presenter.format_diff_output(WHITE) == "♝♞♙♙+8" 87 | assert presenter.format_diff_output(BLACK) == "" 88 | model.board_model.make_move("Nb5") 89 | 90 | assert presenter.format_diff_output(WHITE) == "♝♞♙♙+8" 91 | assert presenter.format_diff_output(BLACK) == "♚" 92 | 93 | game_config.set_value(game_config.Keys.SHOW_MATERIAL_DIFF_IN_UNICODE, "no") 94 | assert presenter.format_diff_output(WHITE) == "BNPP+8" 95 | assert presenter.format_diff_output(BLACK) == "K" 96 | 97 | # Test unicode padding 98 | game_config.set_value(game_config.Keys.SHOW_MATERIAL_DIFF_IN_UNICODE, "yes") 99 | game_config.set_value(game_config.Keys.PAD_UNICODE, "yes") 100 | assert presenter.format_diff_output(WHITE) == "♝ ♞ ♙ ♙ +8" 101 | assert presenter.format_diff_output(BLACK) == "♚ " 102 | -------------------------------------------------------------------------------- /src/cli_chess/core/api/game_state_dispatcher.py: -------------------------------------------------------------------------------- 1 | from cli_chess.utils import Event, EventTopics, log, retry 2 | from typing import Callable 3 | from threading import Thread 4 | from enum import Enum, auto 5 | from types import MappingProxyType 6 | 7 | 8 | class GSDEventTopics(Enum): 9 | CHAT_RECEIVED = auto() 10 | OPPONENT_GONE = auto() 11 | NOT_IMPLEMENTED = auto() 12 | 13 | 14 | gsd_type_to_event_dict = MappingProxyType({ 15 | "gameFull": EventTopics.GAME_START, 16 | "gameState": EventTopics.MOVE_MADE, 17 | "chatLine": GSDEventTopics.CHAT_RECEIVED, 18 | "opponentGone": GSDEventTopics.OPPONENT_GONE, 19 | }) 20 | 21 | 22 | class GameStateDispatcher(Thread): 23 | """Handles streaming a game and sending game commands (make move, offer draw, etc) 24 | using the Board API. The game that is streamed using this class must be owned 25 | by the account linked to the api token. 26 | """ 27 | def __init__(self, game_id=""): 28 | super().__init__() 29 | self.game_id = game_id 30 | self.is_game_over = False 31 | self.e_game_state_dispatcher_event = Event() 32 | 33 | try: 34 | from cli_chess.core.api.api_manager import api_client 35 | self.api_client = api_client 36 | except ImportError: 37 | # TODO: Clean this up so the error is displayed on the main screen 38 | log.error("Failed to import api_client") 39 | raise ImportError("API client not setup. Do you have an API token linked?") 40 | 41 | def run(self): 42 | """This is the threads main function. It handles emitting the game state to 43 | listeners (typically the OnlineGameModel). 44 | """ 45 | log.info(f"Started streaming game state: {self.game_id}") 46 | 47 | for event in self.api_client.board.stream_game_state(self.game_id): 48 | event_topic = gsd_type_to_event_dict.get(event['type'], GSDEventTopics.NOT_IMPLEMENTED) 49 | log.debug(f"GSD Stream event type received: {event['type']} // topic: {event_topic}") 50 | 51 | if event_topic is EventTopics.MOVE_MADE: 52 | status = event.get('status', None) 53 | self.is_game_over = status and status != "started" and status != "created" 54 | 55 | elif event_topic is GSDEventTopics.OPPONENT_GONE: 56 | is_gone = event.get('gone', False) 57 | secs_until_claim = event.get('claimWinInSeconds', None) 58 | 59 | if is_gone and secs_until_claim: 60 | pass # TODO implement call to auto-claim win when `secs_until_claim` elapses 61 | 62 | if not is_gone: 63 | pass # TODO: Cancel auto-claim countdown 64 | 65 | game_end_event = EventTopics.GAME_END if self.is_game_over else None 66 | self.e_game_state_dispatcher_event.notify(event_topic, game_end_event, data=event) 67 | 68 | if self.is_game_over: 69 | self._game_ended() 70 | 71 | log.info(f"Completed streaming of: {self.game_id}") 72 | 73 | @retry(times=3, exceptions=(Exception, )) 74 | def make_move(self, move: str): 75 | """Sends the move to lichess. This move should have already 76 | been verified as valid in the current context of the board. 77 | The move must be in UCI format. 78 | """ 79 | log.debug(f"Sending move ({move}) to lichess") 80 | self.api_client.board.make_move(self.game_id, move) 81 | 82 | @retry(times=3, exceptions=(Exception,)) 83 | def send_takeback_request(self) -> None: 84 | """Sends a takeback request to our opponent""" 85 | log.debug("Sending takeback offer to opponent") 86 | self.api_client.board.offer_takeback(self.game_id) 87 | 88 | @retry(times=3, exceptions=(Exception,)) 89 | def send_draw_offer(self) -> None: 90 | """Sends a draw offer to our opponent""" 91 | log.debug("Sending draw offer to opponent") 92 | self.api_client.board.offer_draw(self.game_id) 93 | 94 | @retry(times=3, exceptions=(Exception,)) 95 | def resign(self) -> None: 96 | """Resigns the game""" 97 | log.debug("Sending resignation") 98 | self.api_client.board.resign_game(self.game_id) 99 | 100 | @retry(times=3, exceptions=(Exception,)) 101 | def claim_victory(self) -> None: 102 | """Submits a claim of victory to lichess as the opponent is gone. 103 | This is to only be called when the opponentGone timer has elapsed. 104 | """ 105 | pass 106 | 107 | def _game_ended(self) -> None: 108 | """Handles removing all event listeners since the game has completed""" 109 | log.info("GAME ENDED: Removing existing GSD listeners") 110 | self.is_game_over = True 111 | self.e_game_state_dispatcher_event.remove_all_listeners() 112 | 113 | def add_event_listener(self, listener: Callable) -> None: 114 | """Subscribes the passed in method to GSD events""" 115 | self.e_game_state_dispatcher_event.add_listener(listener) 116 | -------------------------------------------------------------------------------- /src/cli_chess/modules/token_manager/token_manager_model.py: -------------------------------------------------------------------------------- 1 | from cli_chess.utils.config import lichess_config 2 | from cli_chess.utils import Event, log, threaded 3 | from berserk import Client, TokenSession 4 | import berserk.exceptions 5 | 6 | linked_token_scopes = set() 7 | 8 | 9 | class TokenManagerModel: 10 | def __init__(self): 11 | self.base_url = "" 12 | self.linked_account = "" 13 | self.e_token_manager_model_updated = Event() 14 | 15 | def set_base_url(self, url: str): 16 | """Sets the base URL for all requests to go to""" 17 | self.base_url = url 18 | log.debug(f"Base URL set to: {self.base_url}") 19 | 20 | @threaded 21 | def validate_existing_linked_account(self) -> None: 22 | """Queries the Lichess config file for an existing token. If a token 23 | exists, verification is attempted. Invalid data will be cleared. 24 | """ 25 | try: 26 | existing_token = lichess_config.get_value(lichess_config.Keys.API_TOKEN) 27 | account = self.validate_token(existing_token) 28 | if account: 29 | self.save_account_data(api_token=existing_token, account_data=account, valid=True) 30 | else: 31 | self.save_account_data(api_token="", account_data={}) 32 | except Exception as e: 33 | # Rather than the token being invalid, this means there was a 34 | # connection problem. Ignore so the existing token is not overridden. 35 | if not isinstance(e, berserk.exceptions.ApiError): 36 | log.error(f"Unexpected exception caught: {e}") 37 | 38 | def update_linked_account(self, api_token: str) -> bool: 39 | """Attempts to update the linked account using the passed in API token. 40 | If the token is deemed valid, the api token is saved to the Lichess 41 | configuration. Returns True on success. Existing account data is 42 | only overwritten on success. 43 | """ 44 | if api_token: 45 | try: 46 | account = self.validate_token(api_token) 47 | if account: 48 | log.info("Updating linked Lichess account") 49 | self.save_account_data(api_token=api_token, account_data=account, valid=True) 50 | return True 51 | except Exception as e: 52 | log.error(f"Error updating linked account: {e}") 53 | return False 54 | return False 55 | 56 | def validate_token(self, api_token: str) -> dict: 57 | """Validates the proper scopes are available for the passed in token. 58 | Returns the scopes and userId associated to the passed in token. 59 | """ 60 | if api_token: 61 | session = TokenSession(api_token) 62 | oauth_client = Client(session, base_url=self.base_url).oauth 63 | try: 64 | token_data = oauth_client.test_tokens(api_token) 65 | 66 | if token_data.get(api_token): 67 | found_scopes = set() 68 | for scope in token_data[api_token]['scopes'].split(sep=","): 69 | found_scopes.add(scope) 70 | 71 | from cli_chess.core.api.api_manager import required_token_scopes 72 | if found_scopes >= required_token_scopes: 73 | global linked_token_scopes 74 | linked_token_scopes.clear() 75 | linked_token_scopes = found_scopes 76 | 77 | log.info("Successfully authenticated with Lichess") 78 | return token_data[api_token] 79 | else: 80 | log.error("Valid token but missing required scopes") 81 | 82 | except Exception as e: 83 | log.error(f"Error validating token: {e}") 84 | raise 85 | 86 | def save_account_data(self, api_token: str, account_data: dict, valid=False) -> None: 87 | """Saves the passed in lichess api token to the configuration. 88 | It is assumed the passed in token has already been verified 89 | """ 90 | lichess_config.set_value(lichess_config.Keys.API_TOKEN, api_token) 91 | if valid: 92 | self.linked_account = account_data.get('userId', "") 93 | self._handle_start_api(api_token) 94 | 95 | self._notify_token_manager_model_updated() 96 | 97 | def _handle_start_api(self, api_token: str) -> None: 98 | """Handles starting the API using the supplied token. 99 | This function should only ever be called by this model. 100 | """ 101 | if api_token.strip(): 102 | from cli_chess.core.api.api_manager import _start_api # noqa 103 | _start_api(api_token, self.base_url) 104 | 105 | def _notify_token_manager_model_updated(self) -> None: 106 | """Notifies listeners of token manager model updates""" 107 | self.e_token_manager_model_updated.notify() 108 | 109 | 110 | g_token_manager_model = TokenManagerModel() 111 | -------------------------------------------------------------------------------- /.github/workflows/build-fairy-sf-binaries.yml: -------------------------------------------------------------------------------- 1 | name: Build Fairy-Stockfish Binaries 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | fairy_sf_tag: 7 | type: string 8 | default: fairy_sf_14 9 | description: Fairy-Stockfish repo tag 10 | required: true 11 | create_pull_request: 12 | type: boolean 13 | description: Create PR with new binaries 14 | 15 | jobs: 16 | linux_x86-64: 17 | runs-on: ubuntu-latest 18 | env: 19 | binary: fairy-stockfish_x86-64_linux 20 | steps: 21 | - name: Clone Fairy-Stockfish @ tag:${{ inputs.fairy_sf_tag }} 22 | uses: actions/checkout@master 23 | with: 24 | repository: fairy-stockfish/Fairy-Stockfish 25 | ref: refs/tags/${{ inputs.fairy_sf_tag }} 26 | 27 | - name: Build 28 | run: | 29 | cd src 30 | make clean 31 | make -j build COMP=gcc ARCH=x86-64 EXE=${{ env.binary }} 32 | strip ${{ env.binary }} 33 | 34 | - uses: actions/upload-artifact@v3 35 | with: 36 | name: dir-${{ env.binary }} 37 | path: src/${{ env.binary }} 38 | 39 | windows_x86-64: 40 | runs-on: windows-2022 41 | env: 42 | binary: fairy-stockfish_x86-64_windows 43 | steps: 44 | - name: Clone Fairy-Stockfish @ tag:${{ inputs.fairy_sf_tag }} 45 | uses: actions/checkout@master 46 | with: 47 | repository: fairy-stockfish/Fairy-Stockfish 48 | ref: refs/tags/${{ inputs.fairy_sf_tag }} 49 | 50 | - name: Build 51 | run: | 52 | cd src 53 | make clean 54 | make -j build COMP=mingw ARCH=x86-64 EXE=${{ env.binary }}.exe 55 | strip ${{ env.binary }}.exe 56 | 57 | - uses: actions/upload-artifact@v3 58 | with: 59 | name: dir-${{ env.binary }} 60 | path: src/${{ env.binary }}.exe 61 | 62 | macos_x86-64: 63 | runs-on: macos-12 64 | env: 65 | binary: fairy-stockfish_x86-64_macos 66 | steps: 67 | - name: Clone Fairy-Stockfish @ tag:${{ inputs.fairy_sf_tag }} 68 | uses: actions/checkout@master 69 | with: 70 | repository: fairy-stockfish/Fairy-Stockfish 71 | ref: refs/tags/${{ inputs.fairy_sf_tag }} 72 | 73 | - name: Build 74 | run: | 75 | cd src 76 | make clean 77 | make -j build COMP=clang ARCH=x86-64 EXE=${{ env.binary }} 78 | strip ${{ env.binary }} 79 | 80 | - uses: actions/upload-artifact@v3 81 | with: 82 | name: dir-${{ env.binary }} 83 | path: src/${{ env.binary }} 84 | 85 | macos_arm64: 86 | runs-on: macos-12 87 | env: 88 | binary: fairy-stockfish_arm64_macos 89 | steps: 90 | - name: Clone Fairy-Stockfish @ tag:${{ inputs.fairy_sf_tag }} 91 | uses: actions/checkout@master 92 | with: 93 | repository: fairy-stockfish/Fairy-Stockfish 94 | ref: refs/tags/${{ inputs.fairy_sf_tag }} 95 | 96 | - name: Build 97 | run: | 98 | cd src 99 | make clean 100 | make -j build COMP=clang ARCH=apple-silicon EXE=${{ env.binary }} 101 | strip ${{ env.binary }} 102 | 103 | - uses: actions/upload-artifact@v3 104 | with: 105 | name: dir-${{ env.binary }} 106 | path: src/${{ env.binary }} 107 | 108 | create_pr: 109 | if: github.event.inputs.create_pull_request == 'true' 110 | runs-on: ubuntu-latest 111 | needs: 112 | - linux_x86-64 113 | - windows_x86-64 114 | - macos_x86-64 115 | - macos_arm64 116 | env: 117 | COMMIT_PATH: src/cli_chess/modules/engine/binaries 118 | BRANCH_NAME: workflow/update-binaries-${{github.run_id}} 119 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 120 | steps: 121 | - name: Clone cli-chess 122 | uses: actions/checkout@v3 123 | 124 | - name: Download Fairy-Stockfish artifacts 125 | uses: actions/download-artifact@v4.1.7 126 | with: 127 | path: ${{ env.COMMIT_PATH }} 128 | 129 | - name: Extract and make binaries executable 130 | run: | 131 | find dir-* -type f -exec mv -f '{}' ./ ';' 132 | chmod +x fairy-stockfish* 133 | rm -rf dir-* 134 | ls -la 135 | working-directory: ${{ env.COMMIT_PATH }} 136 | 137 | - name: Create pull request 138 | run: | 139 | cd $COMMIT_PATH 140 | git config user.name 'github-actions[bot]' 141 | git config user.email 'github-actions[bot]@users.noreply.github.com' 142 | git checkout -b $BRANCH_NAME 143 | git add fairy-stockfish* 144 | git commit -m "Update Fairy-Stockfish binaries" 145 | git push -u origin $BRANCH_NAME 146 | gh pr create -B master -H $BRANCH_NAME \ 147 | --title "Update Fairy-Stockfish binaries using tag@${{ inputs.fairy_sf_tag }}" \ 148 | --body "Generated from GitHub workflow [run #${{github.run_id}}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" 149 | -------------------------------------------------------------------------------- /src/cli_chess/core/game/offline_game/offline_game_model.py: -------------------------------------------------------------------------------- 1 | from cli_chess.core.game import PlayableGameModelBase 2 | from cli_chess.modules.engine import EngineModel 3 | from cli_chess.core.game.game_options import GameOption 4 | from cli_chess.utils import EventTopics, log 5 | from cli_chess.utils.config import player_info_config 6 | from chess import COLOR_NAMES 7 | from typing import Optional, Dict 8 | 9 | 10 | class OfflineGameModel(PlayableGameModelBase): 11 | def __init__(self, game_parameters: dict): 12 | super().__init__(play_as_color=game_parameters[GameOption.COLOR], 13 | variant=game_parameters[GameOption.VARIANT]) 14 | 15 | self.engine_model = EngineModel(self.board_model, game_parameters) 16 | self.game_in_progress = True 17 | self._update_game_metadata(EventTopics.GAME_PARAMS, data=game_parameters) 18 | 19 | def update(self, *args, **kwargs) -> None: 20 | """Called automatically as part of an event listener. This method 21 | listens to subscribed model update events and if deemed necessary 22 | triages and notifies listeners of the event. 23 | """ 24 | if EventTopics.GAME_END in args: 25 | self._report_game_over() 26 | 27 | super().update(*args, **kwargs) 28 | 29 | def make_move(self, move: str): 30 | """Sends the move to the board model for it to be made""" 31 | if self.game_in_progress: 32 | try: 33 | if self.board_model.board.is_game_over(): 34 | self.game_in_progress = False 35 | raise Warning("Game has already ended") 36 | 37 | if not self.is_my_turn(): 38 | raise Warning("Not your turn") 39 | 40 | self.board_model.make_move(move.strip()) 41 | self.premove_model.clear_premove() 42 | 43 | except Exception: 44 | raise 45 | else: 46 | log.warning("Attempted to make a move in a game that's not in progress") 47 | raise Warning("Game has already ended") 48 | 49 | def set_premove(self, move: str) -> None: 50 | """Sets the premove""" 51 | self.premove_model.set_premove(move) 52 | 53 | def propose_takeback(self) -> None: 54 | """Take back the previous move""" 55 | try: 56 | if self.board_model.board.is_game_over(): 57 | raise Warning("Game has already ended") 58 | 59 | self.premove_model.clear_premove() 60 | self.board_model.takeback(self.my_color) 61 | except Exception as e: 62 | log.error(f"Takeback failed - {e}") 63 | raise 64 | 65 | def offer_draw(self) -> None: 66 | raise Warning("Offline engine does not accept draw offers") 67 | 68 | def resign(self) -> None: 69 | """Handles resigning the game""" 70 | if self.game_in_progress: 71 | try: 72 | self.board_model.handle_resignation(self.my_color) 73 | except Exception: 74 | raise 75 | else: 76 | log.warning("Attempted to resign a game that's not in progress") 77 | raise Warning("Game has already ended") 78 | 79 | def _update_game_metadata(self, *args, data: Optional[Dict] = None, **kwargs) -> None: 80 | """Parses and saves the data of the game being played""" 81 | if not data: 82 | return 83 | try: 84 | if EventTopics.GAME_PARAMS in args: 85 | self.game_metadata.variant = data[GameOption.VARIANT] 86 | self.game_metadata.players[self.my_color].name = player_info_config.get_value(player_info_config.Keys.OFFLINE_PLAYER_NAME) # noqa: E501 87 | 88 | engine_name = "Fairy-Stockfish" 89 | engine_name = engine_name + f" Lvl {data.get(GameOption.COMPUTER_SKILL_LEVEL)}" if not data.get(GameOption.SPECIFY_ELO) else engine_name # noqa: E501 90 | self.game_metadata.players[not self.my_color].name = engine_name 91 | self.game_metadata.players[not self.my_color].rating = data.get(GameOption.COMPUTER_ELO, "") 92 | 93 | self._notify_game_model_updated(*args, **kwargs) 94 | except KeyError as e: 95 | log.error(f"Error saving offline game metadata: {e}") 96 | 97 | def _report_game_over(self) -> None: 98 | """Saves game information and notifies listeners that the game has ended. 99 | This should only ever be called if the game is confirmed to be over 100 | """ 101 | self.game_in_progress = False 102 | outcome = self.board_model.get_game_over_result() 103 | self.game_metadata.game_status.status = outcome.termination 104 | self.game_metadata.game_status.winner = COLOR_NAMES[outcome.winner] 105 | 106 | log.info(f"Game over (status={outcome.termination} winner={COLOR_NAMES[outcome.winner]})") 107 | log.debug(f"Ending fen: {self.board_model.board.fen()}") 108 | log.debug(f"Final move stack: {self.board_model.get_move_stack(as_string=True)}") 109 | 110 | self._notify_game_model_updated(EventTopics.GAME_END) 111 | -------------------------------------------------------------------------------- /src/cli_chess/core/game/game_options.py: -------------------------------------------------------------------------------- 1 | from cli_chess.utils.common import str_to_bool 2 | from enum import Enum 3 | from types import MappingProxyType 4 | from typing import Dict 5 | from abc import ABC, abstractmethod 6 | 7 | 8 | class GameOption(Enum): 9 | VARIANT = "Variant" 10 | TIME_CONTROL = "Time Control" 11 | COMPUTER_SKILL_LEVEL = "Computer Level" 12 | SPECIFY_ELO = "Specify Elo" 13 | COMPUTER_ELO = "Computer Elo" 14 | RATED = "Rated" 15 | RATING_RANGE = "Rating Range" 16 | COLOR = "Side to play as" 17 | 18 | 19 | class BaseGameOptions(ABC): 20 | """Holds base options universal to Online/Offline games""" 21 | @abstractmethod 22 | def __init__(self): 23 | self.dict_map = {} 24 | 25 | def create_game_parameters_dict(self, menu_selections: dict) -> Dict: 26 | """Lookup menu selections and replace with proper values. A dict 27 | is returned containing the game parameters. 28 | """ 29 | game_parameters = {} 30 | for key in menu_selections: 31 | try: 32 | value = menu_selections[key] 33 | if key == GameOption.SPECIFY_ELO: 34 | value = str_to_bool(value) 35 | 36 | opt_dict = self.dict_map.get(key) 37 | if opt_dict: 38 | game_parameters[key] = opt_dict.get(value) 39 | else: 40 | game_parameters[key] = value 41 | except KeyError: 42 | pass 43 | return game_parameters 44 | 45 | variant_options_dict = MappingProxyType({ 46 | "Standard": "standard", 47 | "Crazyhouse": "crazyhouse", 48 | "Chess960": "chess960", 49 | "King of the Hill": "kingOfTheHill", 50 | "Three-check": "threeCheck", 51 | "Antichess": "antichess", 52 | "Atomic": "atomic", 53 | "Horde": "horde", 54 | "Racing Kings": "racingKings" 55 | }) 56 | 57 | time_control_options_dict = MappingProxyType({ 58 | "30+20 (Classical)": (30, 20), 59 | "30+0 (Classical)": (30, 0), 60 | "15+10 (Rapid)": (15, 10), 61 | "10+5 (Rapid)": (10, 5), 62 | "10+0 (Rapid)": (10, 0) 63 | }) 64 | 65 | skill_level_options_dict = MappingProxyType({ 66 | "Level 1": 1, 67 | "Level 2": 2, 68 | "Level 3": 3, 69 | "Level 4": 4, 70 | "Level 5": 5, 71 | "Level 6": 6, 72 | "Level 7": 7, 73 | "Level 8": 8 74 | }) 75 | 76 | color_options = MappingProxyType({ 77 | "Random": "random", 78 | "White": "white", 79 | "Black": "black" 80 | }) 81 | 82 | 83 | class OfflineGameOptions(BaseGameOptions): 84 | """Game Options class with defined options for playing offline games""" 85 | def __init__(self): 86 | super().__init__() 87 | self.dict_map = { 88 | GameOption.VARIANT: BaseGameOptions.variant_options_dict, 89 | GameOption.TIME_CONTROL: self.time_control_options_dict, 90 | GameOption.COMPUTER_SKILL_LEVEL: BaseGameOptions.skill_level_options_dict, 91 | GameOption.SPECIFY_ELO: None, 92 | GameOption.COMPUTER_ELO: None, 93 | GameOption.COLOR: BaseGameOptions.color_options, 94 | } 95 | 96 | time_control_options_dict = dict(BaseGameOptions.time_control_options_dict) 97 | additional_time_controls = { 98 | "5+3 (Blitz)": (5, 3), 99 | "5+0 (Blitz)": (5, 0), 100 | "3+2 (Blitz)": (3, 2), 101 | "3+0 (Blitz)": (3, 0), 102 | "2+1 (Bullet)": (2, 1), 103 | "1+0 (Bullet)": (1, 0), 104 | } 105 | time_control_options_dict.update(additional_time_controls) 106 | 107 | 108 | class OnlinePublicGameOptions(BaseGameOptions): 109 | """Game Options class with defined options permitted for public seek board API use""" 110 | def __init__(self): 111 | super().__init__() 112 | self.dict_map = { 113 | GameOption.VARIANT: BaseGameOptions.variant_options_dict, 114 | GameOption.TIME_CONTROL: BaseGameOptions.time_control_options_dict, 115 | GameOption.RATED: self.rated_options_dict, 116 | GameOption.COLOR: self.color_options 117 | } 118 | 119 | color_options = { 120 | "Random": "random" # Online public seeks must be random (lila PR# 15969) 121 | } 122 | 123 | rated_options_dict = { 124 | "No": False, 125 | "Yes": True 126 | } 127 | 128 | 129 | class OnlineDirectChallengesGameOptions(BaseGameOptions): 130 | """Game Options class with defined options for direct challenges. 131 | Brings in the additional side to play as and permitted time controls options. 132 | """ 133 | def __init__(self): 134 | super().__init__() 135 | self.dict_map = { 136 | GameOption.VARIANT: BaseGameOptions.variant_options_dict, 137 | GameOption.TIME_CONTROL: self.time_control_options_dict, 138 | GameOption.COMPUTER_SKILL_LEVEL: BaseGameOptions.skill_level_options_dict, 139 | GameOption.COLOR: BaseGameOptions.color_options, 140 | } 141 | 142 | time_control_options_dict = dict(BaseGameOptions.time_control_options_dict) 143 | additional_time_controls = { 144 | "5+3 (Blitz)": (5, 3), 145 | "5+0 (Blitz)": (5, 0), 146 | "3+2 (Blitz)": (3, 2), 147 | "3+0 (Blitz)": (3, 0) 148 | } 149 | time_control_options_dict.update(additional_time_controls) 150 | -------------------------------------------------------------------------------- /src/cli_chess/core/game/online_game/online_game_presenter.py: -------------------------------------------------------------------------------- 1 | from cli_chess.core.game import PlayableGamePresenterBase 2 | from cli_chess.core.game.online_game import OnlineGameModel, OnlineGameView 3 | from cli_chess.utils.ui_common import change_views 4 | from cli_chess.utils import log, AlertType, EventTopics 5 | from chess import Color, COLOR_NAMES 6 | 7 | 8 | def start_online_game(game_parameters: dict, is_vs_ai: bool) -> None: 9 | """Start an online game. If `is_vs_ai` is True a challenge will be sent to 10 | the Lichess AI (stockfish). Otherwise, a seek vs a random opponent will be created 11 | """ 12 | model = OnlineGameModel(game_parameters, is_vs_ai) 13 | presenter = OnlineGamePresenter(model) 14 | change_views(presenter.view, presenter.view.input_field_container) # noqa 15 | model.create_game() 16 | 17 | 18 | class OnlineGamePresenter(PlayableGamePresenterBase): 19 | def __init__(self, model: OnlineGameModel): 20 | self.model = model 21 | super().__init__(model) 22 | 23 | def _get_view(self) -> OnlineGameView: 24 | """Sets and returns the view to use""" 25 | return OnlineGameView(self) 26 | 27 | def update(self, *args, **kwargs) -> None: 28 | """Update method called on game model updates. Overrides base.""" 29 | super().update(*args, **kwargs) 30 | if EventTopics.GAME_SEARCH in args: 31 | self.view.alert.show_alert("Searching for opponent...", AlertType.NEUTRAL) 32 | elif EventTopics.ERROR in args: 33 | self.view.alert.show_alert(kwargs.get('msg', "An unspecified error has occurred"), AlertType.ERROR) 34 | 35 | def _parse_and_present_game_over(self) -> None: 36 | """Triages game over status for parsing and sending to the view for display""" 37 | if not self.is_game_in_progress(): 38 | status = self.model.game_metadata.game_status.status 39 | winner_str = self.model.game_metadata.game_status.winner 40 | 41 | if winner_str: # Handle win/loss output 42 | self._display_win_loss_output(status, winner_str) 43 | else: # Handle draw, no start, abort output 44 | self._display_no_winner_output(status) 45 | else: 46 | log.error("Attempted to present game over status when the game is not over") 47 | 48 | def _display_win_loss_output(self, status: str, winner_str: str) -> None: 49 | """Generates the win/loss result reason string and sends to the view for display. 50 | The winner string must either be `white` or `black`. 51 | """ 52 | if winner_str.lower() not in COLOR_NAMES: 53 | log.error(f"Received game over with invalid winner string: {winner_str} // {status}") 54 | self.view.alert.show_alert("Game over", AlertType.ERROR) 55 | return 56 | 57 | winner_bool = Color(COLOR_NAMES.index(winner_str)) 58 | loser_str = COLOR_NAMES[not winner_bool].capitalize() 59 | output = f" • {winner_str.capitalize()} is victorious" 60 | 61 | # NOTE: Status strings can be found in lichess source (lila status.ts) 62 | if status == "mate": 63 | output = "Checkmate" + output 64 | elif status == "resign": 65 | output = f"{loser_str} resigned" + output 66 | elif status == "timeout": 67 | output = f"{loser_str} left the game" + output 68 | elif status == "outoftime": 69 | output = f"{loser_str} time out" + output 70 | elif status == "cheat": 71 | output = "Cheat detected" + output 72 | elif status == "variantEnd": 73 | variant = self.model.board_model.get_variant_name() 74 | if variant == "3check": 75 | output = "Three Checks" + output 76 | elif variant == "kingofthehill": 77 | output = "King in the center" + output 78 | elif variant == "racingkings": 79 | output = "Race finished" + output 80 | else: 81 | output = "Variant ending" + output 82 | else: 83 | log.debug(f"Received game over with uncaught status: {status} / {winner_str}") 84 | output = "Game over" + output 85 | 86 | alert_type = AlertType.SUCCESS if self.model.my_color == winner_bool else AlertType.ERROR 87 | self.view.alert.show_alert(output, alert_type) 88 | 89 | def _display_no_winner_output(self, status: str) -> None: 90 | """Generates the game result reason string and sends to the view for display. 91 | This function is specific to games which do not have a winner (draw, abort, etc.) 92 | """ 93 | output = "Game over" 94 | if status: 95 | if status == "aborted": 96 | output = "Game aborted" 97 | if status == "noStart": 98 | output = "Game over • No start" 99 | elif status == "draw": 100 | output = "Game over • Draw" 101 | elif status == "stalemate": 102 | output = "Draw • Stalemate" 103 | else: 104 | log.debug(f"Received game over with uncaught status: {status}") 105 | 106 | self.view.alert.show_alert(output, AlertType.NEUTRAL) 107 | 108 | def is_vs_ai(self) -> bool: 109 | """Returns true if the game being played is versus lichess AI""" 110 | return self.model.vs_ai 111 | 112 | def exit(self) -> None: 113 | """Exits the game and returns to the main menu""" 114 | self.model.exit() 115 | super().exit() 116 | -------------------------------------------------------------------------------- /src/cli_chess/tests/modules/move_list/test_move_list_presenter.py: -------------------------------------------------------------------------------- 1 | from cli_chess.modules.move_list import MoveListModel, MoveListPresenter 2 | from cli_chess.modules.board import BoardModel 3 | from cli_chess.utils.config import GameConfig 4 | from os import remove 5 | from unittest.mock import Mock 6 | import pytest 7 | 8 | 9 | @pytest.fixture 10 | def model(): 11 | return MoveListModel(BoardModel()) 12 | 13 | 14 | @pytest.fixture 15 | def presenter(model: MoveListModel, game_config: GameConfig, monkeypatch): 16 | monkeypatch.setattr('cli_chess.modules.move_list.move_list_presenter.game_config', game_config) 17 | return MoveListPresenter(model) 18 | 19 | 20 | @pytest.fixture 21 | def game_config(): 22 | game_config = GameConfig("unit_test_config.ini") 23 | yield game_config 24 | remove(game_config.full_filename) 25 | 26 | 27 | def test_update(model: MoveListModel, presenter: MoveListPresenter, game_config: GameConfig): 28 | # Verify the update method is listening to model updates 29 | assert presenter.update in model.e_move_list_model_updated.listeners 30 | 31 | # Verify the update method is listening to game configuration updates 32 | assert presenter.update in game_config.e_game_config_updated.listeners 33 | 34 | # Verify the presenter update function is calling the move list view 35 | # update function and passing in the formatted move data 36 | model.board_model.make_move("e4") 37 | presenter.view.update = Mock() 38 | presenter.update() 39 | move_data = presenter.get_formatted_move_list() 40 | presenter.view.update.assert_called_with(move_data) 41 | 42 | 43 | def test_get_formatted_move_list(presenter: MoveListPresenter, game_config: GameConfig): 44 | model = MoveListModel(BoardModel(fen="3pkb1r/P4pp1/8/8/8/8/1PPP3p/R2NKP2 w Qk - 0 40")) 45 | presenter.model = model 46 | game_config.set_value(game_config.Keys.PAD_UNICODE, "no") 47 | 48 | # Test empty move list 49 | assert presenter.get_formatted_move_list() == [] 50 | 51 | # Test unicode move list formatting 52 | game_config.set_value(game_config.Keys.SHOW_MOVE_LIST_IN_UNICODE, "yes") 53 | moves = ["d4", "f5", "Nc3", "Bd6"] 54 | for move in moves: 55 | model.board_model.make_move(move) 56 | assert presenter.get_formatted_move_list() == ["d4", "f5", "♞c3", "♝d6"] 57 | 58 | # Test non-unicode move list formatting 59 | game_config.set_value(game_config.Keys.SHOW_MOVE_LIST_IN_UNICODE, "no") 60 | assert presenter.get_formatted_move_list() == ["d4", "f5", "Nc3", "Bd6"] 61 | 62 | # Test move promotion formatting 63 | game_config.set_value(game_config.Keys.SHOW_MOVE_LIST_IN_UNICODE, "yes") 64 | model.board_model.make_move("a8=N") 65 | model.board_model.make_move("h2h1Q") 66 | assert presenter.get_formatted_move_list() == ["d4", "f5", "♞c3", "♝d6", "a8=♞", "h1=♛"] 67 | 68 | # Test unicode padding 69 | game_config.set_value(game_config.Keys.PAD_UNICODE, "yes") 70 | assert presenter.get_formatted_move_list() == ["d4", "f5", "♞ c3", "♝ d6", "a8=♞", "h1=♛"] 71 | game_config.set_value(game_config.Keys.PAD_UNICODE, "no") 72 | 73 | # Test castling output 74 | model.board_model.make_move("e1c1") 75 | model.board_model.make_move("O-O") 76 | assert presenter.get_formatted_move_list() == ["d4", "f5", "♞c3", "♝d6", "a8=♞", "h1=♛", "O-O-O", "O-O"] 77 | 78 | # Test move list formatting when the first move is black 79 | game_config.set_value(game_config.Keys.SHOW_MOVE_LIST_IN_UNICODE, "no") 80 | model = MoveListModel(BoardModel(fen="8/1PK5/8/8/8/8/4kp2/8 b - - 2 70")) 81 | presenter.model = model 82 | model.board_model.make_move("f1Q") 83 | assert presenter.get_formatted_move_list() == ["...", "f1=Q"] 84 | 85 | # Verify move list data is still produced on blindfold chess 86 | game_config.set_value(game_config.Keys.BLINDFOLD_CHESS, "yes") 87 | assert presenter.get_formatted_move_list() == ["...", "f1=Q"] 88 | 89 | 90 | def test_get_move_as_unicode(presenter: MoveListPresenter, game_config: GameConfig): 91 | game_config.set_value(game_config.Keys.SHOW_MOVE_LIST_IN_UNICODE, "yes") 92 | model = MoveListModel(BoardModel(fen="r3kbn1/p2p3P/8/8/5p2/8/p3P3/RNBQK2R w KQq - 0 1")) 93 | presenter.model = model 94 | 95 | # Test pawn move 96 | model.board_model.make_move("e3") 97 | assert presenter.get_move_as_unicode(model.get_move_list_data()[-1]) == "e3" 98 | 99 | # Test pawn capture 100 | model.board_model.make_move("f4e3") 101 | assert presenter.get_move_as_unicode(model.get_move_list_data()[-1]) == "fxe3" 102 | 103 | # Test pawn promotion 104 | model.board_model.make_move("h8=N") 105 | assert presenter.get_move_as_unicode(model.get_move_list_data()[-1]) == "h8=♞" 106 | 107 | # Test pawn promotion with capture 108 | model.board_model.make_move("axb1R") 109 | assert presenter.get_move_as_unicode(model.get_move_list_data()[-1]) == "axb1=♜" 110 | 111 | # Test bishop move 112 | model.board_model.make_move("Bd2") 113 | assert presenter.get_move_as_unicode(model.get_move_list_data()[-1]) == "♝d2" 114 | 115 | # Test queenside castle 116 | model.board_model.make_move("O-O-O") 117 | assert presenter.get_move_as_unicode(model.get_move_list_data()[-1]) == "O-O-O" 118 | 119 | # Test kingside castle 120 | model.board_model.make_move("e1g1") 121 | assert presenter.get_move_as_unicode(model.get_move_list_data()[-1]) == "O-O" 122 | 123 | # Test null move 124 | model.board_model.make_move("0000") 125 | assert presenter.get_move_as_unicode(model.get_move_list_data()[-1]) == "--" 126 | -------------------------------------------------------------------------------- /src/cli_chess/core/game/game_presenter_base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.core.game import GameViewBase, PlayableGameViewBase 3 | from cli_chess.modules.board import BoardPresenter 4 | from cli_chess.modules.move_list import MoveListPresenter 5 | from cli_chess.modules.material_difference import MaterialDifferencePresenter 6 | from cli_chess.modules.player_info import PlayerInfoPresenter 7 | from cli_chess.modules.clock import ClockPresenter 8 | from cli_chess.modules.premove import PremovePresenter 9 | from cli_chess.utils import log, AlertType, RequestSuccessfullySent, EventTopics 10 | from abc import ABC, abstractmethod 11 | from typing import TYPE_CHECKING 12 | if TYPE_CHECKING: 13 | from cli_chess.core.game import GameModelBase, PlayableGameModelBase 14 | 15 | 16 | class GamePresenterBase(ABC): 17 | def __init__(self, model: GameModelBase): 18 | self.model = model 19 | self.board_presenter = BoardPresenter(model.board_model) 20 | self.move_list_presenter = MoveListPresenter(model.move_list_model) 21 | self.material_diff_presenter = MaterialDifferencePresenter(model.material_diff_model) 22 | self.player_info_presenter = PlayerInfoPresenter(model) 23 | self.clock_presenter = ClockPresenter(model) 24 | self.view = self._get_view() 25 | 26 | self.model.e_game_model_updated.add_listener(self.update) 27 | log.debug(f"Created {type(self).__name__} (id={id(self)})") 28 | 29 | @abstractmethod 30 | def _get_view(self) -> GameViewBase: 31 | """Returns the view to use for this presenter""" 32 | pass 33 | 34 | @abstractmethod 35 | def update(self, *args, **kwargs) -> None: 36 | """Listens to game model updates when notified. 37 | See model for specific kwargs that are currently being sent. 38 | """ 39 | if EventTopics.GAME_START in args: 40 | self.view.alert.clear_alert() 41 | 42 | def flip_board(self) -> None: 43 | """Flip the board orientation""" 44 | self.model.board_model.set_board_orientation(not self.model.board_model.get_board_orientation()) 45 | 46 | def exit(self) -> None: 47 | """Exit current presenter/view""" 48 | log.debug("Exiting game presenter") 49 | self.model.cleanup() 50 | self.view.exit() 51 | 52 | 53 | class PlayableGamePresenterBase(GamePresenterBase, ABC): 54 | def __init__(self, model: PlayableGameModelBase): 55 | self.premove_presenter = PremovePresenter(model.premove_model) 56 | super().__init__(model) 57 | self.model = model 58 | 59 | @abstractmethod 60 | def _get_view(self) -> PlayableGameViewBase: 61 | """Returns the view to use for this presenter""" 62 | return PlayableGameViewBase(self) 63 | 64 | @abstractmethod 65 | def is_vs_ai(self) -> bool: 66 | """Inheriting classes must specify if the game 67 | is versus AI (offline engine or Lichess) 68 | """ 69 | pass 70 | 71 | def update(self, *args, **kwargs) -> None: 72 | """Update method called on game model updates. Overrides base.""" 73 | super().update(*args, **kwargs) 74 | if EventTopics.MOVE_MADE in args: 75 | self.view.alert.clear_alert() 76 | if EventTopics.GAME_END in args: 77 | self._parse_and_present_game_over() 78 | self.premove_presenter.clear_premove() 79 | 80 | def user_input_received(self, inpt: str) -> None: 81 | """Respond to the users input. This input can either be the 82 | move input, or game actions (such as resign) 83 | """ 84 | try: 85 | inpt_lower = inpt.lower() 86 | if inpt_lower == "resign" or inpt_lower == "quit" or inpt_lower == "exit": 87 | self.resign() 88 | elif inpt_lower == "draw" or inpt_lower == "offer draw": 89 | self.offer_draw() 90 | elif inpt_lower == "takeback" or inpt_lower == "back" or inpt_lower == "undo": 91 | self.propose_takeback() 92 | elif self.model.is_my_turn(): 93 | self.make_move(inpt) 94 | else: 95 | self.model.set_premove(inpt) 96 | except Exception as e: 97 | self.view.alert.show_alert(str(e)) 98 | 99 | def make_move(self, move: str) -> None: 100 | """Make the passed in move on the board""" 101 | try: 102 | move = move.strip() 103 | if move: 104 | self.model.make_move(move) 105 | except Exception as e: 106 | self.view.alert.show_alert(str(e)) 107 | 108 | def propose_takeback(self) -> None: 109 | """Proposes a takeback""" 110 | try: 111 | self.model.propose_takeback() 112 | except Exception as e: 113 | if isinstance(e, RequestSuccessfullySent): 114 | self.view.alert.show_alert(str(e), AlertType.NEUTRAL) 115 | else: 116 | self.view.alert.show_alert(str(e)) 117 | 118 | def offer_draw(self) -> None: 119 | """Offers a draw""" 120 | try: 121 | self.model.offer_draw() 122 | except Exception as e: 123 | if isinstance(e, RequestSuccessfullySent): 124 | self.view.alert.show_alert(str(e), AlertType.NEUTRAL) 125 | else: 126 | self.view.alert.show_alert(str(e)) 127 | 128 | def resign(self) -> None: 129 | """Resigns the game""" 130 | try: 131 | if self.model.game_in_progress: 132 | self.model.resign() 133 | else: 134 | self.exit() 135 | except Exception as e: 136 | self.view.alert.show_alert(str(e)) 137 | 138 | def is_game_in_progress(self) -> bool: 139 | return self.model.game_in_progress 140 | 141 | @abstractmethod 142 | def _parse_and_present_game_over(self) -> str: 143 | pass 144 | -------------------------------------------------------------------------------- /src/cli_chess/menus/main_menu/main_menu_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from cli_chess.menus import MenuView 3 | from cli_chess.menus.main_menu import MainMenuOptions 4 | from cli_chess.core.api.api_manager import api_is_ready 5 | from cli_chess.__metadata__ import __url__ 6 | from prompt_toolkit.layout import Container, ConditionalContainer, VSplit, HSplit 7 | from prompt_toolkit.key_binding import KeyBindings, ConditionalKeyBindings, merge_key_bindings 8 | from prompt_toolkit.keys import Keys 9 | from prompt_toolkit.formatted_text import StyleAndTextTuples 10 | from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous 11 | from prompt_toolkit.filters import Condition, is_done 12 | from prompt_toolkit.widgets import Box, TextArea 13 | from typing import TYPE_CHECKING 14 | if TYPE_CHECKING: 15 | from cli_chess.menus.main_menu import MainMenuPresenter 16 | 17 | 18 | class MainMenuView(MenuView): 19 | def __init__(self, presenter: MainMenuPresenter): 20 | self.presenter = presenter 21 | super().__init__(self.presenter, container_width=15) 22 | self.main_menu_container = self._create_main_menu() 23 | 24 | def _create_main_menu(self) -> Container: 25 | """Creates the container for the main menu""" 26 | return HSplit([ 27 | VSplit([ 28 | Box(self._container, padding=0, padding_right=1), 29 | ConditionalContainer( 30 | Box(self.presenter.offline_games_menu_presenter.view, padding=0, padding_right=1), 31 | filter=~is_done 32 | & Condition(lambda: self.presenter.selection == MainMenuOptions.OFFLINE_GAMES) 33 | ), 34 | ConditionalContainer( 35 | TextArea( 36 | "Missing API Token or API client unavailable.\n" 37 | "Go to 'Settings' to link your Lichess API token.\n\n" 38 | "For further assistance check out the Github page:\n" 39 | f"{__url__}", 40 | wrap_lines=True, read_only=True, focusable=False 41 | ), 42 | filter=~is_done 43 | & Condition(lambda: self.presenter.selection == MainMenuOptions.ONLINE_GAMES) 44 | & ~Condition(api_is_ready) 45 | ), 46 | ConditionalContainer( 47 | Box(self.presenter.online_games_menu_presenter.view, padding=0, padding_right=1), 48 | filter=~is_done 49 | & Condition(lambda: self.presenter.selection == MainMenuOptions.ONLINE_GAMES) 50 | & Condition(api_is_ready) 51 | ), 52 | ConditionalContainer( 53 | Box(self.presenter.settings_menu_presenter.view, padding=0, padding_right=1), 54 | filter=~is_done 55 | & Condition(lambda: self.presenter.selection == MainMenuOptions.SETTINGS) 56 | ), 57 | ConditionalContainer( 58 | Box(self.presenter.about_presenter.view, padding=0, padding_right=1), 59 | filter=~is_done 60 | & Condition(lambda: self.presenter.selection == MainMenuOptions.ABOUT) 61 | ) 62 | ]), 63 | ], key_bindings=self.get_key_bindings()) 64 | 65 | @staticmethod 66 | def get_key_bindings() -> KeyBindings: 67 | """Returns the key bindings for this container""" 68 | bindings = KeyBindings() 69 | bindings.add(Keys.Right)(focus_next) 70 | bindings.add(Keys.ControlF)(focus_next) 71 | bindings.add(Keys.Tab)(focus_next) 72 | bindings.add(Keys.Left)(focus_previous) 73 | bindings.add(Keys.ControlB)(focus_previous) 74 | bindings.add(Keys.BackTab)(focus_previous) 75 | return bindings 76 | 77 | def get_function_bar_fragments(self) -> StyleAndTextTuples: 78 | """Returns the appropriate function bar fragments based on menu item selection""" 79 | fragments: StyleAndTextTuples = [] 80 | if self.presenter.selection == MainMenuOptions.ONLINE_GAMES: 81 | fragments = self.presenter.online_games_menu_presenter.view.get_function_bar_fragments() 82 | elif self.presenter.selection == MainMenuOptions.OFFLINE_GAMES: 83 | fragments = self.presenter.offline_games_menu_presenter.view.get_function_bar_fragments() 84 | elif self.presenter.selection == MainMenuOptions.SETTINGS: 85 | fragments = self.presenter.settings_menu_presenter.view.get_function_bar_fragments() 86 | elif self.presenter.selection == MainMenuOptions.ABOUT: 87 | fragments = self.presenter.about_presenter.view.get_function_bar_fragments() 88 | return fragments 89 | 90 | def get_function_bar_key_bindings(self) -> "_MergedKeyBindings": # noqa: F821 91 | """Returns the appropriate function bar key bindings based on menu item selection""" 92 | online_games_kb = ConditionalKeyBindings( 93 | self.presenter.online_games_menu_presenter.view.get_function_bar_key_bindings(), 94 | filter=Condition(lambda: self.presenter.selection == MainMenuOptions.ONLINE_GAMES) 95 | ) 96 | 97 | offline_games_kb = ConditionalKeyBindings( 98 | self.presenter.offline_games_menu_presenter.view.get_function_bar_key_bindings(), 99 | filter=Condition(lambda: self.presenter.selection == MainMenuOptions.OFFLINE_GAMES) 100 | ) 101 | 102 | settings_kb = ConditionalKeyBindings( 103 | self.presenter.settings_menu_presenter.view.get_function_bar_key_bindings(), 104 | filter=Condition(lambda: self.presenter.selection == MainMenuOptions.SETTINGS) 105 | ) 106 | 107 | about_kb = ConditionalKeyBindings( 108 | self.presenter.about_presenter.view.get_function_bar_key_bindings(), 109 | filter=Condition(lambda: self.presenter.selection == MainMenuOptions.ABOUT) 110 | ) 111 | return merge_key_bindings([online_games_kb, offline_games_kb, settings_kb, about_kb]) 112 | 113 | def __pt_container__(self) -> Container: 114 | return self.main_menu_container 115 | -------------------------------------------------------------------------------- /src/cli_chess/modules/material_difference/material_difference_model.py: -------------------------------------------------------------------------------- 1 | from cli_chess.modules.board import BoardModel 2 | from cli_chess.utils import EventManager 3 | from typing import Dict 4 | from chess import PIECE_SYMBOLS, PIECE_TYPES, PieceType, Color, COLORS, WHITE, BLACK, PAWN, KNIGHT, BISHOP, ROOK, QUEEN, KING 5 | import re 6 | 7 | PIECE_VALUE: Dict[PieceType, int] = { 8 | KING: 0, 9 | QUEEN: 9, 10 | ROOK: 5, 11 | BISHOP: 3, 12 | KNIGHT: 3, 13 | PAWN: 1, 14 | } 15 | 16 | 17 | class MaterialDifferenceModel: 18 | def __init__(self, board_model: BoardModel): 19 | self.board_model = board_model 20 | self.board_model.e_board_model_updated.add_listener(self.update) 21 | 22 | self.material_difference: Dict[Color, Dict[PieceType, int]] = self.default_material_difference() 23 | self.score: Dict[Color, int] = self.default_score() 24 | 25 | self._event_manager = EventManager() 26 | self.e_material_difference_model_updated = self._event_manager.create_event() 27 | self.update() 28 | 29 | @staticmethod 30 | def default_material_difference() -> Dict[Color, Dict[PieceType, int]]: 31 | """Returns a default material difference dictionary""" 32 | return { 33 | WHITE: {KING: 0, QUEEN: 0, ROOK: 0, BISHOP: 0, KNIGHT: 0, PAWN: 0}, 34 | BLACK: {KING: 0, QUEEN: 0, ROOK: 0, BISHOP: 0, KNIGHT: 0, PAWN: 0} 35 | } 36 | 37 | @staticmethod 38 | def default_score() -> Dict[Color, int]: 39 | """Returns a default score dictionary""" 40 | return {WHITE: 0, BLACK: 0} 41 | 42 | @staticmethod 43 | def generate_pieces_fen(board_fen: str) -> str: 44 | """Generates a fen containing pieces only by 45 | parsing the passed in board fen 46 | Example: rnbqkbnrppppppppPPPPPPPPRNBQKBNR""" 47 | pieces_fen = "" 48 | if board_fen: 49 | regex = re.compile('[^a-zA-Z]') 50 | pieces_fen = regex.sub('', board_fen) 51 | return pieces_fen 52 | 53 | def _reset_all(self) -> None: 54 | """Reset variables to default state""" 55 | self.material_difference = self.default_material_difference() 56 | self.score = self.default_score() 57 | 58 | def update(self, *args, **kwargs) -> None: # noqa 59 | """Update the material difference using the latest board FEN""" 60 | variant = self.board_model.get_variant_name() 61 | 62 | if variant != "horde": 63 | self._reset_all() 64 | 65 | if variant == "crazyhouse": # Show material difference in pocket format 66 | self._update_material_difference_crazyhouse(variant) 67 | else: 68 | pieces_fen = self.generate_pieces_fen(self.board_model.board.board_fen()) 69 | 70 | # Todo: Update to use chess.Piece()? 71 | for piece in pieces_fen: 72 | color = WHITE if piece.isupper() else BLACK 73 | piece_type = PIECE_SYMBOLS.index(piece.lower()) 74 | 75 | self._update_material_difference(color, piece_type) 76 | self._update_score(color, piece_type) 77 | 78 | if variant == "3check": 79 | self.material_difference[WHITE][KING] = 3 - self.board_model.board.remaining_checks[WHITE] 80 | self.material_difference[BLACK][KING] = 3 - self.board_model.board.remaining_checks[BLACK] 81 | 82 | self._notify_material_difference_model_updated() 83 | 84 | def _update_material_difference(self, color: Color, piece_type: PieceType) -> None: 85 | """Updates the material difference based on the passed in piece""" 86 | if piece_type in PIECE_TYPES: 87 | opponent_piece_type_count = self.material_difference[not color][piece_type] 88 | 89 | if opponent_piece_type_count > 0: 90 | self.material_difference[not color][piece_type] -= 1 91 | else: 92 | self.material_difference[color][piece_type] += 1 93 | 94 | def _update_material_difference_crazyhouse(self, variant: str) -> None: 95 | """Updates the material difference to represent the crazyhouse pocket data. 96 | This function should only ever be called on confirmed crazyhouse games. 97 | """ 98 | if variant == "crazyhouse": 99 | for color in COLORS: 100 | for _, piece in enumerate(str(self.board_model.board.pockets[color])): 101 | piece_type = PIECE_SYMBOLS.index(piece.lower()) 102 | self.material_difference[color][piece_type] += 1 103 | 104 | def _update_score(self, color: Color, piece_type: PieceType) -> None: 105 | """Uses the material difference to 106 | calculate the score for each side""" 107 | if piece_type in PIECE_TYPES: 108 | self.score[color] += PIECE_VALUE[piece_type] 109 | 110 | advantage_color = WHITE if self.score[WHITE] > self.score[BLACK] else BLACK 111 | difference = abs(self.score[WHITE] - self.score[BLACK]) 112 | self.score[advantage_color] = difference 113 | self.score[not advantage_color] = 0 114 | 115 | def get_material_difference(self, color: Color) -> Dict[PieceType, int]: 116 | """Returns the material difference dictionary associated to the passed in color""" 117 | return self.material_difference[color] 118 | 119 | def get_score(self, color: Color) -> int: 120 | """Returns the material difference 121 | score for the passed in color""" 122 | return self.score[color] 123 | 124 | def get_board_orientation(self) -> Color: 125 | """Returns the orientation of the board""" 126 | return self.board_model.get_board_orientation() 127 | 128 | def _notify_material_difference_model_updated(self) -> None: 129 | """Notifies listeners of material difference model updates""" 130 | self.e_material_difference_model_updated.notify() 131 | 132 | def cleanup(self) -> None: 133 | """Handles model cleanup tasks. This should only ever 134 | be run when this model is no longer needed. 135 | """ 136 | self._event_manager.purge_all_events() 137 | --------------------------------------------------------------------------------