├── 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}{square_style}>"
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 |
--------------------------------------------------------------------------------