diff --git a/.env.dist b/.env.dist index f368de6..d441c3a 100644 --- a/.env.dist +++ b/.env.dist @@ -1,3 +1,5 @@ # This file will never be used in production, so it's ok to commit this secret key :-) SECRET_KEY=not-a-security-issue DATABASE_URL=sqlite:///db.sqlite3 + +LICHESS_CLIENT_ID=zakuchess-local-dev diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 924a10d..0da555f 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -19,31 +19,41 @@ jobs: runs-on: "ubuntu-latest" steps: + # Setup - uses: "actions/checkout@v4" - - name: Set up uv - # Install a specific uv version using the installer - run: "curl -LsSf https://astral.sh/uv/${UV_VERSION}/install.sh | sh" - env: - UV_VERSION: "0.4.4" - - name: Set up Python + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "0.5.7" + enable-cache: true + cache-dependency-glob: "uv.lock" + - name: Install Python, via uv run: uv python install - name: "Install dependencies via uv" run: uv sync --all-extras + - name: "Install the project in 'editable' mode" + run: uv pip install --no-build -e . + + # Code quality checks - name: "Run linting checks: Ruff checker" - run: uv run ruff format --check --quiet src/ + run: uv run --no-sync ruff format --check --quiet src/ - name: "Run linting checks: Ruff linter" - run: uv run ruff check --quiet src/ + run: uv run --no-sync ruff check --quiet src/ - name: "Run linting checks: Mypy" - run: uv run mypy src/ + run: uv run --no-sync mypy src/ + - name: "Run linting checks: fix-future-annotations" + run: uv run --no-sync fix-future-annotations src/ - name: "Check that Django DB migrations are up to date" - run: uv run python manage.py makemigrations | grep "No changes detected" + run: uv run --no-sync python manage.py makemigrations --check + + # Test suite & code coverage - name: "Run tests" - # TODO: progressively increase minimum coverage to something closer to 80% - run: uv run pytest --cov=src --cov-report xml:coverage.xml + # TODO: progressively increase minimum coverage to something closer to 80% + run: uv run --no-sync pytest --cov=src --cov-report xml:coverage.xml # --cov-fail-under=60 --> we'll actually do that with the "Report coverage" step - name: "Report coverage" # @link https://github.com/orgoro/coverage - uses: "orgoro/coverage@v3.1" + uses: "orgoro/coverage@v3.2" continue-on-error: true with: coverageFile: coverage.xml diff --git a/.gitignore b/.gitignore index c803548..2e0adce 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,7 @@ # uv-related stuff: /bin/uv /bin/uvx -/.uv.env -/.uv.env.fish +*.egg-info /.docker/* !/.docker/.gitkeep diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4c2b8d4..7515b85 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,18 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: + + # -------------- MyPy: type checking + # Although MyPy has limited abilities in a pre-commit context, as it can only + # type-check each file in isolation, it can still catch errors. - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.11.2 hooks: - id: mypy - additional_dependencies: [types-requests==2.31.0.2] + additional_dependencies: [ types-requests==2.31.0.2 ] exclude: "^scripts/load_testing/.*\\.py$" + + # -------------- Ruff: linter & "à la Black" formatter - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.6.3 hooks: @@ -14,8 +20,23 @@ repos: - id: ruff-format # Run the linter. - id: ruff - args: ["--fix"] + args: [ "--fix" ] exclude: "^src/project/settings/.*\\.py$" + + # -------------- pyupgrade: make sure we're using the latest Python features + - repo: https://github.com/asottile/pyupgrade + rev: v3.19.0 + hooks: + - id: pyupgrade + args: [ "--py311-plus" ] + + # -------------- fix-future-annotations: upgrade the typing annotations syntax to PEP 585 and PEP 604. + - repo: https://github.com/frostming/fix-future-annotations + rev: 0.5.0 # a released version tag + hooks: + - id: fix-future-annotations + + # -------------- validate-pyproject: does what it says on the tin ^_^ - repo: https://github.com/abravalheri/validate-pyproject rev: v0.19 hooks: diff --git a/Dockerfile b/Dockerfile index bac4abc..9f6ab30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,7 @@ COPY src/apps/chess/static-src ./src/apps/chess/static-src # so that Tailwind see the classes used in them: COPY src/apps/chess/components ./src/apps/chess/components COPY src/apps/daily_challenge/components ./src/apps/daily_challenge/components +COPY src/apps/lichess_bridge/components ./src/apps/lichess_bridge/components COPY src/apps/webui/components ./src/apps/webui/components # We're going to use our Makefile to build the assets: COPY Makefile ./ @@ -74,7 +75,7 @@ EOT # Install uv. # https://docs.astral.sh/uv/guides/integration/docker/ -COPY --from=ghcr.io/astral-sh/uv:0.4.4 /uv /usr/local/bin/uv +COPY --from=ghcr.io/astral-sh/uv:0.5.7 /uv /usr/local/bin/uv RUN mkdir -p /app WORKDIR /app @@ -115,8 +116,8 @@ FROM python:3.11-slim-bookworm AS assets_download # By having a separate build stage for downloading assets, we can cache them # as long as the `download_assets.py` doesn't change. -# should preferably be the same as in `poetry.lock`: -ENV PYTHON_HTTPX_VERSION=0.26.0 +# should preferably be the same as in `uv.lock`: +ENV PYTHON_HTTPX_VERSION=0.27.2 RUN pip install -U pip httpx==${PYTHON_HTTPX_VERSION} @@ -159,7 +160,6 @@ COPY --chown=1001:1001 --from=frontend_build /app/src/apps/chess/static src/apps COPY --chown=1001:1001 --from=backend_build /app/.venv .venv -COPY --chown=1001:1001 --from=assets_download /app/src/apps/webui/static/webui/fonts/OpenSans.woff2 src/apps/webui/static/webui/fonts/OpenSans.woff2 COPY --chown=1001:1001 --from=assets_download /app/src/apps/chess/static/chess/js/bot src/apps/chess/static/chess/js/bot COPY --chown=1001:1001 --from=assets_download /app/src/apps/chess/static/chess/units src/apps/chess/static/chess/units COPY --chown=1001:1001 --from=assets_download /app/src/apps/chess/static/chess/symbols src/apps/chess/static/chess/symbols @@ -179,7 +179,7 @@ EXPOSE 8080 ENV DJANGO_SETTINGS_MODULE=project.settings.production -ENV GUNICORN_CMD_ARGS="--bind 0.0.0.0:8080 --workers 2 --max-requests 120 --max-requests-jitter 20 --timeout 8" +ENV GUNICORN_CMD_ARGS="--bind 0.0.0.0:8080 --workers 4 -k uvicorn_worker.UvicornWorker --max-requests 120 --max-requests-jitter 20 --timeout 8" RUN chmod +x scripts/start_server.sh # See . diff --git a/Makefile b/Makefile index 2d4623e..df7cc25 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ PYTHON_BINS ?= ./.venv/bin PYTHON ?= ${PYTHON_BINS}/python DJANGO_SETTINGS_MODULE ?= project.settings.development SUB_MAKE = ${MAKE} --no-print-directory +UV ?= bin/uv .DEFAULT_GOAL := help @@ -10,15 +11,14 @@ help: @grep -P '^[.a-zA-Z/_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' .PHONY: install -install: bin/uv .venv ./node_modules ## Install the Python and frontend dependencies - bin/uv sync --all-extras - ${PYTHON_BINS}/pre-commit install - ${SUB_MAKE} .venv/bin/black +install: backend/install frontend/install ## Install the Python and frontend dependencies + .PHONY: dev dev: .env.local db.sqlite3 dev: ## Start Django in "development" mode, as well as our frontend assets compilers in "watch" mode @${SUB_MAKE} frontend/img + ${UV} pip install --no-build -e . @./node_modules/.bin/concurrently --names "django,css,js" --prefix-colors "blue,yellow,green" \ "${SUB_MAKE} backend/watch" \ "${SUB_MAKE} frontend/css/watch" \ @@ -31,19 +31,49 @@ download_assets: download_assets_opts ?= download_assets: ${PYTHON_BINS}/python scripts/download_assets.py ${download_assets_opts} +.PHONY: backend/install +backend/install: uv_sync_opts ?= --all-extras +backend/install: bin/uv .venv ## Install the Python dependencies (via uv) and install pre-commit +# Install Python dependencies: + ${UV} sync ${uv_sync_opts} +# Install the project in editable mode, so we don't have to add "src/" to the Python path: + ${UV} pip install --no-build -e . +# Install pre-commit hooks: + ${PYTHON_BINS}/pre-commit install +# Create a shim for Black (actually using Ruff), so the IDE can use it: + @${SUB_MAKE} .venv/bin/black +# Create the database if it doesn't exist: + @${SUB_MAKE} db.sqlite3 +# Make sure the SQLite database is up-to-date: + @${SUB_MAKE} django/manage cmd='createcachetable' + .PHONY: backend/watch +backend/watch: env_vars ?= backend/watch: address ?= localhost backend/watch: port ?= 8000 backend/watch: dotenv_file ?= .env.local -backend/watch: ## Start the Django development server - @${SUB_MAKE} django/manage cmd='runserver ${address}:${port}' +backend/watch: uvicorn_opts ?= --use-colors --access-log +backend/watch: ## Start Django via Uvicorn, in "watch" mode + @DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE} ${env_vars} \ + ${UV} run uvicorn \ + --reload --reload-dir src/ \ + --host ${address} --port ${port} \ + --env-file ${dotenv_file} \ + ${uvicorn_opts} \ + project.asgi:application .PHONY: backend/resetdb -backend/resetdb: dotenv_file ?= .env.local -backend/resetdb: # Destroys the SQLite database and recreates it from scratch +backend/resetdb: .confirm # Destroys the SQLite database and recreates it from scratch rm -f db.sqlite3 @${SUB_MAKE} db.sqlite3 +.PHONY: backend/backupdb +backend/backupdb: backup_name ?= $$(date --iso-8601=seconds | cut -d + -f 1) +backend/backupdb: # Creates a backup of the SQLite database + @sqlite3 db.sqlite3 ".backup 'db.local.${backup_name}.backup.sqlite3'" + @echo "Backup created as 'db.local.${backup_name}.backup.sqlite3'" + + .PHONY: backend/createsuperuser backend/createsuperuser: dotenv_file ?= .env.local backend/createsuperuser: email ?= admin@zakuchess.localhost @@ -61,7 +91,7 @@ test: ## Launch the pytest tests suite ${PYTHON_BINS}/pytest ${pytest_opts} .PHONY: code-quality/all -code-quality/all: code-quality/ruff_check code-quality/ruff_lint code-quality/mypy ## Run all our code quality tools +code-quality/all: code-quality/ruff_check code-quality/ruff_lint code-quality/mypy code-quality/fix-future-annotations ## Run all our code quality tools .PHONY: code-quality/ruff_check code-quality/ruff_check: ruff_opts ?= @@ -81,10 +111,20 @@ code-quality/mypy: ## Python's equivalent of TypeScript # @link https://mypy.readthedocs.io/en/stable/ @${PYTHON_BINS}/mypy src/ ${mypy_opts} +.PHONY: code-quality/fix-future-annotations +code-quality/fix-future-annotations: fix_future_annotations_opts ?= +code-quality/fix-future-annotations: ## Make sure we're using PEP 585 and PEP 604 +# @link https://github.com/frostming/fix-future-annotations + @${PYTHON_BINS}/fix-future-annotations ${fix_future_annotations_opts} src/ + # Here starts the frontend stuff +.PHONY: frontend/install +frontend/install: ## Install the frontend dependencies (via npm) + npm install + .PHONY: frontend/watch -frontend/watch: ## Compile the CSS & JS assets of our various Django apps, in 'watch' mode +frontend/watch: ./node_modules ## Compile the CSS & JS assets of our various Django apps, in 'watch' mode @./node_modules/.bin/concurrently --names "img,css,js" --prefix-colors "yellow,green" \ "${SUB_MAKE} frontend/img" \ "${SUB_MAKE} frontend/css/watch" \ @@ -143,14 +183,14 @@ frontend/img/copy_assets: # Here starts the "misc util targets" stuff -bin/uv: uv_version ?= 0.4.4 +bin/uv: uv_version ?= 0.5.7 bin/uv: # Install `uv` and `uvx` locally in the "bin/" folder curl -LsSf "https://astral.sh/uv/${uv_version}/install.sh" | \ - CARGO_DIST_FORCE_INSTALL_DIR="$$(pwd)" INSTALLER_NO_MODIFY_PATH=1 sh + UV_INSTALL_DIR="$$(pwd)/bin" UV_NO_MODIFY_PATH=1 sh @echo "We'll use 'bin/uv' to manage Python dependencies." .venv: ## Initialises the Python virtual environment in a ".venv" folder, via uv - bin/uv venv + ${UV} venv .env.local: cp .env.dist .env.local @@ -171,6 +211,7 @@ django/manage: env_vars ?= django/manage: dotenv_file ?= .env.local django/manage: cmd ?= --help django/manage: .venv .env.local ## Run a Django management command + @echo "Running Django management command: ${cmd}" @DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE} ${env_vars} \ ${PYTHON_BINS}/dotenv -f '${dotenv_file}' run -- \ ${PYTHON} manage.py ${cmd} @@ -179,8 +220,13 @@ django/manage: .venv .env.local ## Run a Django management command ./node_modules: frontend/install -frontend/install: - npm install + +# Here starts the "Internal Makefile utils" stuff + +.PHONY: .confirm +.confirm: +# https://www.alexedwards.net/blog/a-time-saving-makefile-for-your-go-projects + @echo -n 'Are you sure? [y/N] ' && read ans && [ $${ans:-N} = y ] # Here starts the "Lichess database" stuff diff --git a/dev-start.zsh b/dev-start.zsh index 982ff88..b8d75f5 100755 --- a/dev-start.zsh +++ b/dev-start.zsh @@ -16,7 +16,7 @@ export DJANGO_SETTINGS_MODULE=project.settings.development alias run_in_dotenv='dotenv -f .env.local run -- ' alias uv='bin/uv' -alias djm='run_in_dotenv python src/manage.py' +alias djm='run_in_dotenv python manage.py' alias test='DJANGO_SETTINGS_MODULE=project.settings.test run_in_dotenv pytest -x --reuse-db' alias test-no-reuse='DJANGO_SETTINGS_MODULE=project.settings.test run_in_dotenv pytest -x' diff --git a/pyproject.toml b/pyproject.toml index 86e918d..d3da29a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,20 +11,25 @@ readme = "README.md" requires-python = ">=3.11" dependencies= [ - "Django==5.1.*", - # Django doesn't follow SemVer, so we need to specify the minor version + # Django doesn't follow SemVer, so we need to specify the minor version: + "django==5.1.*", "gunicorn==22.*", + "uvicorn[standard]==0.30.*", + "uvicorn-worker==0.2.*", "django-alive==1.*", "chess==1.*", "django-htmx==1.*", "dominate==2.*", "dj-database-url==2.*", "requests==2.*", - "django-axes[ipware]==6.*", + "django-axes[ipware]==6.5.*", "whitenoise==6.*", - "django-import-export==3.*", + "django-import-export==4.*", "msgspec==0.18.*", "zakuchess", + "authlib==1.*", + "httpx==0.27.*", + "django-google-fonts==0.0.3", ] @@ -37,15 +42,17 @@ dev = [ "ipython==8.*", "types-requests==2.*", "django-extensions==3.*", - # (httpx is only used in "scripts/download_assets.py" for parallel downloads) - "httpx==0.26.*", "sqlite-utils==3.*", + "fix-future-annotations>=0.5.0", ] test = [ - "pytest==7.*", + "pytest==8.3.*", "pytest-django==4.*", "pytest-cov==4.*", "time-machine==2.*", + "pytest-blockage==0.2.*", + "pytest-asyncio==0.24.*", + "pytest-httpx-blockage==0.0.8", ] load-testing = [ "locust==2.*", @@ -58,7 +65,7 @@ Repository = "https://github.com/olivierphi/zakuchess" [tool.uv] -package = true # symlinks the project's "src/" root folder to the venv +package = false [tool.ruff] @@ -76,11 +83,12 @@ select = [ # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tch "TCH001", "TCH002", - "TCH003", + "TCH003", + "FA100", # future-rewritable-type-annotation + "FA102", # future-required-type-annotation ] [tool.ruff.lint.per-file-ignores] "src/project/settings/*" = ["F405"] -"src/lib/chess_engines/sunfish/sunfish.py" = ["F841", "F821"] [tool.ruff.lint.isort] # https://docs.astral.sh/ruff/settings/#lintisort @@ -107,6 +115,7 @@ exclude = [ ] [[tool.mypy.overrides]] module = [ + "authlib.*", "django.*", "dominate.*", "import_export.*", @@ -121,8 +130,20 @@ testpaths = [ "src/project/tests/", ] python_files = ["test_*.py"] +# pytest-django settings: +# https://pytest-django.readthedocs.io/ addopts = "--reuse-db" DJANGO_SETTINGS_MODULE = "project.settings.test" +# pytest-asyncio settings: +# https://pytest-asyncio.readthedocs.io/en/stable/reference/configuration.html +asyncio_default_fixture_loop_scope = "function" +asyncio_mode = "auto" +# pytest-blockage settings: +# https://github.com/rob-b/pytest-blockage +blockage = true +# pytest-httpx-blockage settings: +# https://github.com/SlavaSkvortsov/pytest-httpx-blockage +blockage-httpx = true [tool.coverage.run] # @link https://coverage.readthedocs.io/en/latest/excluding.html diff --git a/scripts/download_assets.py b/scripts/download_assets.py index 777c646..368c945 100755 --- a/scripts/download_assets.py +++ b/scripts/download_assets.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +from __future__ import annotations import asyncio from pathlib import Path @@ -21,7 +22,6 @@ CHESS_STATIC = BASE_DIR / "src" / "apps" / "chess" / "static" / "chess" ASSETS_PATTERNS: dict[str, str] = { - "GOOGLE_FONTS": "https://fonts.gstatic.com/s/{font_name}/{v}/{file_id}.woff2", "STOCKFISH_CDN": "https://cdnjs.cloudflare.com/ajax/libs/stockfish.js/10.0.2/{file}", "LOZZA_GITHUB": "https://raw.githubusercontent.com/op12no2/lozza/{rev}/lozza.js", "WESNOTH_UNITS_GITHUB": "https://raw.githubusercontent.com/wesnoth/wesnoth/master/data/core/images/units/{path}", @@ -43,8 +43,6 @@ # fmt: off ASSETS_MAP: dict[URL, Path] = { - # Fonts: - ASSETS_PATTERNS["GOOGLE_FONTS"].format(font_name="opensans", file_id="mem8YaGs126MiZpBA-UFVZ0b", v="v35"): WEBUI_STATIC / "fonts" / "OpenSans.woff2", # Stockfish: ASSETS_PATTERNS["STOCKFISH_CDN"].format(file="stockfish.min.js"): CHESS_STATIC / "js" / "bot" / "stockfish.js", ASSETS_PATTERNS["STOCKFISH_CDN"].format(file="stockfish.wasm"): CHESS_STATIC / "js" / "bot" / "stockfish.wasm", @@ -86,11 +84,10 @@ async def download_assets(*, even_if_exists: bool) -> None: - download_coros: list["Coroutine"] = [] + download_coros: list[Coroutine] = [] limits = httpx.Limits( max_connections=DOWNLOADS_CONCURRENCY, - max_keepalive_connections=DOWNLOADS_CONCURRENCY, ) async with httpx.AsyncClient( limits=limits, headers={"User-Agent": _USER_AGENT} diff --git a/scripts/start_server.sh b/scripts/start_server.sh index 2ab7cb4..76a9dd3 100644 --- a/scripts/start_server.sh +++ b/scripts/start_server.sh @@ -10,7 +10,11 @@ set -o errexit # initialised some environment variables in the Dockerfile, # such as DJANGO_SETTINGS_MODULE and GUNICORN_CMD_ARGS. -# TODO: remove this once we have a proper deployment pipeline +# TODO: remove this once we have a proper deployment pipeline? + +echo "Make sure the cache table is operational." +.venv/bin/python manage.py createcachetable + echo "Running Django migrations." .venv/bin/python manage.py migrate --noinput @@ -19,4 +23,4 @@ echo "Make sure the SQLite database is always optimised." # Go! echo "Starting Gunicorn." -.venv/bin/gunicorn project.wsgi +.venv/bin/gunicorn project.asgi:application diff --git a/src/apps/chess/business_logic/__init__.py b/src/apps/chess/business_logic/__init__.py index f67ade3..f4ae296 100644 --- a/src/apps/chess/business_logic/__init__.py +++ b/src/apps/chess/business_logic/__init__.py @@ -2,5 +2,7 @@ from ._calculate_fen_before_move import calculate_fen_before_move from ._calculate_piece_available_targets import calculate_piece_available_targets -from ._compute_game_score import compute_game_score from ._do_chess_move import do_chess_move +from ._do_chess_move_with_piece_role_by_square import ( + do_chess_move_with_piece_role_by_square, +) diff --git a/src/apps/chess/business_logic/_calculate_fen_before_move.py b/src/apps/chess/business_logic/_calculate_fen_before_move.py index eeb3706..8894792 100644 --- a/src/apps/chess/business_logic/_calculate_fen_before_move.py +++ b/src/apps/chess/business_logic/_calculate_fen_before_move.py @@ -1,8 +1,10 @@ +from __future__ import annotations + from typing import TYPE_CHECKING import chess -from ..helpers import chess_lib_color_to_player_side +from ..chess_helpers import chess_lib_color_to_player_side if TYPE_CHECKING: from apps.chess.types import FEN, PlayerSide @@ -11,10 +13,10 @@ def calculate_fen_before_move( # TODO: change move_uci to a MoveTuple type *, - fen_after_move: "FEN", + fen_after_move: FEN, move_uci: str, - moving_player_side: "PlayerSide", -) -> "FEN": + moving_player_side: PlayerSide, +) -> FEN: """ Calculate the FEN of the chess board before the given move. Raises a ValueError if the move is invalid. diff --git a/src/apps/chess/business_logic/_calculate_piece_available_targets.py b/src/apps/chess/business_logic/_calculate_piece_available_targets.py index 9d8ccf1..86c47ea 100644 --- a/src/apps/chess/business_logic/_calculate_piece_available_targets.py +++ b/src/apps/chess/business_logic/_calculate_piece_available_targets.py @@ -1,18 +1,20 @@ +from __future__ import annotations + from typing import TYPE_CHECKING import chess -from apps.chess.helpers import chess_lib_square_to_square +from ..chess_helpers import chess_lib_square_to_square if TYPE_CHECKING: from apps.chess.types import Square def calculate_piece_available_targets( - *, chess_board: chess.Board, piece_square: "Square" -) -> frozenset["Square"]: + *, chess_board: chess.Board, piece_square: Square +) -> frozenset[Square]: square_index = chess.parse_square(piece_square) - result: list["Square"] = [] + result: list[Square] = [] for move in chess_board.legal_moves: if move.from_square == square_index: result.append(chess_lib_square_to_square(move.to_square)) diff --git a/src/apps/chess/business_logic/_compute_game_score.py b/src/apps/chess/business_logic/_compute_game_score.py deleted file mode 100644 index c92aab7..0000000 --- a/src/apps/chess/business_logic/_compute_game_score.py +++ /dev/null @@ -1,48 +0,0 @@ -import logging -from typing import TYPE_CHECKING - -import chess -import chess.engine -from django.conf import settings - -if TYPE_CHECKING: - from ..types import FEN - -_logger = logging.getLogger(__name__) - - -def compute_game_score( - *, chess_board: chess.Board | None = None, fen: "FEN | None" = None -) -> int: - """ - Returns the advantage of the white player, in centipawns. - ⚠ This function is blocking, and it can take up to 0.1 second to return, so it - should only be called from operations taking place in the Django Admin! - """ - assert chess_board or fen, "Either `chess_board` or `fen` must be provided." - - if not chess_board: - chess_board = chess.Board(fen) - - _logger.info( - "Computing game score for FEN: %s", - chess_board.fen(), - extra={ - "Stockfish path": settings.STOCKFISH_PATH, - "Stockfish time limit": settings.STOCKFISH_TIME_LIMIT, - }, - ) - - try: - engine = chess.engine.SimpleEngine.popen_uci(settings.STOCKFISH_PATH) - except chess.engine.EngineTerminatedError as exc: - _logger.error("Stockfish engine terminated before analyze: %s", exc) - return 0 - - info = engine.analyse( - chess_board, chess.engine.Limit(time=settings.STOCKFISH_TIME_LIMIT) - ) - advantage = info["score"].white().score() - engine.quit() - - return advantage or 0 diff --git a/src/apps/chess/business_logic/_do_chess_move.py b/src/apps/chess/business_logic/_do_chess_move.py index 83ce257..818e2a5 100644 --- a/src/apps/chess/business_logic/_do_chess_move.py +++ b/src/apps/chess/business_logic/_do_chess_move.py @@ -1,15 +1,16 @@ -from functools import lru_cache +from __future__ import annotations + from typing import TYPE_CHECKING, Literal, cast import chess -from apps.chess.helpers import ( +from ..chess_helpers import ( chess_lib_piece_to_piece_type, file_and_rank_from_square, square_from_file_and_rank, ) -from apps.chess.types import ( - ChessInvalidMoveException, +from ..exceptions import ChessInvalidMoveException +from ..types import ( ChessMoveResult, GameOverDescription, ) @@ -19,12 +20,12 @@ from apps.chess.types import FEN, GameEndReason, MoveTuple, PlayerSide, Rank, Square -_CHESS_COLOR_TO_PLAYER_SIDE_MAPPING: "Mapping[chess.Color, PlayerSide]" = { +_CHESS_COLOR_TO_PLAYER_SIDE_MAPPING: Mapping[chess.Color, PlayerSide] = { True: "w", False: "b", } -_CHESS_OUTCOME_TO_GAME_END_REASON_MAPPING: "Mapping[chess.Termination, GameEndReason]" = { +_CHESS_OUTCOME_TO_GAME_END_REASON_MAPPING: Mapping[chess.Termination, GameEndReason] = { chess.Termination.CHECKMATE: "checkmate", chess.Termination.STALEMATE: "stalemate", chess.Termination.INSUFFICIENT_MATERIAL: "insufficient_material", @@ -45,7 +46,7 @@ ("e8", "c8"), ) -_CASTLING_ROOK_MOVE: "Mapping[_CastlingPossibleTo, tuple[Square, Square]]" = { +_CASTLING_ROOK_MOVE: Mapping[_CastlingPossibleTo, tuple[Square, Square]] = { # {king new square: (rook previous square, rook new square)} dict: "g1": ("h1", "f1"), "c1": ("a1", "d1"), @@ -53,7 +54,7 @@ "c8": ("a8", "d8"), } -_EN_PASSANT_CAPTURED_PIECES_RANK_CONVERSION: dict["Rank", "Rank"] = { +_EN_PASSANT_CAPTURED_PIECES_RANK_CONVERSION: dict[Rank, Rank] = { # if a pawn was captured by en passant targeting a6, its position on the board # before at the moment it's been captured was a5: "6": "5", @@ -62,12 +63,27 @@ } -@lru_cache(maxsize=512) -def do_chess_move(*, fen: "FEN", from_: "Square", to: "Square") -> ChessMoveResult: - moves: list["MoveTuple"] = [] - captured: "Square | None" = None +def do_chess_move( + *, + from_: Square, + to: Square, + fen: FEN | None = None, + chess_board: chess.Board | None = None, +) -> ChessMoveResult: + """ + Execute a move on the given board and return the result of that move. + The board can be passed as a FEN string *or* as a `chess.Board` object. + """ + if (not fen and not chess_board) or (fen and chess_board): + raise ValueError( + "You must provide either a FEN string or a `chess.Board` object" + ) + + moves: list[MoveTuple] = [] + captured: Square | None = None - chess_board = chess.Board(fen) + if not chess_board: + chess_board = chess.Board(fen) chess_from = chess.parse_square(from_) chess_to = chess.parse_square(to) diff --git a/src/apps/chess/business_logic/_do_chess_move_with_piece_role_by_square.py b/src/apps/chess/business_logic/_do_chess_move_with_piece_role_by_square.py new file mode 100644 index 0000000..1afff1b --- /dev/null +++ b/src/apps/chess/business_logic/_do_chess_move_with_piece_role_by_square.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, NamedTuple, cast + +from ..chess_helpers import ( + get_active_player_side_from_chess_board, + get_active_player_side_from_fen, +) +from ..exceptions import ChessInvalidStateException + +if TYPE_CHECKING: + import chess + + from ..types import ( + FEN, + ChessMoveResult, + PieceRole, + PieceRoleBySquare, + PieceSymbol, + Square, + ) + + +class ChessMoveWithPieceRoleBySquareResult(NamedTuple): + move_result: ChessMoveResult + piece_role_by_square: PieceRoleBySquare + captured_piece: PieceRole | None + + +def do_chess_move_with_piece_role_by_square( + *, + from_: Square, + to: Square, + piece_role_by_square: PieceRoleBySquare, + fen: FEN | None = None, + chess_board: chess.Board | None = None, +) -> ChessMoveWithPieceRoleBySquareResult: + from ._do_chess_move import do_chess_move + + if (not fen and not chess_board) or (fen and chess_board): + raise ValueError( + "You must provide either a FEN string or a `chess.Board` object" + ) + + try: + move_result = do_chess_move( + fen=fen, + chess_board=chess_board, + from_=from_, + to=to, + ) + except ValueError as err: + raise ChessInvalidStateException(f"Suspicious chess move: '{err}'") from err + + active_player_side = ( + get_active_player_side_from_fen(fen) + if fen + else get_active_player_side_from_chess_board(chess_board) # type: ignore[arg-type] + ) + piece_role_by_square = piece_role_by_square.copy() + if promotion := move_result["promotion"]: + # Let's promote that piece! + piece_promotion = cast( + "PieceSymbol", promotion.upper() if active_player_side == "w" else promotion + ) + piece_role_by_square[from_] += piece_promotion # type: ignore + + captured_piece: PieceRole | None = None + if captured := move_result["captured"]: + assert move_result["is_capture"] + captured_piece = piece_role_by_square[captured] + del piece_role_by_square[captured] # this square is now empty + + for move_from, move_to in move_result["moves"]: + piece_role_by_square[move_to] = piece_role_by_square[move_from] + del piece_role_by_square[move_from] # this square is now empty + + return ChessMoveWithPieceRoleBySquareResult( + move_result, piece_role_by_square, captured_piece + ) diff --git a/src/apps/chess/helpers.py b/src/apps/chess/chess_helpers.py similarity index 61% rename from src/apps/chess/helpers.py rename to src/apps/chess/chess_helpers.py index 6117fce..66b75c3 100644 --- a/src/apps/chess/helpers.py +++ b/src/apps/chess/chess_helpers.py @@ -1,4 +1,6 @@ -from functools import cache, lru_cache +from __future__ import annotations + +from functools import cache from typing import TYPE_CHECKING, cast import chess @@ -9,7 +11,6 @@ PIECE_TYPE_TO_NAME, PIECE_TYPE_TO_UNICODE, RANKS, - SQUARES, ) if TYPE_CHECKING: @@ -28,23 +29,23 @@ @cache -def chess_lib_square_to_square(chess_lib_square: int) -> "Square": - return cast("Square", chess.square_name(chess_lib_square)) +def chess_lib_square_to_square(chess_lib_square: int) -> Square: + return cast("Square", chess.SQUARE_NAMES[chess_lib_square]) @cache -def chess_lib_piece_to_piece_type(chess_lib_piece: int) -> "PieceType": +def chess_lib_piece_to_piece_type(chess_lib_piece: int) -> PieceType: # a bit hacky but that will do the job for now ^^ return PIECE_INT_TO_PIECE_TYPE[chess_lib_piece] @cache -def player_side_other(player_side: "PlayerSide") -> "PlayerSide": +def player_side_other(player_side: PlayerSide) -> PlayerSide: return "w" if player_side == "b" else "b" @cache -def symbol_from_piece_role(piece_role: "PieceRole") -> "PieceSymbol": +def symbol_from_piece_role(piece_role: PieceRole) -> PieceSymbol: # If it's a promoted pawn (len == 3), we want the last character # (which is the promoted piece in such a case) return cast( @@ -53,34 +54,34 @@ def symbol_from_piece_role(piece_role: "PieceRole") -> "PieceSymbol": @cache -def type_from_piece_role(piece_role: "PieceRole") -> "PieceType": +def type_from_piece_role(piece_role: PieceRole) -> PieceType: return cast("PieceType", symbol_from_piece_role(piece_role).lower()) @cache -def type_from_piece_symbol(piece_symbol: "PieceSymbol") -> "PieceType": +def type_from_piece_symbol(piece_symbol: PieceSymbol) -> PieceType: return cast("PieceType", piece_symbol.lower()) @cache -def player_side_from_piece_symbol(piece_role: "PieceSymbol") -> "PlayerSide": +def player_side_from_piece_symbol(piece_role: PieceSymbol) -> PlayerSide: return "w" if piece_role.isupper() else "b" @cache -def player_side_from_piece_role(piece_role: "PieceRole") -> "PlayerSide": +def player_side_from_piece_role(piece_role: PieceRole) -> PlayerSide: return player_side_from_piece_symbol(piece_role) @cache -def team_member_role_from_piece_role(piece_role: "PieceRole") -> "TeamMemberRole": +def team_member_role_from_piece_role(piece_role: PieceRole) -> TeamMemberRole: return cast("TeamMemberRole", piece_role[0:2].lower()) @cache def piece_role_from_team_member_role_and_player_side( - team_member_role: "TeamMemberRole", player_side: "PlayerSide" -) -> "PieceRole": + team_member_role: TeamMemberRole, player_side: PlayerSide +) -> PieceRole: return cast( "PieceRole", team_member_role.upper() if player_side == "w" else team_member_role, @@ -88,7 +89,7 @@ def piece_role_from_team_member_role_and_player_side( @cache -def file_and_rank_from_square(square: "Square") -> tuple["File", "Rank"]: +def file_and_rank_from_square(square: Square) -> tuple[File, Rank]: file, rank = square[0], square[1] # As the result is cached, we can allow ourselves some sanity checks # when Python's "optimization mode" is requested: @@ -97,7 +98,7 @@ def file_and_rank_from_square(square: "Square") -> tuple["File", "Rank"]: @cache -def square_from_file_and_rank(file: "File", rank: "Rank") -> "Square": +def square_from_file_and_rank(file: File, rank: Rank) -> Square: """Inverse of the function above""" # ditto assert file in FILES, f"file '{file}' is not valid" @@ -106,56 +107,54 @@ def square_from_file_and_rank(file: "File", rank: "Rank") -> "Square": @cache -def piece_name_from_piece_type(piece_type: "PieceType") -> "PieceName": +def piece_name_from_piece_type(piece_type: PieceType) -> PieceName: return PIECE_TYPE_TO_NAME[piece_type] @cache -def piece_name_from_piece_role(piece_role: "PieceRole") -> "PieceName": +def piece_name_from_piece_role(piece_role: PieceRole) -> PieceName: return piece_name_from_piece_type(type_from_piece_role(piece_role)) @cache -def utf8_symbol_from_piece_type(piece_type: "PieceType") -> str: +def utf8_symbol_from_piece_type(piece_type: PieceType) -> str: return PIECE_TYPE_TO_UNICODE[piece_type] @cache -def utf8_symbol_from_piece_role(piece_role: "PieceRole") -> str: +def utf8_symbol_from_piece_role(piece_role: PieceRole) -> str: return utf8_symbol_from_piece_type(type_from_piece_role(piece_role)) -def get_squares_with_pieces_that_can_move(board: chess.Board) -> frozenset["Square"]: +def get_squares_with_pieces_that_can_move(board: chess.Board) -> frozenset[Square]: return frozenset( cast("Square", chess.square_name(move.from_square)) for move in board.legal_moves ) -@lru_cache -def get_active_player_side_from_fen(fen: "FEN") -> "PlayerSide": +def get_active_player_side_from_fen(fen: FEN) -> PlayerSide: return cast("PlayerSide", fen.split(" ")[1]) -def get_active_player_side_from_chess_board(board: chess.Board) -> "PlayerSide": - return "w" if board.turn else "b" +def get_turns_counter_from_fen(fen: FEN) -> int: + """Returns the fullmove number, starting from 1""" + return int(fen.split(" ")[-1]) -@lru_cache -def uci_move_squares(move: str) -> tuple["Square", "Square"]: - return cast("Square", move[:2]), cast("Square", move[2:4]) +def get_active_player_side_from_chess_board(board: chess.Board) -> PlayerSide: + return "w" if board.turn else "b" -@cache -def get_square_order(square: "Square") -> int: - return SQUARES.index(square) +def uci_move_squares(move: str) -> tuple[Square, Square]: + return cast("Square", move[:2]), cast("Square", move[2:4]) @cache -def player_side_to_chess_lib_color(player_side: "PlayerSide") -> chess.Color: +def player_side_to_chess_lib_color(player_side: PlayerSide) -> chess.Color: return chess.WHITE if player_side == "w" else chess.BLACK @cache -def chess_lib_color_to_player_side(color: chess.Color) -> "PlayerSide": +def chess_lib_color_to_player_side(color: chess.Color) -> PlayerSide: return "w" if color == chess.WHITE else "b" diff --git a/src/apps/chess/components/chess_board.py b/src/apps/chess/components/chess_board.py index efe2966..92ab8bd 100644 --- a/src/apps/chess/components/chess_board.py +++ b/src/apps/chess/components/chess_board.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import json from functools import cache from string import Template -from typing import TYPE_CHECKING, Literal, cast +from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, cast from chess import FILE_NAMES, RANK_NAMES from django.conf import settings @@ -9,7 +11,7 @@ from dominate.tags import button, div, section, span from dominate.util import raw as unescaped_html -from ..helpers import ( +from ..chess_helpers import ( file_and_rank_from_square, piece_name_from_piece_role, player_side_from_piece_role, @@ -19,7 +21,8 @@ from .chess_helpers import ( chess_unit_symbol_class, piece_character_classes, - square_to_piece_tailwind_classes, + piece_should_face_left, + square_to_positioning_tailwind_classes, ) from .misc_ui import speech_bubble_container @@ -28,8 +31,15 @@ from dominate.tags import dom_tag + from ..models import GameFactions from ..presenters import GamePresenter - from ..types import Factions, PieceRole, PieceType, PlayerSide, Square + from ..types import ( + BoardOrientation, + PieceRole, + PieceType, + PlayerSide, + Square, + ) SQUARE_COLOR_TAILWIND_CLASSES = ("bg-chess-square-dark", "bg-chess-square-light") @@ -38,7 +48,7 @@ INFO_BARS_COMMON_CLASSES = ( "p-2 text-slate-200 bg-slate-800 border-2 border-solid border-slate-400" ) -_PIECE_GROUND_MARKER_COLOR_TAILWIND_CLASSES: dict[tuple["PlayerSide", bool], str] = { +_PIECE_GROUND_MARKER_COLOR_TAILWIND_CLASSES: dict[tuple[PlayerSide, bool], str] = { # the boolean says if the piece can move ("w", False): "bg-emerald-800/40 border-2 border-emerald-800", ("b", False): "bg-indigo-800/40 border-2 border-indigo-800", @@ -52,7 +62,8 @@ "character": "z-20", } - +# TODO: get rid of _PLAY_BOT_JS_TEMPLATE and _PLAY_SOLUTION_JS_TEMPLATE, and implement +# this as Web Components we return in the HTMX response. # We'll wait that amount of milliseconds before starting the bot move's calculation: _BOT_MOVE_DELAY = 700 _BOT_MOVE_DELAY_FIRST_TURN_OF_THE_DAY = 1_400 @@ -86,9 +97,17 @@ ) +class ChessArenaCompanionBars(TypedDict): + top: NotRequired[dom_tag] + bottom: NotRequired[dom_tag] + + def chess_arena( - *, game_presenter: "GamePresenter", status_bars: "list[dom_tag]", board_id: str -) -> "dom_tag": + *, + game_presenter: GamePresenter, + companion_bars: ChessArenaCompanionBars | None = None, + board_id: str, +) -> dom_tag: arena_additional_classes = ( "border-3 border-solid md:border-lime-400 xl:border-red-400" if settings.DEBUG_LAYOUT @@ -134,7 +153,8 @@ def chess_arena( ), chess_bot_data(board_id), div( - *status_bars, + companion_bars.get("top", "") if companion_bars else "", + companion_bars.get("bottom", "") if companion_bars else "", id=f"chess-status-bars-{board_id}", cls="xl:px-2 xl:w-1/3 xl:bg-slate-950", ), @@ -150,7 +170,7 @@ def chess_arena( ) -def chess_bot_data(board_id: str) -> "dom_tag": +def chess_bot_data(board_id: str) -> dom_tag: # This is used in "chess-bot.ts" match settings.JS_CHESS_ENGINE.lower(): case "lozza": @@ -172,19 +192,32 @@ def chess_bot_data(board_id: str) -> "dom_tag": ) -def chess_board(*, game_presenter: "GamePresenter", board_id: str) -> "dom_tag": +def chess_board(*, game_presenter: GamePresenter, board_id: str) -> dom_tag: force_square_info: bool = ( game_presenter.force_square_info or game_presenter.is_preview ) squares: list[dom_tag] = [] - for file in FILE_NAMES: - for rank in RANK_NAMES: - squares.append( - chess_board_square( - cast("Square", f"{file}{rank}"), - force_square_info=force_square_info, - ) - ) + match game_presenter.board_orientation: + case "1->8": + for file in FILE_NAMES: + for rank in RANK_NAMES: + squares.append( + chess_board_square( + game_presenter.board_orientation, + cast("Square", f"{file}{rank}"), + force_square_info=force_square_info, + ) + ) + case "8->1": + for file in reversed(FILE_NAMES): + for rank in reversed(RANK_NAMES): + squares.append( + chess_board_square( + game_presenter.board_orientation, + cast("Square", f"{file}{rank}"), + force_square_info=force_square_info, + ) + ) squares_container_classes: list[str] = [ "relative", @@ -217,10 +250,17 @@ def chess_board(*, game_presenter: "GamePresenter", board_id: str) -> "dom_tag": def chess_pieces( - *, game_presenter: "GamePresenter", board_id: str, **extra_attrs: str -) -> "dom_tag": - pieces: "list[dom_tag]" = [] - for square, piece_role in game_presenter.piece_role_by_square.items(): + *, game_presenter: GamePresenter, board_id: str, **extra_attrs: str +) -> dom_tag: + pieces_to_append: list[tuple[Square, PieceRole]] = sorted( + # We sort the pieces by their role, so that the pieces are always displayed + # in the same order, regardless of their position on the chess board. + game_presenter.piece_role_by_square.items(), + key=lambda item: item[1], + ) + + pieces: list[dom_tag] = [] + for square, piece_role in pieces_to_append: pieces.append( chess_piece( square=square, @@ -253,8 +293,11 @@ def chess_pieces( @cache def chess_board_square( - square: "Square", *, force_square_info: bool = False -) -> "dom_tag": + board_orientation: BoardOrientation, + square: Square, + *, + force_square_info: bool = False, +) -> dom_tag: file, rank = file_and_rank_from_square(square) square_index = FILE_NAMES.index(file) + RANK_NAMES.index(rank) square_color_cls = SQUARE_COLOR_TAILWIND_CLASSES[square_index % 2] @@ -263,23 +306,29 @@ def chess_board_square( "aspect-square", "w-1/8", square_color_cls, - *square_to_piece_tailwind_classes(square), + *square_to_positioning_tailwind_classes(board_orientation, square), ] - display_square_info = force_square_info or (rank == "1" or file == "a") - if display_square_info: - square_name = ( - f"{file}{rank}" - if force_square_info - else "".join((file if rank == "1" else "", rank if file == "a" else "")) - ) - square_info = ( - span( - square_name, - cls="text-chess-square-square-info select-none", - ) - if display_square_info - else "" + displayed_file, displayed_rank = None, None + if force_square_info: + displayed_file, displayed_rank = file, rank + else: + match board_orientation: + case "1->8": + if rank == "1": + displayed_file = file + if file == "a": + displayed_rank = rank + case "8->1": + if rank == "8": + displayed_file = file + if file == "h": + displayed_rank = rank + if displayed_file or displayed_rank: + square_name = f"{displayed_file or ''}{displayed_rank or ''}" + square_info = span( + square_name, + cls="text-chess-square-square-info select-none", ) else: square_info = "" @@ -294,23 +343,23 @@ def chess_board_square( def chess_piece( *, - game_presenter: "GamePresenter", - square: "Square", - piece_role: "PieceRole", + game_presenter: GamePresenter, + square: Square, + piece_role: PieceRole, board_id: str, -) -> "dom_tag": +) -> dom_tag: player_side = player_side_from_piece_role(piece_role) piece_can_be_moved_by_player = ( game_presenter.solution_index is not None - and game_presenter.is_player_turn + and game_presenter.is_my_turn and square in game_presenter.squares_with_pieces_that_can_move ) unit_display = chess_character_display( piece_role=piece_role, game_presenter=game_presenter, square=square ) unit_chess_symbol_display = chess_unit_symbol_display( - piece_role=piece_role, square=square + board_orientation=game_presenter.board_orientation, piece_role=piece_role ) ground_marker = chess_unit_ground_marker( player_side=player_side, can_move=piece_can_be_moved_by_player @@ -331,7 +380,9 @@ def chess_piece( "absolute", "aspect-square", "w-1/8", - *square_to_piece_tailwind_classes(square), + *square_to_positioning_tailwind_classes( + game_presenter.board_orientation, square + ), "cursor-pointer" if not is_game_over else "cursor-default", "pointer-events-auto" if not is_game_over else "pointer-events-none", # Transition-related classes: @@ -375,21 +426,37 @@ def chess_piece( def chess_available_targets( - *, game_presenter: "GamePresenter", board_id: str, **extra_attrs: str -) -> "dom_tag": + *, game_presenter: GamePresenter, board_id: str, **extra_attrs: str +) -> dom_tag: children: list[dom_tag] = [] if game_presenter.selected_piece and not game_presenter.is_game_over: selected_piece_player_side = game_presenter.selected_piece.player_side - for square in game_presenter.selected_piece.available_targets: + if ( + game_presenter.moves_must_be_confirmed + and game_presenter.target_square_to_confirm is not None + ): + # We're waiting for the user confirmation for a specific move: + # --> let's only display the target square as a target! + # TODO: also display a "preview" of the selected piece on the target square? children.append( chess_available_target( game_presenter=game_presenter, piece_player_side=selected_piece_player_side, - square=square, + square=game_presenter.target_square_to_confirm, board_id=board_id, ) ) + else: + for square in game_presenter.selected_piece.available_targets: + children.append( + chess_available_target( + game_presenter=game_presenter, + piece_player_side=selected_piece_player_side, + square=square, + board_id=board_id, + ) + ) return div( *children, @@ -401,24 +468,32 @@ def chess_available_targets( def chess_available_target( *, - game_presenter: "GamePresenter", - piece_player_side: "PlayerSide", - square: "Square", + game_presenter: GamePresenter, + piece_player_side: PlayerSide, + square: Square, board_id: str, -) -> "dom_tag": +) -> dom_tag: assert game_presenter.selected_piece is not None can_move = ( not game_presenter.is_game_over - and game_presenter.active_player_side == piece_player_side + and game_presenter.is_my_turn + and game_presenter.my_side == piece_player_side ) bg_class = ( - "bg-active-chess-available-target-marker" + "bg-playable-chess-available-target-marker" if can_move - else "bg-opponent-chess-available-target-marker" + else "bg-non-playable-chess-available-target-marker" ) hover_class = "hover:w-1/3 hover:h-1/3" if can_move else "" + target_marker_size = ( + "w-1/5 h-1/5" + if not game_presenter.moves_must_be_confirmed + or game_presenter.target_square_to_confirm is None + or game_presenter.target_square_to_confirm != square + else "w-1/2 h-1/2" + ) target_marker = div( - cls=f"w-1/5 h-1/5 rounded-full transition-size {bg_class} {hover_class}", + cls=f"{target_marker_size} rounded-full transition-size {bg_class} {hover_class}", ) target_marker_container = div( target_marker, @@ -429,7 +504,9 @@ def chess_available_target( "aspect-square", "w-1/8", "block", - *square_to_piece_tailwind_classes(square), + *square_to_positioning_tailwind_classes( + game_presenter.board_orientation, square + ), ] additional_attributes = {} @@ -440,12 +517,20 @@ def chess_available_target( additional_attributes["disabled"] = True if can_move: - htmx_attributes = { - "data_hx_post": game_presenter.urls.htmx_game_move_piece_url( - square=square, board_id=board_id - ), - "data_hx_target": f"#chess-pieces-container-{board_id}", - } + if game_presenter.moves_must_be_confirmed: + htmx_attributes = { + "data_hx_get": game_presenter.urls.htmx_game_move_piece_confirmation_dialog_url( + square=square, board_id=board_id + ), + "data_hx_target": f"#chess-pieces-container-{board_id}", + } + else: + htmx_attributes = { + "data_hx_post": game_presenter.urls.htmx_game_move_piece_url( + square=square, board_id=board_id + ), + "data_hx_target": f"#chess-pieces-container-{board_id}", + } else: htmx_attributes = {} @@ -461,20 +546,32 @@ def chess_available_target( def chess_character_display( *, - piece_role: "PieceRole", - game_presenter: "GamePresenter | None" = None, - square: "Square | None" = None, - additional_classes: "Sequence[str]|None" = None, - factions: "Factions | None" = None, -) -> "dom_tag": + piece_role: PieceRole, + game_presenter: GamePresenter | None = None, + square: Square | None = None, + additional_classes: Sequence[str] | None = None, + factions: GameFactions | None = None, + board_orientation: BoardOrientation = "1->8", +) -> dom_tag: assert ( game_presenter or factions ), "You must provide either a GamePresenter or a Factions kwarg." # Some data we'll need: piece_player_side = player_side_from_piece_role(piece_role) - is_active_player_piece = ( - game_presenter.active_player == piece_player_side if game_presenter else False + belongs_to_active_player = ( + bool(piece_player_side == game_presenter.active_player_side) + if game_presenter + else False + ) + is_my_turn = game_presenter.is_my_turn if game_presenter else False + is_playable = is_my_turn and ( + ( + piece_player_side == game_presenter.my_side + and not game_presenter.is_game_over + ) + if game_presenter + else False ) is_potential_capture: bool = False is_highlighted: bool = False @@ -495,34 +592,36 @@ def chess_character_display( ): is_potential_capture = True - is_w_side = piece_player_side == "w" - piece_type: "PieceType" = type_from_piece_role(piece_role) + if game_presenter: + board_orientation = game_presenter.board_orientation + is_from_original_left_hand_side = ( + piece_player_side == "w" + if board_orientation == "1->8" + else piece_player_side == "b" + ) + piece_type: PieceType = type_from_piece_role(piece_role) is_knight, is_king = piece_type == "n", piece_type == "k" # Right, let's do this shall we? if ( is_king - and is_active_player_piece and game_presenter - and game_presenter.solution_index is None + and belongs_to_active_player and game_presenter.is_check ): - is_potential_capture = True # let's highlight our king if it's in check - elif ( - is_king - and is_active_player_piece - and game_presenter - and game_presenter.solution_index is not None - and game_presenter.is_check - ): - is_potential_capture = True # let's highlight checks in "see solution" mode + # let's always highlight a king if it's in check: + is_potential_capture = True horizontal_translation = ( - ("left-3" if is_knight else "left-0") if is_w_side else "right-0" + ("left-2" if (is_knight or is_king) else "left-0") + if is_from_original_left_hand_side + else "right-0" + ) + vertical_translation = ( + "top-2" if is_knight and is_from_original_left_hand_side else "top-1" ) - vertical_translation = "top-2" if is_knight and is_w_side else "top-1" - game_factions = cast("Factions", factions or game_presenter.factions) # type: ignore + game_factions = cast("GameFactions", factions or game_presenter.factions) # type: ignore classes = [ "relative", @@ -533,19 +632,23 @@ def chess_character_display( _CHESS_PIECE_Z_INDEXES["character"], horizontal_translation, vertical_translation, - *piece_character_classes(piece_role=piece_role, factions=game_factions), + *piece_character_classes( + board_orientation=board_orientation, + piece_role=piece_role, + factions=game_factions, + ), # Conditional classes: ( ( - "drop-shadow-active-selected-piece" - if is_active_player_piece - else "drop-shadow-opponent-selected-piece" + "drop-shadow-playable-selected-piece" + if is_playable + else "drop-shadow-non-playable-selected-piece" ) if is_highlighted else ( - "drop-shadow-piece-symbol-w" + "drop-shadow-piece-unit-w" if piece_player_side == "w" - else "drop-shadow-piece-symbol-b" + else "drop-shadow-piece-unit-b" ) ), "drop-shadow-potential-capture" if is_potential_capture else "", @@ -560,8 +663,8 @@ def chess_character_display( def chess_unit_ground_marker( - *, player_side: "PlayerSide", can_move: bool = False -) -> "dom_tag": + *, player_side: PlayerSide, can_move: bool = False +) -> dom_tag: classes = [ "absolute", "w-11/12", @@ -580,10 +683,10 @@ def chess_unit_ground_marker( def chess_unit_display_with_ground_marker( *, - piece_role: "PieceRole", - game_presenter: "GamePresenter | None" = None, - factions: "Factions | None" = None, -) -> "dom_tag": + piece_role: PieceRole, + game_presenter: GamePresenter | None = None, + factions: GameFactions | None = None, +) -> dom_tag: assert ( game_presenter or factions ), "You must provide either a GamePresenter or a Factions kwarg." @@ -603,14 +706,18 @@ def chess_unit_display_with_ground_marker( def chess_unit_symbol_display( - *, piece_role: "PieceRole", square: "Square" -) -> "dom_tag": + *, board_orientation: BoardOrientation, piece_role: PieceRole +) -> dom_tag: player_side = player_side_from_piece_role(piece_role) piece_type = type_from_piece_role(piece_role) piece_name = piece_name_from_piece_role(piece_role) is_knight, is_pawn = piece_type == "n", piece_type == "p" + unit_symbol_class = chess_unit_symbol_class( + player_side=player_side, piece_name=piece_name + ) + symbol_class = ( # We have to do some ad-hoc adjustments for Knights and Pawns: "w-7" if (is_pawn or is_knight) else "w-8", @@ -623,19 +730,20 @@ def chess_unit_symbol_display( if player_side == "w" else "drop-shadow-piece-symbol-b" ), - chess_unit_symbol_class(player_side="w", piece_name=piece_name), + unit_symbol_class, ) symbol_display = div( cls=" ".join(symbol_class), ) + should_face_left = piece_should_face_left(board_orientation, player_side) symbol_display_container_classes = ( "absolute", "top-0", - "left-0" if player_side == "w" else "right-0", + "right-0" if should_face_left else "left-0", _CHESS_PIECE_Z_INDEXES["symbol"], # Quick custom display for white knights, so they face the inside of the board: - "-scale-x-100" if player_side == "w" and is_knight else "", + "-scale-x-100" if is_knight and not should_face_left else "", ) return div( @@ -646,14 +754,22 @@ def chess_unit_symbol_display( def chess_last_move( - *, game_presenter: "GamePresenter", board_id: str, **extra_attrs: str -) -> "dom_tag": + *, game_presenter: GamePresenter, board_id: str, **extra_attrs: str +) -> dom_tag: children: list[dom_tag] = [] if last_move := game_presenter.last_move: children.extend( [ - chess_last_move_marker(square=last_move[0], move_part="from"), - chess_last_move_marker(square=last_move[1], move_part="to"), + chess_last_move_marker( + board_orientation=game_presenter.board_orientation, + square=last_move[0], + move_part="from", + ), + chess_last_move_marker( + board_orientation=game_presenter.board_orientation, + square=last_move[1], + move_part="to", + ), ] ) @@ -668,8 +784,11 @@ def chess_last_move( def chess_last_move_marker( - *, square: "Square", move_part: Literal["from", "to"] -) -> "dom_tag": + *, + board_orientation: BoardOrientation, + square: Square, + move_part: Literal["from", "to"], +) -> dom_tag: match move_part: case "from": start_class = "!w-full" @@ -708,7 +827,7 @@ def chess_last_move_marker( "aspect-square", "w-1/8", "flex items-center justify-center", - *square_to_piece_tailwind_classes(square), + *square_to_positioning_tailwind_classes(board_orientation, square), ] return div( @@ -718,8 +837,8 @@ def chess_last_move_marker( def _bot_turn_html_elements( - *, game_presenter: "GamePresenter", board_id: str -) -> "list[dom_tag]": + *, game_presenter: GamePresenter, board_id: str +) -> list[dom_tag]: if ( game_presenter.solution_index is not None or not game_presenter.is_bot_turn @@ -767,8 +886,8 @@ def _bot_turn_html_elements( def _solution_turn_html_elements( - *, game_presenter: "GamePresenter", board_id: str -) -> "list[dom_tag]": + *, game_presenter: GamePresenter, board_id: str +) -> list[dom_tag]: if game_presenter.solution_index is None or game_presenter.is_game_over: return [] diff --git a/src/apps/chess/components/chess_helpers.py b/src/apps/chess/components/chess_helpers.py index aa1c919..fa0f207 100644 --- a/src/apps/chess/components/chess_helpers.py +++ b/src/apps/chess/components/chess_helpers.py @@ -1,19 +1,22 @@ +from __future__ import annotations + from functools import cache from typing import TYPE_CHECKING -from apps.chess.consts import PIECE_TYPE_TO_NAME -from apps.chess.helpers import ( +from apps.chess.chess_helpers import ( file_and_rank_from_square, player_side_from_piece_role, type_from_piece_role, ) +from apps.chess.consts import PIECE_TYPE_TO_NAME if TYPE_CHECKING: from collections.abc import Sequence + from apps.chess.models import GameFactions from apps.chess.types import ( + BoardOrientation, Faction, - Factions, File, PieceName, PieceRole, @@ -22,28 +25,52 @@ Square, ) -_PIECE_FILE_TO_TAILWIND_POSITIONING_CLASS: dict["File", str] = { - "a": "translate-y-0/1", - "b": "translate-y-1/1", - "c": "translate-y-2/1", - "d": "translate-y-3/1", - "e": "translate-y-4/1", - "f": "translate-y-5/1", - "g": "translate-y-6/1", - "h": "translate-y-7/1", +_PIECE_FILE_TO_TAILWIND_POSITIONING_CLASS: dict[BoardOrientation, dict[File, str]] = { + "1->8": { + "a": "translate-y-0/1", + "b": "translate-y-1/1", + "c": "translate-y-2/1", + "d": "translate-y-3/1", + "e": "translate-y-4/1", + "f": "translate-y-5/1", + "g": "translate-y-6/1", + "h": "translate-y-7/1", + }, + "8->1": { + "a": "translate-y-7/1", + "b": "translate-y-6/1", + "c": "translate-y-5/1", + "d": "translate-y-4/1", + "e": "translate-y-3/1", + "f": "translate-y-2/1", + "g": "translate-y-1/1", + "h": "translate-y-0/1", + }, } -_PIECE_RANK_TO_TAILWIND_POSITIONING_CLASS: dict["Rank", str] = { - "1": "translate-x-0/1", - "2": "translate-x-1/1", - "3": "translate-x-2/1", - "4": "translate-x-3/1", - "5": "translate-x-4/1", - "6": "translate-x-5/1", - "7": "translate-x-6/1", - "8": "translate-x-7/1", +_PIECE_RANK_TO_TAILWIND_POSITIONING_CLASS: dict[BoardOrientation, dict[Rank, str]] = { + "1->8": { + "1": "translate-x-0/1", + "2": "translate-x-1/1", + "3": "translate-x-2/1", + "4": "translate-x-3/1", + "5": "translate-x-4/1", + "6": "translate-x-5/1", + "7": "translate-x-6/1", + "8": "translate-x-7/1", + }, + "8->1": { + "1": "translate-x-7/1", + "2": "translate-x-6/1", + "3": "translate-x-5/1", + "4": "translate-x-4/1", + "5": "translate-x-3/1", + "6": "translate-x-2/1", + "7": "translate-x-1/1", + "8": "translate-x-0/1", + }, } -_SQUARE_FILE_TO_TAILWIND_POSITIONING_CLASS: dict["File", str] = { +_SQUARE_FILE_TO_TAILWIND_POSITIONING_CLASS: dict[File, str] = { "a": "top-1/8%", "b": "top-2/8%", "c": "top-3/8%", @@ -53,7 +80,7 @@ "g": "top-7/8%", "h": "top-8/8%", } -_SQUARE_RANK_TO_TAILWIND_POSITIONING_CLASS: dict["Rank", str] = { +_SQUARE_RANK_TO_TAILWIND_POSITIONING_CLASS: dict[Rank, str] = { "1": "left-1/8%", "2": "left-2/8%", "3": "left-3/8%", @@ -64,7 +91,7 @@ "8": "left-8/8%", } -_PIECE_UNITS_CLASSES: "dict[Faction, dict[PieceName, str]]" = { +_PIECE_UNITS_CLASSES: dict[Faction, dict[PieceName, str]] = { # We need Tailwind to see these classes, so that it bundles them in the final CSS file. "humans": { "pawn": "bg-humans-pawn", @@ -84,7 +111,7 @@ }, } -_PIECE_SYMBOLS_CLASSES: "dict[PlayerSide, dict[PieceName, str]]" = { +_PIECE_SYMBOLS_CLASSES: dict[PlayerSide, dict[PieceName, str]] = { # Ditto. "w": { "pawn": "bg-w-pawn", @@ -106,16 +133,18 @@ @cache -def square_to_piece_tailwind_classes(square: "Square") -> "Sequence[str]": +def square_to_positioning_tailwind_classes( + board_orientation: BoardOrientation, square: Square +) -> Sequence[str]: file, rank = file_and_rank_from_square(square) return ( - _PIECE_FILE_TO_TAILWIND_POSITIONING_CLASS[file], - _PIECE_RANK_TO_TAILWIND_POSITIONING_CLASS[rank], + _PIECE_FILE_TO_TAILWIND_POSITIONING_CLASS[board_orientation][file], + _PIECE_RANK_TO_TAILWIND_POSITIONING_CLASS[board_orientation][rank], ) @cache -def square_to_square_center_tailwind_classes(square: "Square") -> "Sequence[str]": +def square_to_square_center_tailwind_classes(square: Square) -> Sequence[str]: file, rank = file_and_rank_from_square(square) return ( _SQUARE_FILE_TO_TAILWIND_POSITIONING_CLASS[file], @@ -123,32 +152,33 @@ def square_to_square_center_tailwind_classes(square: "Square") -> "Sequence[str] ) -def piece_character_classes( - *, piece_role: "PieceRole", factions: "Factions" -) -> "Sequence[str]": - return _piece_character_classes_for_factions( - piece_role=piece_role, factions_tuple=tuple(factions.items()) +@cache +def piece_should_face_left( + board_orientation: BoardOrientation, player_side: PlayerSide +) -> bool: + return (board_orientation == "1->8" and player_side == "b") or ( + board_orientation == "8->1" and player_side == "w" ) @cache -def _piece_character_classes_for_factions( - *, piece_role: "PieceRole", factions_tuple: "tuple[tuple[PlayerSide, Faction], ...]" -) -> "Sequence[str]": - # N.B. We use a tuple here for the factions, so they're hashable and can be used as cached key - piece_name = PIECE_TYPE_TO_NAME[type_from_piece_role(piece_role)] +def piece_character_classes( + *, + board_orientation: BoardOrientation, + piece_role: PieceRole, + factions: GameFactions, +) -> Sequence[str]: player_side = player_side_from_piece_role(piece_role) - factions_dict = dict(factions_tuple) - faction = factions_dict[player_side] + piece_name = PIECE_TYPE_TO_NAME[type_from_piece_role(piece_role)] + faction = factions.get_faction_for_side(player_side) classes = [_PIECE_UNITS_CLASSES[faction][piece_name]] - player_side = player_side_from_piece_role(piece_role) - if player_side == "b": + + if piece_should_face_left(board_orientation, player_side): classes.append("-scale-x-100") + return classes @cache -def chess_unit_symbol_class( - *, player_side: "PlayerSide", piece_name: "PieceName" -) -> str: +def chess_unit_symbol_class(*, player_side: PlayerSide, piece_name: PieceName) -> str: return _PIECE_SYMBOLS_CLASSES[player_side][piece_name] diff --git a/src/apps/chess/components/misc_ui.py b/src/apps/chess/components/misc_ui.py index 200afed..3258173 100644 --- a/src/apps/chess/components/misc_ui.py +++ b/src/apps/chess/components/misc_ui.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import random from typing import TYPE_CHECKING, Literal @@ -27,7 +29,7 @@ # TODO: manage i18n -def modal_container(*, header: "h3", body: div) -> "dom_tag": +def modal_container(*, header: h3, body: div) -> dom_tag: # Converted from https://flowbite.com/docs/components/modal/ modal_header = div( @@ -79,8 +81,8 @@ def modal_container(*, header: "h3", body: div) -> "dom_tag": def speech_bubble_container( - *, game_presenter: "GamePresenter", board_id: str, **extra_attrs: str -) -> "dom_tag": + *, game_presenter: GamePresenter, board_id: str, **extra_attrs: str +) -> dom_tag: if speech_bubble_data := game_presenter.speech_bubble: return speech_bubble( game_presenter=game_presenter, @@ -97,14 +99,14 @@ def speech_bubble_container( def speech_bubble( *, - game_presenter: "GamePresenter", - text: "str | dominate_text", - square: "Square", + game_presenter: GamePresenter, + text: str | dominate_text, + square: Square, time_out: float | None, - character_display: "PieceRole | None" = None, + character_display: PieceRole | None = None, board_id: str, **extra_attrs: str, -) -> "dom_tag": +) -> dom_tag: from .chess_board import chess_character_display relative_position: Literal["left", "right"] = "right" if square[1] < "5" else "left" @@ -222,5 +224,5 @@ def speech_bubble( ) -def reset_chess_engine_worker() -> "dom_tag": +def reset_chess_engine_worker() -> dom_tag: return script(raw("""window.resetChessEngineWorker()""")) diff --git a/src/apps/chess/consts.py b/src/apps/chess/consts.py index 91708d3..dfb5bee 100644 --- a/src/apps/chess/consts.py +++ b/src/apps/chess/consts.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Final import chess @@ -13,9 +15,9 @@ Square, ) -PLAYER_SIDES: Final[tuple["PlayerSide", "PlayerSide"]] = ("w", "b") +PLAYER_SIDES: Final[tuple[PlayerSide, PlayerSide]] = ("w", "b") -PIECES_VALUES: Final[dict["PieceType", int]] = { +PIECES_VALUES: Final[dict[PieceType, int]] = { "p": 1, "n": 3, "b": 3, @@ -24,7 +26,7 @@ } # fmt: off -SQUARES: Final[tuple["Square", ...]] = ( +SQUARES: Final[tuple[Square, ...]] = ( # The order matters here, as we use that for the board visual representation. "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "b1", "b2", "b3", "b4", "b5", "b6", "b7", "b8", @@ -36,18 +38,18 @@ "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", ) # fmt: on -FILES: Final[tuple["File", ...]] = ("a", "b", "c", "d", "e", "f", "g", "h") -RANKS: Final[tuple["Rank", ...]] = ("1", "2", "3", "4", "5", "6", "7", "8") +FILES: Final[tuple[File, ...]] = ("a", "b", "c", "d", "e", "f", "g", "h") +RANKS: Final[tuple[Rank, ...]] = ("1", "2", "3", "4", "5", "6", "7", "8") -MOVES = frozenset(f"{sq1}{sq2}" for sq1 in SQUARES for sq2 in SQUARES if sq1 != sq2) +# MOVES = frozenset(f"{sq1}{sq2}" for sq1 in SQUARES for sq2 in SQUARES if sq1 != sq2) -STARTING_PIECES: dict["PlayerSide", tuple["PieceSymbol"]] = { +STARTING_PIECES: dict[PlayerSide, tuple[PieceSymbol]] = { "w": (*("P" * 8), *("N" * 2), *("B" * 2), *("R" * 2), "Q", "K"), # type: ignore "b": (*("p" * 8), *("n" * 2), *("b" * 2), *("r" * 2), "q", "k"), # type: ignore } -PIECE_INT_TO_PIECE_TYPE: dict[int, "PieceType"] = { +PIECE_INT_TO_PIECE_TYPE: dict[int, PieceType] = { chess.PAWN: "p", chess.KNIGHT: "n", chess.BISHOP: "b", @@ -56,7 +58,7 @@ chess.KING: "k", } -PIECE_TYPE_TO_NAME: dict["PieceType", "PieceName"] = { +PIECE_TYPE_TO_NAME: dict[PieceType, PieceName] = { "p": "pawn", "n": "knight", "b": "bishop", @@ -65,7 +67,7 @@ "k": "king", } -PIECE_TYPE_TO_UNICODE: dict["PieceType", str] = { +PIECE_TYPE_TO_UNICODE: dict[PieceType, str] = { "p": "♟", "n": "♞", "b": "♝", diff --git a/src/apps/chess/exceptions.py b/src/apps/chess/exceptions.py new file mode 100644 index 0000000..782c17e --- /dev/null +++ b/src/apps/chess/exceptions.py @@ -0,0 +1,17 @@ +from __future__ import annotations + + +class ChessLogicException(Exception): + pass + + +class ChessInvalidStateException(ChessLogicException): + pass + + +class ChessInvalidActionException(ChessLogicException): + pass + + +class ChessInvalidMoveException(ChessInvalidActionException): + pass diff --git a/src/apps/chess/models.py b/src/apps/chess/models.py index b17f122..3b2f13f 100644 --- a/src/apps/chess/models.py +++ b/src/apps/chess/models.py @@ -1,9 +1,17 @@ +from __future__ import annotations + import enum -from typing import Self +from typing import TYPE_CHECKING, NamedTuple, Self import msgspec from django.db import models +if TYPE_CHECKING: + from collections.abc import Sequence + + from .types import Faction, GameTeamsDict, PlayerSide, TeamMemberRole + + # MsgSpec doesn't seem to be handling Django Choices correctly, so we have one # "Python enum" for the Struct and one `models.IntegerChoices` derived from it for # Django-related operations (such a forms) 😔 @@ -59,3 +67,45 @@ def to_cookie_content(self) -> str: @classmethod def from_cookie_content(cls, cookie_content: str) -> Self: return msgspec.json.decode(cookie_content.encode(), type=cls) + + +class GameFactions(NamedTuple): + w: Faction # the faction for the "w" player + b: Faction # the faction for the "b" player + + def get_faction_for_side(self, item: PlayerSide) -> Faction: + return getattr(self, item) + + +class TeamMember(NamedTuple): + role: TeamMemberRole + name: Sequence[str] + faction: Faction | None = None + + +class GameTeams(NamedTuple): + """ + We'll use this immutable class to store the team members for each player side. + """ + + w: tuple[TeamMember, ...] # the team members for the "w" player + b: tuple[TeamMember, ...] # the team members for the "b" player + + def get_team_for_side(self, item: PlayerSide) -> tuple[TeamMember]: + return getattr(self, item) + + def to_dict(self) -> GameTeamsDict: + """ + Used to store that in the database + """ + return {"w": list(self.w), "b": list(self.b)} + + @classmethod + def from_dict(cls, data: GameTeamsDict) -> GameTeams: + """ + Used to re-hydrate the data from the database. + """ + return cls( + w=tuple(TeamMember(*member) for member in data["w"]), + b=tuple(TeamMember(*member) for member in data["b"]), + ) diff --git a/src/apps/chess/presenters.py b/src/apps/chess/presenters.py index 910d9c8..8d76e10 100644 --- a/src/apps/chess/presenters.py +++ b/src/apps/chess/presenters.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from abc import ABC, abstractmethod from functools import cached_property from typing import TYPE_CHECKING, NamedTuple, cast @@ -6,8 +8,7 @@ from apps.chess.business_logic import calculate_piece_available_targets -from .consts import PLAYER_SIDES -from .helpers import ( +from .chess_helpers import ( chess_lib_color_to_player_side, chess_lib_square_to_square, get_active_player_side_from_chess_board, @@ -15,32 +16,34 @@ player_side_from_piece_symbol, symbol_from_piece_role, team_member_role_from_piece_role, + type_from_piece_role, ) +from .consts import PIECE_TYPE_TO_NAME, PLAYER_SIDES +from .exceptions import ChessInvalidStateException from .models import UserPrefs -from .types import ChessInvalidStateException if TYPE_CHECKING: from dominate.util import text + from .models import GameFactions, GameTeams, TeamMember from .types import ( FEN, - Factions, + BoardOrientation, GamePhase, - GameTeams, + PieceName, PieceRole, PieceRoleBySquare, PieceSymbol, PieceType, PlayerSide, Square, - TeamMember, TeamMemberRole, ) # Presenters are the objects we pass to our templates. -_PIECES_VALUES: dict["PieceType", int] = { +_PIECES_VALUES: dict[PieceType, int] = { "p": 1, "n": 3, "b": 3, @@ -60,18 +63,19 @@ class GamePresenter(ABC): def __init__( self, *, - fen: "FEN", - piece_role_by_square: "PieceRoleBySquare", - teams: "GameTeams", + fen: FEN, + piece_role_by_square: PieceRoleBySquare, + teams: GameTeams, refresh_last_move: bool, is_htmx_request: bool, - selected_square: "Square | None" = None, - selected_piece_square: "Square | None" = None, - target_to_confirm: "Square | None" = None, - forced_bot_move: tuple["Square", "Square"] | None = None, + selected_square: Square | None = None, + selected_piece_square: Square | None = None, + target_to_confirm: Square | None = None, + forced_bot_move: tuple[Square, Square] | None = None, force_square_info: bool = False, - last_move: tuple["Square", "Square"] | None = None, - captured_piece_role: "PieceRole | None" = None, + target_square_to_confirm: Square | None = None, + last_move: tuple[Square, Square] | None = None, + captured_piece_role: PieceRole | None = None, is_preview: bool = False, bot_depth: int = 1, user_prefs: UserPrefs | None = None, @@ -85,6 +89,7 @@ def __init__( self.refresh_last_move = refresh_last_move self.is_htmx_request = is_htmx_request self.force_square_info = force_square_info + self.target_square_to_confirm = target_square_to_confirm self.last_move = last_move self.captured_piece_role = captured_piece_role self.is_preview = is_preview @@ -109,7 +114,15 @@ def __init__( @property @abstractmethod - def urls(self) -> "GamePresenterUrls": ... + def board_orientation(self) -> BoardOrientation: ... + + @property + @abstractmethod + def urls(self) -> GamePresenterUrls: ... + + @property + @abstractmethod + def moves_must_be_confirmed(self) -> bool: ... @property @abstractmethod @@ -117,7 +130,11 @@ def is_my_turn(self) -> bool: ... @property @abstractmethod - def game_phase(self) -> "GamePhase": ... + def my_side(self) -> PlayerSide | None: ... + + @property + @abstractmethod + def game_phase(self) -> GamePhase: ... # Properties derived from the chess board: @cached_property @@ -133,7 +150,7 @@ def is_game_over(self) -> bool: return self.winner is not None @cached_property - def winner(self) -> "PlayerSide | None": + def winner(self) -> PlayerSide | None: return ( None if (outcome := self._chess_board.outcome()) is None @@ -141,29 +158,25 @@ def winner(self) -> "PlayerSide | None": ) @cached_property - def active_player(self) -> "PlayerSide": + def active_player(self) -> PlayerSide: return get_active_player_side_from_chess_board(self._chess_board) @cached_property - def squares_with_pieces_that_can_move(self) -> set["Square"]: - return set( + def squares_with_pieces_that_can_move(self) -> set[Square]: + return { chess_lib_square_to_square(move.from_square) for move in self._chess_board.legal_moves - ) + } # Properties derived from the Game model: @cached_property - def active_player_side(self) -> "PlayerSide": + def active_player_side(self) -> PlayerSide: return chess_lib_color_to_player_side(self._chess_board.turn) @property def can_select_pieces(self) -> bool: return True - @property - @abstractmethod - def is_player_turn(self) -> bool: ... - @property @abstractmethod def is_bot_turn(self) -> bool: ... @@ -178,7 +191,7 @@ def game_id(self) -> str: ... @property @abstractmethod - def factions(self) -> "Factions": ... + def factions(self) -> GameFactions: ... @property @abstractmethod @@ -186,17 +199,17 @@ def is_intro_turn(self) -> bool: ... @property @abstractmethod - def player_side_to_highlight_all_pieces_for(self) -> "PlayerSide | None": ... + def player_side_to_highlight_all_pieces_for(self) -> PlayerSide | None: ... @property @abstractmethod - def speech_bubble(self) -> "SpeechBubbleData | None": ... + def speech_bubble(self) -> SpeechBubbleData | None: ... @cached_property - def piece_role_by_square(self) -> "PieceRoleBySquare": + def piece_role_by_square(self) -> PieceRoleBySquare: return self._piece_role_by_square - def piece_role_at_square(self, square: "Square") -> "PieceRole": + def piece_role_at_square(self, square: Square) -> PieceRole: try: return self._piece_role_by_square[square] except KeyError as exc: @@ -205,12 +218,12 @@ def piece_role_at_square(self, square: "Square") -> "PieceRole": @cached_property def team_members_by_role_by_side( self, - ) -> "dict[PlayerSide, dict[TeamMemberRole, TeamMember]]": - result: "dict[PlayerSide, dict[TeamMemberRole, TeamMember]]" = {} + ) -> dict[PlayerSide, dict[TeamMemberRole, TeamMember]]: + result: dict[PlayerSide, dict[TeamMemberRole, TeamMember]] = {} for player_side in PLAYER_SIDES: result[player_side] = {} - for team_member in self._teams[player_side]: - member_role = team_member_role_from_piece_role(team_member["role"]) + for team_member in self._teams.get_team_for_side(player_side): + member_role = team_member_role_from_piece_role(team_member.role) result[player_side][member_role] = team_member return result @@ -243,20 +256,31 @@ class GamePresenterUrls(ABC): def __init__(self, *, game_presenter: GamePresenter): self._game_presenter = game_presenter + @abstractmethod def htmx_game_no_selection_url(self, *, board_id: str) -> str: - raise NotImplementedError + pass - def htmx_game_select_piece_url(self, *, square: "Square", board_id: str) -> str: - raise NotImplementedError + @abstractmethod + def htmx_game_select_piece_url(self, *, square: Square, board_id: str) -> str: + pass - def htmx_game_move_piece_url(self, *, square: "Square", board_id: str) -> str: - raise NotImplementedError + @abstractmethod + def htmx_game_move_piece_confirmation_dialog_url( + self, *, square: Square, board_id: str + ) -> str: + pass + @abstractmethod + def htmx_game_move_piece_url(self, *, square: Square, board_id: str) -> str: + pass + + @abstractmethod def htmx_game_play_bot_move_url(self, *, board_id: str) -> str: - raise NotImplementedError + pass + @abstractmethod def htmx_game_play_solution_move_url(self, *, board_id: str) -> str: - raise NotImplementedError + pass class SelectedSquarePresenter: @@ -265,14 +289,14 @@ def __init__( *, game_presenter: GamePresenter, chess_board: chess.Board, - square: "Square", + square: Square, ): self._game_presenter = game_presenter self._chess_board = chess_board self.square = square @cached_property - def team_member(self) -> "TeamMember": + def team_member(self) -> TeamMember: player_side = ( self._game_presenter.selected_piece.player_side if self._game_presenter.selected_piece @@ -283,21 +307,21 @@ def team_member(self) -> "TeamMember": ] @cached_property - def player_side(self) -> "PlayerSide": + def player_side(self) -> PlayerSide: return player_side_from_piece_role( self._game_presenter.piece_role_at_square(self.square) ) @cached_property - def symbol(self) -> "PieceSymbol": + def symbol(self) -> PieceSymbol: return symbol_from_piece_role(self.piece_role) @cached_property - def piece_role(self) -> "PieceRole": + def piece_role(self) -> PieceRole: return self._game_presenter.piece_role_by_square[self.square] @cached_property - def piece_at(self) -> "chess.Piece": + def piece_at(self) -> chess.Piece: return cast("chess.Piece", self._chess_board.piece_at(self._chess_lib_square)) @cached_property @@ -317,8 +341,8 @@ def __init__( *, game_presenter: GamePresenter, chess_board: chess.Board, - piece_square: "Square", - target_to_confirm: "Square | None", + piece_square: Square, + target_to_confirm: Square | None, ): super().__init__( game_presenter=game_presenter, @@ -328,7 +352,7 @@ def __init__( self.target_to_confirm = target_to_confirm @cached_property - def available_targets(self) -> frozenset["Square"]: + def available_targets(self) -> frozenset[Square]: chess_board_active_player_side = chess_lib_color_to_player_side( self._chess_board.turn ) @@ -349,7 +373,7 @@ def available_targets(self) -> frozenset["Square"]: chess_board=chess_board, piece_square=self.square ) - def is_potential_capture(self, square: "Square") -> bool: + def is_potential_capture(self, square: Square) -> bool: return square in self.available_targets and self.piece_at is not None @cached_property @@ -359,6 +383,10 @@ def is_pinned(self) -> bool: self._chess_lib_square, ) + @cached_property + def piece_name(self) -> PieceName: + return PIECE_TYPE_TO_NAME[type_from_piece_role(self.piece_role)] + def __str__(self) -> str: return f"{self.piece_role} at {self.square}" @@ -367,7 +395,7 @@ def __repr__(self) -> str: class SpeechBubbleData(NamedTuple): - text: "str | text" - square: "Square" + text: str | text + square: Square time_out: float | None = None # if it's None, should be expressed in seconds - character_display: "PieceRole | None" = None + character_display: PieceRole | None = None diff --git a/src/apps/chess/tests/business_logic/test_do_chess_move.py b/src/apps/chess/tests/business_logic/test_do_chess_move.py index 1145321..914017c 100644 --- a/src/apps/chess/tests/business_logic/test_do_chess_move.py +++ b/src/apps/chess/tests/business_logic/test_do_chess_move.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING import pytest @@ -40,15 +42,13 @@ ), ) def test_can_manage_en_passant_correctly( - starting_fen: "FEN", - move: "tuple[Square, Square]", - expected_fen_after_en_passant: "FEN", - expected_moves: list["MoveTuple"], - expected_captured: "Square", + starting_fen: FEN, + move: tuple[Square, Square], + expected_fen_after_en_passant: FEN, + expected_moves: list[MoveTuple], + expected_captured: Square, ): - result: "ChessMoveResult" = do_chess_move( - fen=starting_fen, from_=move[0], to=move[1] - ) + result: ChessMoveResult = do_chess_move(fen=starting_fen, from_=move[0], to=move[1]) assert result["is_capture"] is True assert result["fen"] == expected_fen_after_en_passant diff --git a/src/apps/chess/types.py b/src/apps/chess/types.py index 7856375..fabd58d 100644 --- a/src/apps/chess/types.py +++ b/src/apps/chess/types.py @@ -1,6 +1,18 @@ -from typing import Literal, Required, TypeAlias, TypedDict +from __future__ import annotations +from typing import TYPE_CHECKING, Literal, TypeAlias, TypedDict + +if TYPE_CHECKING: + from .models import TeamMember + +# Apart from cases when we use these types in msgspec models (the package will need their +# "real" imports to be able to work with them), the types defined here should always +# be used in `if TYPE_CHECKING:` blocks. + +# https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation FEN: TypeAlias = str +# https://en.wikipedia.org/wiki/Portable_Game_Notation +PGN: TypeAlias = str # fmt: off PlayerSide = Literal[ @@ -28,7 +40,6 @@ PieceName = Literal["pawn", "knight", "bishop", "rook", "queen", "king"] - # fmt: off TeamMemberRole = Literal[ # 8 pawns: @@ -72,6 +83,7 @@ ] # fmt: on +UCIMove: TypeAlias = str # e.g. "e2e4", "a7a8q"... MoveTuple = tuple[Square, Square] SquareColor = Literal["light", "dark"] @@ -102,51 +114,31 @@ "fifty_moves", ] +BoardOrientation = Literal[ + "1->8", # initial "white" side on the left-hand side + "8->1", # initial "black" side on the left-hand side +] + Faction = Literal[ "humans", - "undeads", + "undeads", # mispelled, but it's a bit everywhere in the codebase now 😅 ] -Factions: TypeAlias = dict[PlayerSide, Faction] - class GameOverDescription(TypedDict): - winner: "PlayerSide | None" - reason: "GameEndReason" + winner: PlayerSide | None + reason: GameEndReason class ChessMoveResult(TypedDict): - fen: "FEN" + fen: FEN moves: list[MoveTuple] is_capture: bool captured: Square | None is_castling: bool - promotion: "PieceType | None" + promotion: PieceType | None game_over: GameOverDescription | None -class TeamMember(TypedDict, total=False): - role: Required["TeamMemberRole"] - # TODO: change this to just `lst[str]` when we finished migrating to a list-name - name: Required[list[str] | str] - faction: "Faction" - - -GameTeams: TypeAlias = dict["PlayerSide", list["TeamMember"]] - - -class ChessLogicException(Exception): - pass - - -class ChessInvalidStateException(ChessLogicException): - pass - - -class ChessInvalidActionException(ChessLogicException): - pass - - -class ChessInvalidMoveException(ChessInvalidActionException): - pass +GameTeamsDict: TypeAlias = "dict[PlayerSide, list[TeamMember]]" diff --git a/src/apps/chess/url_converters.py b/src/apps/chess/url_converters.py index 8b0fe72..0db87a9 100644 --- a/src/apps/chess/url_converters.py +++ b/src/apps/chess/url_converters.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -7,8 +9,8 @@ class ChessSquareConverter: regex = "[a-h][1-8]" - def to_python(self, value: str) -> "Square": + def to_python(self, value: str) -> Square: return value # type: ignore - def to_url(self, value: "Square") -> str: + def to_url(self, value: Square) -> str: return value # type: ignore diff --git a/src/apps/conftest.py b/src/apps/conftest.py index 790312d..e161ddd 100644 --- a/src/apps/conftest.py +++ b/src/apps/conftest.py @@ -5,3 +5,8 @@ @pytest.fixture def cleared_django_default_cache() -> None: cache.clear() + + +@pytest.fixture +async def acleared_django_default_cache() -> None: + await cache.aclear() diff --git a/src/apps/daily_challenge/admin.py b/src/apps/daily_challenge/admin.py index 829b97b..5dfb797 100644 --- a/src/apps/daily_challenge/admin.py +++ b/src/apps/daily_challenge/admin.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import json import re from datetime import timedelta -from typing import TYPE_CHECKING, Any, Callable, Literal, TypeAlias, TypedDict, cast +from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict, cast import chess from django import forms @@ -26,6 +28,8 @@ from .view_helpers import GameContext if TYPE_CHECKING: + from collections.abc import Callable + from django.db.models import QuerySet from django.http import HttpRequest @@ -51,7 +55,7 @@ _FUTURE_DAILY_CHALLENGE_COOKIE_DURATION = timedelta(minutes=20) -_INVALID_FEN_FALLBACK: "FEN" = "3k4/p7/8/8/8/8/7P/3K4 w - - 0 1" +_INVALID_FEN_FALLBACK: FEN = "3k4/p7/8/8/8/8/7P/3K4 w - - 0 1" class DailyChallengeAdminForm(forms.ModelForm): @@ -106,13 +110,13 @@ class SourceTypeListFilter(admin.SimpleListFilter): title = _("source type") parameter_name = "source_type" - def lookups(self, request: "HttpRequest", model_admin: admin.ModelAdmin): + def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin): return [ ("none", _("None")), ("lichess", _("Lichess")), ] - def queryset(self, request: "HttpRequest", queryset: "QuerySet[DailyChallenge]"): + def queryset(self, request: HttpRequest, queryset: QuerySet[DailyChallenge]): match self.value(): case "none": return queryset.filter(source__isnull=True) @@ -186,7 +190,7 @@ def get_urls(self) -> list: @staticmethod def play_future_daily_challenge_view( - request: "HttpRequest", lookup_key: str + request: HttpRequest, lookup_key: str ) -> HttpResponse: ctx = GameContext.create_from_request(request) clear_daily_challenge_game_state_in_session( @@ -199,12 +203,13 @@ def play_future_daily_challenge_view( lookup_key, expires=now() + _FUTURE_DAILY_CHALLENGE_COOKIE_DURATION, httponly=True, + samesite="Lax", ) return response @method_decorator(xframe_options_exempt) - def preview_daily_challenge_view(self, request: "HttpRequest") -> HttpResponse: + def preview_daily_challenge_view(self, request: HttpRequest) -> HttpResponse: from dominate.util import raw from apps.chess.components.chess_board import chess_arena @@ -297,9 +302,7 @@ def preview_daily_challenge_view(self, request: "HttpRequest") -> HttpResponse: else "" ), # Last but certainly not least, display the chess board: - chess_arena( - game_presenter=game_presenter, status_bars=[], board_id=board_id - ), + chess_arena(game_presenter=game_presenter, board_id=board_id), request=request, ) ) @@ -367,7 +370,7 @@ class DailyChallengeStatsAdmin(admin.ModelAdmin): list_display_links = None view_on_site = False - def get_queryset(self, request: "HttpRequest") -> "QuerySet[DailyChallengeStats]": + def get_queryset(self, request: HttpRequest) -> QuerySet[DailyChallengeStats]: return super().get_queryset(request).select_related("challenge") def challenge_link(self, obj: DailyChallengeStats) -> str: @@ -386,24 +389,24 @@ def wins_percentage(self, obj: DailyChallengeStats) -> str: return f"{obj.wins_count/total:.1%}" if total else "-" # Stats are read-only: - def has_add_permission(self, request: "HttpRequest") -> bool: + def has_add_permission(self, request: HttpRequest) -> bool: return False def has_change_permission( - self, request: "HttpRequest", obj: DailyChallengeStats | None = None + self, request: HttpRequest, obj: DailyChallengeStats | None = None ) -> bool: return False def has_delete_permission( - self, request: "HttpRequest", obj: DailyChallengeStats | None = None + self, request: HttpRequest, obj: DailyChallengeStats | None = None ) -> bool: return False def _get_game_presenter( - fen: "FEN | None", + fen: FEN | None, bot_first_move: str | None, - intro_turn_speech_square: "Square | None", + intro_turn_speech_square: Square | None, game_update_cmd: GameUpdateCommand | None, ) -> DailyChallengeGamePresenter: from .models import PlayerGameState @@ -418,7 +421,7 @@ def _get_game_presenter( ) challenge_preview = DailyChallenge( fen=fen, - teams=game_teams, + teams=game_teams.to_dict(), piece_role_by_square=piece_role_by_square, ) setattr(challenge_preview, "max_turns_count", 40) # we need this to return a value @@ -444,20 +447,20 @@ def _get_game_presenter( ) -def _apply_game_update(*, fen: "FEN", game_update_cmd: GameUpdateCommand) -> "FEN": +def _apply_game_update(*, fen: FEN, game_update_cmd: GameUpdateCommand) -> FEN: """Dispatches the game update command to the appropriate function.""" cmd_type, params = game_update_cmd return _GAME_UPDATE_MAPPING[cmd_type](fen=fen, **params) -def _add_piece_to_square(*, fen: "FEN", target: "Square", piece: "PieceSymbol") -> str: +def _add_piece_to_square(*, fen: FEN, target: Square, piece: PieceSymbol) -> str: chess_board = chess.Board(fen) square_int = chess.parse_square(target.lower()) chess_board.set_piece_at(square_int, chess.Piece.from_symbol(piece)) return cast("FEN", chess_board.fen()) -def _move_piece_to_square(*, fen: "FEN", from_: "Square", to: "Square") -> str: +def _move_piece_to_square(*, fen: FEN, from_: Square, to: Square) -> str: chess_board = chess.Board(fen) square_from_int = chess.parse_square(from_.lower()) square_to_int = chess.parse_square(to.lower()) @@ -467,21 +470,21 @@ def _move_piece_to_square(*, fen: "FEN", from_: "Square", to: "Square") -> str: return cast("FEN", chess_board.fen()) -def _remove_piece_from_square(*, fen: "FEN", target: "Square") -> str: +def _remove_piece_from_square(*, fen: FEN, target: Square) -> str: chess_board = chess.Board(fen) square_int = chess.parse_square(target.lower()) chess_board.remove_piece_at(square_int) return cast("FEN", chess_board.fen()) -def _mirror_board(*, fen: "FEN") -> str: +def _mirror_board(*, fen: FEN) -> str: chess_board = chess.Board(fen) chess_board.apply_mirror() chess_board.turn = chess.WHITE # it's still the human player's turn return cast("FEN", chess_board.fen()) -def _solve_problem(*, fen: "FEN") -> str: +def _solve_problem(*, fen: FEN) -> str: # This is a no-op on the server, as we solve problems on the frontend side chess_board = chess.Board(fen) return cast("FEN", chess_board.fen()) @@ -530,7 +533,7 @@ def clean_bot_first_move(self) -> str | None: raise ValidationError(exc) from exc return bot_first_move - def clean_intro_turn_speech_square(self) -> "Square | None": + def clean_intro_turn_speech_square(self) -> Square | None: intro_turn_speech_square = self.cleaned_data.get("intro_turn_speech_square", "") if not intro_turn_speech_square or len(intro_turn_speech_square) != 2: return None @@ -560,11 +563,11 @@ def clean_game_update(self) -> GameUpdateCommand | None: if TYPE_CHECKING: class CleanedData(TypedDict): - fen: "FEN" + fen: FEN bot_first_move: str | None bot_depth: int player_simulated_depth: int - intro_turn_speech_square: "Square | None" + intro_turn_speech_square: Square | None game_update: GameUpdateCommand @property diff --git a/src/apps/daily_challenge/business_logic/__init__.py b/src/apps/daily_challenge/business_logic/__init__.py index 42a3a50..b021846 100644 --- a/src/apps/daily_challenge/business_logic/__init__.py +++ b/src/apps/daily_challenge/business_logic/__init__.py @@ -1,4 +1,5 @@ # ruff: noqa: F401 + from ._compute_fields_before_bot_first_move import compute_fields_before_bot_first_move from ._get_current_daily_challenge import get_current_daily_challenge from ._get_speech_bubble import get_speech_bubble @@ -18,3 +19,4 @@ from ._set_daily_challenge_teams_and_pieces_roles import ( set_daily_challenge_teams_and_pieces_roles, ) +from ._undo_last_move import undo_last_move diff --git a/src/apps/daily_challenge/business_logic/_compute_fields_before_bot_first_move.py b/src/apps/daily_challenge/business_logic/_compute_fields_before_bot_first_move.py index 2e70fa7..0f26b36 100644 --- a/src/apps/daily_challenge/business_logic/_compute_fields_before_bot_first_move.py +++ b/src/apps/daily_challenge/business_logic/_compute_fields_before_bot_first_move.py @@ -1,6 +1,8 @@ +from __future__ import annotations + from typing import TYPE_CHECKING -from apps.chess.helpers import uci_move_squares +from apps.chess.chess_helpers import uci_move_squares from ...chess.business_logic import calculate_fen_before_move from ..consts import BOT_SIDE @@ -10,7 +12,7 @@ def compute_fields_before_bot_first_move( - challenge: "DailyChallenge", + challenge: DailyChallenge, ) -> None: """ Set the `*_before_bot_first_move` fields on the given challenge models, diff --git a/src/apps/daily_challenge/business_logic/_get_current_daily_challenge.py b/src/apps/daily_challenge/business_logic/_get_current_daily_challenge.py index 76ad896..49f1c48 100644 --- a/src/apps/daily_challenge/business_logic/_get_current_daily_challenge.py +++ b/src/apps/daily_challenge/business_logic/_get_current_daily_challenge.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from typing import TYPE_CHECKING @@ -11,7 +13,7 @@ _logger = logging.getLogger("apps.daily_challenge") -def get_current_daily_challenge() -> "DailyChallenge": +def get_current_daily_challenge() -> DailyChallenge: from ..models import DailyChallenge today = timezone.now().date() diff --git a/src/apps/daily_challenge/business_logic/_get_speech_bubble.py b/src/apps/daily_challenge/business_logic/_get_speech_bubble.py index d88aa64..971a8e7 100644 --- a/src/apps/daily_challenge/business_logic/_get_speech_bubble.py +++ b/src/apps/daily_challenge/business_logic/_get_speech_bubble.py @@ -1,9 +1,11 @@ +from __future__ import annotations + import random from typing import TYPE_CHECKING from dominate.util import raw -from apps.chess.helpers import ( +from apps.chess.chess_helpers import ( chess_lib_square_to_square, player_side_to_chess_lib_color, team_member_role_from_piece_role, @@ -13,7 +15,8 @@ if TYPE_CHECKING: import chess - from apps.chess.types import PlayerSide, Square, TeamMember + from apps.chess.models import TeamMember + from apps.chess.types import PlayerSide, Square from apps.daily_challenge.presenters import DailyChallengeGamePresenter # This code was originally part of the DailyChallengeGamePresenter class, @@ -36,7 +39,7 @@ def get_speech_bubble( - game_presenter: "DailyChallengeGamePresenter", + game_presenter: DailyChallengeGamePresenter, ) -> SpeechBubbleData | None: if game_presenter.game_state.solution_index is not None: return None @@ -86,16 +89,10 @@ def get_speech_bubble( team_member_role = team_member_role_from_piece_role( game_presenter.captured_piece_role ) - captured_team_member: "TeamMember" = ( - game_presenter.team_members_by_role_by_side[ - game_presenter.challenge.my_side - ][team_member_role] - ) - if isinstance(name := captured_team_member["name"], str): - # TODO: remove that code when we finished migrating to a list-name - captured_team_member_display = name.split(" ")[0] - else: - captured_team_member_display = name[0] + captured_team_member: TeamMember = game_presenter.team_members_by_role_by_side[ + game_presenter.challenge.my_side + ][team_member_role] + captured_team_member_display = captured_team_member.name[0] reaction, reaction_time_out = random.choice(_UNIT_LOST_REACTIONS) return SpeechBubbleData( text=reaction.format(captured_team_member_display), @@ -130,7 +127,7 @@ def get_speech_bubble( ) if ( - game_presenter.is_player_turn + game_presenter.is_my_turn and game_presenter.is_htmx_request and not game_presenter.selected_piece and game_presenter.naive_score < -3 @@ -151,10 +148,10 @@ def get_speech_bubble( def _bot_leftmost_piece_square( - chess_board: "chess.Board", bot_side: "PlayerSide" -) -> "Square": + chess_board: chess.Board, bot_side: PlayerSide +) -> Square: leftmost_rank = 9 # *will* be overridden by our loop - leftmost_square: "Square" = "h8" # ditto + leftmost_square: Square = "h8" # ditto bot_color = player_side_to_chess_lib_color(bot_side) for square_int, piece in chess_board.piece_map().items(): if piece.color != bot_color: @@ -167,11 +164,11 @@ def _bot_leftmost_piece_square( return leftmost_square -def _my_king_square(game_presenter: "DailyChallengeGamePresenter") -> "Square": +def _my_king_square(game_presenter: DailyChallengeGamePresenter) -> Square: return _king_square(game_presenter.chess_board, game_presenter.challenge.my_side) -def _king_square(chess_board: "chess.Board", player_side: "PlayerSide") -> "Square": +def _king_square(chess_board: chess.Board, player_side: PlayerSide) -> Square: return chess_lib_square_to_square( chess_board.king(player_side_to_chess_lib_color(player_side)) ) diff --git a/src/apps/daily_challenge/business_logic/_has_player_won_today.py b/src/apps/daily_challenge/business_logic/_has_player_won_today.py index 226fd42..09ed4c8 100644 --- a/src/apps/daily_challenge/business_logic/_has_player_won_today.py +++ b/src/apps/daily_challenge/business_logic/_has_player_won_today.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from django.utils.timezone import now @@ -6,6 +8,6 @@ from ..models import PlayerStats -def has_player_won_today(stats: "PlayerStats") -> bool: +def has_player_won_today(stats: PlayerStats) -> bool: today = now().date() return bool((last_won := stats.last_won) and today == last_won) diff --git a/src/apps/daily_challenge/business_logic/_has_player_won_yesterday.py b/src/apps/daily_challenge/business_logic/_has_player_won_yesterday.py index 3c1eb48..68e23e1 100644 --- a/src/apps/daily_challenge/business_logic/_has_player_won_yesterday.py +++ b/src/apps/daily_challenge/business_logic/_has_player_won_yesterday.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from django.utils.timezone import now @@ -6,6 +8,6 @@ from ..models import PlayerStats -def has_player_won_yesterday(stats: "PlayerStats") -> bool: +def has_player_won_yesterday(stats: PlayerStats) -> bool: today = now().date() return bool((last_won := stats.last_won) and (today - last_won).days == 1) diff --git a/src/apps/daily_challenge/business_logic/_manage_daily_challenge_defeat_logic.py b/src/apps/daily_challenge/business_logic/_manage_daily_challenge_defeat_logic.py index d54a869..2dc0330 100644 --- a/src/apps/daily_challenge/business_logic/_manage_daily_challenge_defeat_logic.py +++ b/src/apps/daily_challenge/business_logic/_manage_daily_challenge_defeat_logic.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from ..models import PlayerGameOverState @@ -7,7 +9,7 @@ def manage_daily_challenge_defeat_logic( - *, game_state: "PlayerGameState", is_preview: bool = False + *, game_state: PlayerGameState, is_preview: bool = False ) -> None: """ When a player loses a daily challenge, we may need to update part of their game state. diff --git a/src/apps/daily_challenge/business_logic/_manage_daily_challenge_moved_piece_logic.py b/src/apps/daily_challenge/business_logic/_manage_daily_challenge_moved_piece_logic.py index f3a399f..d3427b3 100644 --- a/src/apps/daily_challenge/business_logic/_manage_daily_challenge_moved_piece_logic.py +++ b/src/apps/daily_challenge/business_logic/_manage_daily_challenge_moved_piece_logic.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from django.utils.timezone import now @@ -10,8 +12,8 @@ def manage_daily_challenge_moved_piece_logic( *, - game_state: "PlayerGameState", - stats: "PlayerStats", + game_state: PlayerGameState, + stats: PlayerStats, is_preview: bool = False, is_staff_user: bool = False, ) -> None: diff --git a/src/apps/daily_challenge/business_logic/_manage_daily_challenge_victory_logic.py b/src/apps/daily_challenge/business_logic/_manage_daily_challenge_victory_logic.py index 0b56c87..8f022c1 100644 --- a/src/apps/daily_challenge/business_logic/_manage_daily_challenge_victory_logic.py +++ b/src/apps/daily_challenge/business_logic/_manage_daily_challenge_victory_logic.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, cast from django.utils.timezone import now @@ -10,8 +12,8 @@ def manage_daily_challenge_victory_logic( *, - challenge: "DailyChallenge", - game_state: "PlayerGameState", + challenge: DailyChallenge, + game_state: PlayerGameState, stats: PlayerStats, is_preview: bool = False, is_staff_user: bool = False, diff --git a/src/apps/daily_challenge/business_logic/_manage_new_daily_challenge_stats_logic.py b/src/apps/daily_challenge/business_logic/_manage_new_daily_challenge_stats_logic.py index 9ecb4de..3564857 100644 --- a/src/apps/daily_challenge/business_logic/_manage_new_daily_challenge_stats_logic.py +++ b/src/apps/daily_challenge/business_logic/_manage_new_daily_challenge_stats_logic.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from ..models import DailyChallengeStats @@ -8,7 +10,7 @@ def manage_new_daily_challenge_stats_logic( - stats: "PlayerStats", *, is_preview: bool = False, is_staff_user: bool = False + stats: PlayerStats, *, is_preview: bool = False, is_staff_user: bool = False ) -> None: """ When a player starts a new daily challenge, diff --git a/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py b/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py index 49c139d..a661f02 100644 --- a/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py +++ b/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py @@ -1,52 +1,37 @@ -from typing import TYPE_CHECKING, cast +from __future__ import annotations -from apps.chess.business_logic import do_chess_move -from apps.chess.helpers import get_active_player_side_from_fen -from apps.chess.types import ChessInvalidStateException +from typing import TYPE_CHECKING, NamedTuple + +from apps.chess.business_logic import do_chess_move_with_piece_role_by_square from ..models import PlayerGameOverState if TYPE_CHECKING: - from apps.chess.types import PieceRole, PieceSymbol, Square + from apps.chess.types import PieceRole, Square from ..models import PlayerGameState +class MoveDailyChallengePieceResult(NamedTuple): + game_state: PlayerGameState + captured_piece: PieceRole | None + + def move_daily_challenge_piece( *, - game_state: "PlayerGameState", - from_: "Square", - to: "Square", + game_state: PlayerGameState, + from_: Square, + to: Square, is_my_side: bool, -) -> tuple["PlayerGameState", "PieceRole | None"]: - fen = game_state.fen - active_player_side = get_active_player_side_from_fen(fen) - try: - move_result = do_chess_move( - fen=fen, +) -> MoveDailyChallengePieceResult: + move_result, piece_role_by_square, captured_piece = ( + do_chess_move_with_piece_role_by_square( + fen=game_state.fen, from_=from_, to=to, + piece_role_by_square=game_state.piece_role_by_square, ) - except ValueError as err: - raise ChessInvalidStateException(f"Suspicious chess move: '{err}'") from err - - piece_role_by_square = game_state.piece_role_by_square.copy() - if promotion := move_result["promotion"]: - # Let's promote that piece! - piece_promotion = cast( - "PieceSymbol", promotion.upper() if active_player_side == "w" else promotion - ) - piece_role_by_square[from_] += piece_promotion # type: ignore - - captured_piece: "PieceRole | None" = None - if captured := move_result["captured"]: - assert move_result["is_capture"] - captured_piece = piece_role_by_square[captured] - del piece_role_by_square[captured] # this square is now empty - - for move_from, move_to in move_result["moves"]: - piece_role_by_square[move_to] = piece_role_by_square[move_from] - del piece_role_by_square[move_from] # this square is now empty + ) if game_over := move_result["game_over"]: game_over_state = ( @@ -69,4 +54,4 @@ def move_daily_challenge_piece( new_game_state.turns_counter += 1 new_game_state.current_attempt_turns_counter += 1 - return new_game_state, captured_piece + return MoveDailyChallengePieceResult(new_game_state, captured_piece) diff --git a/src/apps/daily_challenge/business_logic/_restart_daily_challenge.py b/src/apps/daily_challenge/business_logic/_restart_daily_challenge.py index fbc5494..4b84c54 100644 --- a/src/apps/daily_challenge/business_logic/_restart_daily_challenge.py +++ b/src/apps/daily_challenge/business_logic/_restart_daily_challenge.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import copy from typing import TYPE_CHECKING @@ -9,10 +11,10 @@ def restart_daily_challenge( *, - challenge: "DailyChallenge", - game_state: "PlayerGameState", + challenge: DailyChallenge, + game_state: PlayerGameState, is_staff_user: bool = False, -) -> "PlayerGameState": +) -> PlayerGameState: # These fields are always set on a published challenge - let's make the # type checker happy: assert ( diff --git a/src/apps/daily_challenge/business_logic/_see_daily_challenge_solution.py b/src/apps/daily_challenge/business_logic/_see_daily_challenge_solution.py index 87fc697..0387a7c 100644 --- a/src/apps/daily_challenge/business_logic/_see_daily_challenge_solution.py +++ b/src/apps/daily_challenge/business_logic/_see_daily_challenge_solution.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import copy from typing import TYPE_CHECKING @@ -10,11 +12,11 @@ def see_daily_challenge_solution( *, - challenge: "DailyChallenge", - stats: "PlayerStats", - game_state: "PlayerGameState", + challenge: DailyChallenge, + stats: PlayerStats, + game_state: PlayerGameState, is_staff_user: bool = False, -) -> "PlayerGameState": +) -> PlayerGameState: # This field is always set on a published challenge - let's make the # type checker happy: assert challenge.piece_role_by_square diff --git a/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py b/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py index 324a5bb..6a507cc 100644 --- a/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py +++ b/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py @@ -1,29 +1,30 @@ +from __future__ import annotations + import random -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, TypeAlias, cast import chess -from apps.chess.data.team_member_names import FIRST_NAMES, LAST_NAMES -from apps.chess.helpers import ( +from apps.chess.chess_helpers import ( chess_lib_color_to_player_side, chess_lib_square_to_square, + piece_role_from_team_member_role_and_player_side, player_side_other, ) +from apps.chess.data.team_member_names import FIRST_NAMES, LAST_NAMES +from apps.chess.models import GameTeams, TeamMember if TYPE_CHECKING: from apps.chess.types import ( FEN, Faction, - GameTeams, - PieceRole, PieceRoleBySquare, PieceType, PlayerSide, - TeamMember, TeamMemberRole, ) -_CHESS_LIB_PIECE_TYPE_TO_PIECE_TYPE_MAPPING: dict[int, "PieceType"] = { +_CHESS_LIB_PIECE_TYPE_TO_PIECE_TYPE_MAPPING: dict[int, PieceType] = { chess.PAWN: "p", chess.KNIGHT: "n", chess.BISHOP: "b", @@ -32,20 +33,22 @@ chess.KING: "k", } +TeamsDict: TypeAlias = "dict[PlayerSide, list[TeamMember]]" + def set_daily_challenge_teams_and_pieces_roles( *, - fen: "FEN", - default_faction_w: "Faction" = "humans", - default_faction_b: "Faction" = "undeads", - bot_side: "PlayerSide" = "b", + fen: FEN, + default_faction_w: Faction = "humans", + default_faction_b: Faction = "undeads", + bot_side: PlayerSide = "b", # TODO: allow partial customisation of team members? # custom_team_members: "GameTeams | None" = None, -) -> tuple["GameTeams", "PieceRoleBySquare"]: +) -> tuple[GameTeams, PieceRoleBySquare]: chess_board = chess.Board(fen) # fmt: off - team_members_counters: dict["PlayerSide", dict["PieceType", list[int]]] = { + team_members_counters: dict[PlayerSide, dict[PieceType, list[int]]] = { # - First int of the tuple is the current counter # - Second int is the maximum value for that counter # (9 knights/bishops/rooks/queens on a player's side is quite an extreme case, @@ -59,14 +62,14 @@ def set_daily_challenge_teams_and_pieces_roles( } # fmt: on - piece_role_by_square: "PieceRoleBySquare" = {} + piece_role_by_square: PieceRoleBySquare = {} - piece_faction: dict["PlayerSide", "Faction"] = { + piece_faction: dict[PlayerSide, Faction] = { "w": default_faction_w, "b": default_faction_b, } - teams: "GameTeams" = {"w": [], "b": []} + teams: TeamsDict = {"w": [], "b": []} for chess_square, chess_piece in chess_board.piece_map().items(): piece_player_side = chess_lib_color_to_player_side(chess_piece.color) @@ -74,17 +77,16 @@ def set_daily_challenge_teams_and_pieces_roles( team_member_role_counter, piece_role_max_value = team_members_counters[ piece_player_side ][piece_type] - piece_role = cast( - "PieceRole", + team_member_role = cast( + "TeamMemberRole", ( f"{piece_type}{team_member_role_counter}" if team_member_role_counter > 0 else piece_type ), ) - team_member_role = cast( - "TeamMemberRole", - piece_role.upper() if piece_player_side == "w" else piece_role, + piece_role = piece_role_from_team_member_role_and_player_side( + team_member_role, piece_player_side ) if team_member_role_counter > piece_role_max_value: @@ -94,29 +96,35 @@ def set_daily_challenge_teams_and_pieces_roles( ) square = chess_lib_square_to_square(chess_square) - piece_role_by_square[square] = team_member_role + piece_role_by_square[square] = piece_role - team_member: "TeamMember" = { - "role": team_member_role, - "name": "", # will be filled below by `_set_character_names_for_non_bot_side` - "faction": piece_faction[piece_player_side], - } + team_member = TeamMember( + role=team_member_role, + name=tuple(), # will be filled below by `_set_character_names_for_non_bot_side` + faction=piece_faction[piece_player_side], + ) teams[piece_player_side].append(team_member) team_members_counters[piece_player_side][piece_type][0] += 1 # Give a name to the player's team members - _set_character_names_for_non_bot_side(teams, bot_side=bot_side) + player_side = player_side_other(bot_side) + _set_character_names_for_team(teams, player_side) + + return ( + GameTeams(w=tuple(teams["w"]), b=tuple(teams["b"])), + piece_role_by_square, + ) - return teams, piece_role_by_square +def _set_character_names_for_team(teams: TeamsDict, side: PlayerSide) -> None: + anonymous_team_members = teams[side] + first_names = random.sample(FIRST_NAMES, k=len(anonymous_team_members)) + last_names = random.sample(LAST_NAMES, k=len(anonymous_team_members)) -def _set_character_names_for_non_bot_side( - teams: "GameTeams", bot_side: "PlayerSide" -) -> None: - player_side: "PlayerSide" = player_side_other(bot_side) - player_team_members = teams[player_side] - first_names = random.sample(FIRST_NAMES, k=len(player_team_members)) - last_names = random.sample(LAST_NAMES, k=len(player_team_members)) - for team_member in player_team_members: - team_member["name"] = [first_names.pop(), last_names.pop()] + named_team_members: list[TeamMember] = [] + for team_member in anonymous_team_members: + named_team_members.append( + team_member._replace(name=(first_names.pop(), last_names.pop())) + ) + teams[side] = named_team_members diff --git a/src/apps/daily_challenge/business_logic/_undo_last_move.py b/src/apps/daily_challenge/business_logic/_undo_last_move.py index 7e90e2f..f908d13 100644 --- a/src/apps/daily_challenge/business_logic/_undo_last_move.py +++ b/src/apps/daily_challenge/business_logic/_undo_last_move.py @@ -1,8 +1,10 @@ +from __future__ import annotations + import logging import textwrap from typing import TYPE_CHECKING -from apps.chess.helpers import uci_move_squares +from apps.chess.chess_helpers import uci_move_squares from ..models import DailyChallengeStats from ._move_daily_challenge_piece import move_daily_challenge_piece @@ -20,10 +22,10 @@ def undo_last_move( *, - challenge: "DailyChallenge", - game_state: "PlayerGameState", + challenge: DailyChallenge, + game_state: PlayerGameState, is_staff_user: bool = False, -) -> "PlayerGameState": +) -> PlayerGameState: # A published challenge always has a `piece_role_by_square`: assert challenge.piece_role_by_square diff --git a/src/lib/chess_engines/__init__.py b/src/apps/daily_challenge/components/companion_bars/__init__.py similarity index 100% rename from src/lib/chess_engines/__init__.py rename to src/apps/daily_challenge/components/companion_bars/__init__.py diff --git a/src/apps/daily_challenge/components/misc_ui/status_bar.py b/src/apps/daily_challenge/components/companion_bars/bottom_companion_bar.py similarity index 79% rename from src/apps/daily_challenge/components/misc_ui/status_bar.py rename to src/apps/daily_challenge/components/companion_bars/bottom_companion_bar.py index 8871962..8b209ef 100644 --- a/src/apps/daily_challenge/components/misc_ui/status_bar.py +++ b/src/apps/daily_challenge/components/companion_bars/bottom_companion_bar.py @@ -1,22 +1,26 @@ +from __future__ import annotations + from typing import TYPE_CHECKING -from dominate.tags import b, button, div, p +from dominate.tags import b, div, p from dominate.util import raw -from apps.chess.helpers import ( +from apps.chess.chess_helpers import ( piece_name_from_piece_role, player_side_from_piece_role, type_from_piece_role, ) from apps.daily_challenge.components.misc_ui.help import ( - character_type_tip, chess_status_bar_tip, - chess_unit_symbol_display, help_content, unit_display_container, ) - -from .common_styles import BUTTON_CLASSES +from apps.webui.components.atoms.buttons import zc_button +from apps.webui.components.chess_units import ( + character_type_tip, + chess_unit_symbol_display, +) +from apps.webui.components.molecules.chess_arena_companion_bars import companion_bar if TYPE_CHECKING: from dominate.tags import dom_tag @@ -25,11 +29,12 @@ def status_bar( - *, game_presenter: "DailyChallengeGamePresenter", board_id: str, **extra_attrs: str -) -> "dom_tag": - from apps.chess.components.chess_board import INFO_BARS_COMMON_CLASSES - - # TODO: split this function into smaller ones + *, + game_presenter: DailyChallengeGamePresenter, + board_id: str, + htmx_attrs: dict[str, str] | None = None, +) -> dom_tag: + # TODO: split this function into smaller ones? inner_content: dom_tag = div("status to implement") @@ -42,13 +47,15 @@ def status_bar( inner_content = div( help_content( challenge_solution_turns_count=game_presenter.challenge_solution_turns_count, - factions_tuple=tuple(game_presenter.factions.items()), + factions=game_presenter.factions, ), div( - button( + zc_button( "⇧ Scroll up to the board", - cls=BUTTON_CLASSES, - onclick="""window.scrollTo({ top: 0, behavior: "smooth" })""", + button_type="action", + extra_attrs={ + "onclick": """"window.scrollTo({ top: 0, behavior: "smooth" })""" + }, ), cls="w-full flex justify-center", ), @@ -88,17 +95,17 @@ def status_bar( case "waiting_for_bot_turn": inner_content = _chess_status_bar_waiting_for_bot_turn(game_presenter) - return div( + return companion_bar( inner_content, - id=f"chess-board-status-bar-{board_id}", - cls=f"min-h-[4rem] flex items-center {INFO_BARS_COMMON_CLASSES} border-t-0 rounded-b-md", - **extra_attrs, + id_=f"chess-board-status-bar-{board_id}", + position="bottom", + htmx_attrs=htmx_attrs, ) def _chess_status_bar_selected_piece( - game_presenter: "DailyChallengeGamePresenter", -) -> "dom_tag": + game_presenter: DailyChallengeGamePresenter, +) -> dom_tag: assert game_presenter.selected_piece is not None selected_piece = game_presenter.selected_piece @@ -110,12 +117,7 @@ def _chess_status_bar_selected_piece( unit_display = unit_display_container( piece_role=piece_role, factions=game_presenter.factions ) - team_member_name = team_member.get("name", "") - name_display = ( - " ".join(team_member_name) - if isinstance(team_member_name, list) - else team_member_name - ) + name_display = " ".join(team_member.name) unit_about = div( div("> ", b(name_display, cls="text-yellow-400"), " <") if name_display else "", @@ -145,6 +147,6 @@ def _chess_status_bar_selected_piece( def _chess_status_bar_waiting_for_bot_turn( - game_presenter: "DailyChallengeGamePresenter", -) -> "dom_tag": + game_presenter: DailyChallengeGamePresenter, +) -> dom_tag: return div("Waiting for opponent's turn 🛡", cls="w-full text-center items-center") diff --git a/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py b/src/apps/daily_challenge/components/companion_bars/top_companion_bar.py similarity index 63% rename from src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py rename to src/apps/daily_challenge/components/companion_bars/top_companion_bar.py index cc24730..8528270 100644 --- a/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py +++ b/src/apps/daily_challenge/components/companion_bars/top_companion_bar.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import functools import math from typing import TYPE_CHECKING @@ -5,52 +7,50 @@ from django.contrib.humanize.templatetags.humanize import ordinal from django.urls import reverse -from dominate.tags import b, button, div, p +from dominate.tags import b, div, p from dominate.util import raw -from apps.chess.components.svg_icons import ICON_SVG_CANCEL, ICON_SVG_CONFIRM - -from ...models import PlayerGameOverState -from .common_styles import BUTTON_CANCEL_CLASSES, BUTTON_CLASSES, BUTTON_CONFIRM_CLASSES -from .svg_icons import ( - ICON_SVG_COG, +from apps.daily_challenge.components.misc_ui.svg_icons import ( ICON_SVG_LIGHT_BULB, ICON_SVG_RESTART, ICON_SVG_UNDO, ) +from apps.daily_challenge.models import PlayerGameOverState +from apps.webui.components.atoms.buttons import zc_button +from apps.webui.components.misc_ui.svg_icons import ICON_SVG_COG +from apps.webui.components.molecules.chess_arena_companion_bars import ( + companion_bar, + confirmation_dialog_bar, +) if TYPE_CHECKING: + from collections.abc import Sequence + from dominate.tags import dom_tag - from ...presenters import DailyChallengeGamePresenter + from apps.daily_challenge.presenters import DailyChallengeGamePresenter def daily_challenge_bar( *, - game_presenter: "DailyChallengeGamePresenter | None", + game_presenter: DailyChallengeGamePresenter, board_id: str, - inner_content: "dom_tag | None" = None, - **extra_attrs: str, -) -> "dom_tag": - from apps.chess.components.chess_board import INFO_BARS_COMMON_CLASSES - - if not inner_content: - assert game_presenter is not None - inner_content = _current_state_display( - game_presenter=game_presenter, board_id=board_id - ) + htmx_attrs: dict[str, str] | None = None, +) -> dom_tag: + inner_content = _current_state_display( + game_presenter=game_presenter, board_id=board_id + ) - return div( + return companion_bar( inner_content, - id=f"chess-board-daily-challenge-bar-{board_id}", - cls=f"min-h-[4rem] flex items-center justify-center {INFO_BARS_COMMON_CLASSES} " - "border-t-0 xl:border-2 xl:rounded-t-md", - **extra_attrs, + id_=f"chess-board-daily-challenge-bar-{board_id}", + position="top", + htmx_attrs=htmx_attrs, ) -def retry_confirmation_display(*, board_id: str) -> "dom_tag": - htmx_attributes_confirm = { +def retry_confirmation_dialog_bar(*, board_id: str) -> dom_tag: + htmx_attrs_confirm = { "data_hx_post": "".join( ( reverse("daily_challenge:htmx_restart_daily_challenge_do"), @@ -61,7 +61,7 @@ def retry_confirmation_display(*, board_id: str) -> "dom_tag": "data_hx_target": f"#chess-board-pieces-{board_id}", "data_hx_swap": "outerHTML", } - htmx_attributes_cancel = { + htmx_attrs_cancel = { "data_hx_get": "".join( ( reverse("daily_challenge:htmx_game_no_selection"), @@ -73,15 +73,16 @@ def retry_confirmation_display(*, board_id: str) -> "dom_tag": "data_hx_swap": "outerHTML", } - return _confirmation_dialog( + return confirmation_dialog_bar( question=div("Retry today's challenge from the start?", cls="text-center"), - htmx_attributes_confirm=htmx_attributes_confirm, - htmx_attributes_cancel=htmx_attributes_cancel, + htmx_attrs_confirm=htmx_attrs_confirm, + htmx_attrs_cancel=htmx_attrs_cancel, + id_=f"chess-board-daily-challenge-bar-{board_id}", ) -def undo_confirmation_display(*, board_id: str) -> "dom_tag": - htmx_attributes_confirm = { +def undo_confirmation_dialog_bar(*, board_id: str) -> dom_tag: + htmx_attrs_confirm = { "data_hx_post": "".join( ( reverse("daily_challenge:htmx_undo_last_move_do"), @@ -92,7 +93,7 @@ def undo_confirmation_display(*, board_id: str) -> "dom_tag": "data_hx_target": f"#chess-board-pieces-{board_id}", "data_hx_swap": "outerHTML", } - htmx_attributes_cancel = { + htmx_attrs_cancel = { "data_hx_get": "".join( ( reverse("daily_challenge:htmx_game_no_selection"), @@ -104,19 +105,20 @@ def undo_confirmation_display(*, board_id: str) -> "dom_tag": "data_hx_swap": "outerHTML", } - return _confirmation_dialog( + return confirmation_dialog_bar( question=div( p("Undo your last move?"), b("⚠️ You will not be able to undo a move for today's challenge again."), cls="text-center", ), - htmx_attributes_confirm=htmx_attributes_confirm, - htmx_attributes_cancel=htmx_attributes_cancel, + htmx_attrs_confirm=htmx_attrs_confirm, + htmx_attrs_cancel=htmx_attrs_cancel, + id_=f"chess-board-daily-challenge-bar-{board_id}", ) -def see_solution_confirmation_display(*, board_id: str) -> "dom_tag": - htmx_attributes_confirm = { +def see_solution_confirmation_dialog_bar(*, board_id: str) -> dom_tag: + htmx_attrs_confirm = { "data_hx_post": "".join( ( reverse("daily_challenge:htmx_see_daily_challenge_solution_do"), @@ -127,7 +129,7 @@ def see_solution_confirmation_display(*, board_id: str) -> "dom_tag": "data_hx_target": f"#chess-board-pieces-{board_id}", "data_hx_swap": "outerHTML", } - htmx_attributes_cancel = { + htmx_attrs_cancel = { "data_hx_get": "".join( ( reverse("daily_challenge:htmx_game_no_selection"), @@ -139,48 +141,21 @@ def see_solution_confirmation_display(*, board_id: str) -> "dom_tag": "data_hx_swap": "outerHTML", } - return _confirmation_dialog( + return confirmation_dialog_bar( question=div( p("Give up for today, and see a solution?"), b("⚠️ You will not be able to try today's challenge again."), cls="text-center", ), - htmx_attributes_confirm=htmx_attributes_confirm, - htmx_attributes_cancel=htmx_attributes_cancel, - ) - - -def _confirmation_dialog( - *, - question: "dom_tag", - htmx_attributes_confirm: dict[str, str], - htmx_attributes_cancel: dict[str, str], -) -> "dom_tag": - return div( - question, - div( - button( - "Confirm", - " ", - ICON_SVG_CONFIRM, - cls=BUTTON_CONFIRM_CLASSES, - **htmx_attributes_confirm, - ), - button( - "Cancel", - " ", - ICON_SVG_CANCEL, - cls=BUTTON_CANCEL_CLASSES, - **htmx_attributes_cancel, - ), - cls="text-center", - ), + htmx_attrs_confirm=htmx_attrs_confirm, + htmx_attrs_cancel=htmx_attrs_cancel, + id_=f"chess-board-daily-challenge-bar-{board_id}", ) def _current_state_display( - *, game_presenter: "DailyChallengeGamePresenter", board_id: str -) -> "dom_tag": + *, game_presenter: DailyChallengeGamePresenter, board_id: str +) -> dom_tag: if game_presenter.solution_index is not None: return _see_solution_mode_display( game_presenter=game_presenter, board_id=board_id @@ -216,8 +191,8 @@ def _current_state_display( def _undo_button( - *, game_presenter: "DailyChallengeGamePresenter", board_id: str -) -> "dom_tag": + *, game_presenter: DailyChallengeGamePresenter, board_id: str +) -> dom_tag: game_state = game_presenter.game_state can_undo: bool = game_presenter.is_preview or ( game_state.current_attempt_turns_counter > 0 @@ -225,11 +200,11 @@ def _undo_button( and game_state.game_over != PlayerGameOverState.WON ) - htmx_attributes = ( + htmx_attrs = ( { - "data_hx_post": "".join( + "data_hx_get": "".join( ( - reverse("daily_challenge:htmx_undo_last_move_ask_confirmation"), + reverse("daily_challenge:htmx_undo_last_move_confirmation_dialog"), "?", urlencode({"board_id": board_id}), ) @@ -241,32 +216,32 @@ def _undo_button( else {} ) - additional_attributes = {"disabled": True} if not can_undo else {} + additional_attrs = {"disabled": True} if not can_undo else {} classes = _button_classes(disabled=not can_undo) - return button( + return zc_button( "Undo", - " ", - ICON_SVG_UNDO, - cls=classes, + svg_icon=ICON_SVG_UNDO, + button_type="action", title="Undo your last move", - id=f"chess-board-undo-daily-challenge-{board_id}", - **additional_attributes, - **htmx_attributes, + id_=f"chess-board-undo-daily-challenge-{board_id}", + htmx_attrs=htmx_attrs, + extra_classes=classes, + extra_attrs=additional_attrs, ) def _retry_button( - *, game_presenter: "DailyChallengeGamePresenter", board_id: str -) -> "dom_tag": + *, game_presenter: DailyChallengeGamePresenter, board_id: str +) -> dom_tag: can_retry: bool = game_presenter.game_state.current_attempt_turns_counter > 0 - htmx_attributes = ( + htmx_attrs = ( { - "data_hx_post": "".join( + "data_hx_get": "".join( ( reverse( - "daily_challenge:htmx_restart_daily_challenge_ask_confirmation" + "daily_challenge:htmx_restart_daily_challenge_confirmation_dialog" ), "?", urlencode({"board_id": board_id}), @@ -279,29 +254,30 @@ def _retry_button( else {} ) - additional_attributes = {"disabled": True} if not can_retry else {} + additional_attrs = {"disabled": True} if not can_retry else {} classes = _button_classes(disabled=not can_retry) - return button( + return zc_button( "Retry", - " ", - ICON_SVG_RESTART, - cls=classes, + svg_icon=ICON_SVG_RESTART, + button_type="action", + extra_classes=classes, title="Try this daily challenge again, from the beginning", - id=f"chess-board-restart-daily-challenge-{board_id}", - **additional_attributes, - **htmx_attributes, + id_=f"chess-board-restart-daily-challenge-{board_id}", + extra_attrs=additional_attrs, + htmx_attrs=htmx_attrs, ) def _see_solution_button( board_id: str, *, full_width: bool, see_it_again: bool = False -) -> "dom_tag": +) -> dom_tag: target_route = ( "daily_challenge:htmx_see_daily_challenge_solution_do" if see_it_again - else "daily_challenge:htmx_see_daily_challenge_solution_ask_confirmation" + else "daily_challenge:htmx_see_daily_challenge_solution_confirmation_dialog" ) + target_route_http_method = "post" if see_it_again else "get" target_selector = ( f"#chess-board-pieces-{board_id}" if see_it_again @@ -313,8 +289,8 @@ def _see_solution_button( else "Give up for today, and see a solution" ) - htmx_attributes = { - "data_hx_post": "".join( + htmx_attrs = { + f"data_hx_{target_route_http_method}": "".join( ( reverse(target_route), "?", @@ -327,51 +303,50 @@ def _see_solution_button( classes = _button_classes(full_width=full_width) - return button( + return zc_button( "See solution", - " ", - ICON_SVG_LIGHT_BULB, - cls=classes, + svg_icon=ICON_SVG_LIGHT_BULB, + button_type="action", + extra_classes=classes, title=title, - id=f"chess-board-restart-daily-challenge-{board_id}", - **htmx_attributes, + id_=f"chess-board-restart-daily-challenge-{board_id}", + htmx_attrs=htmx_attrs, ) -def _user_prefs_button(board_id: str) -> "dom_tag": - htmx_attributes = { - "data_hx_get": reverse("daily_challenge:htmx_daily_challenge_modal_user_prefs"), +def _user_prefs_button(board_id: str) -> dom_tag: + htmx_attrs = { + "data_hx_get": reverse("webui:htmx_modal_user_prefs"), "data_hx_target": "#modals-container", "data_hx_swap": "outerHTML", } classes = _button_classes() - return button( + return zc_button( "Preferences", - " ", - ICON_SVG_COG, - cls=classes, + svg_icon=ICON_SVG_COG, + button_type="action", + extra_classes=classes, title="Edit preferences", - id=f"chess-board-preferences-daily-challenge-{board_id}", - **htmx_attributes, + id_=f"chess-board-preferences-daily-challenge-{board_id}", + htmx_attrs=htmx_attrs, ) @functools.cache -def _button_classes(*, full_width: bool = True, disabled: bool = False) -> str: - return " ".join( - ( - BUTTON_CLASSES, - ("w-full" if full_width else ""), - (" opacity-50 cursor-not-allowed" if disabled else ""), - ) +def _button_classes( + *, full_width: bool = True, disabled: bool = False +) -> Sequence[str]: + return ( + ("w-full" if full_width else ""), + (" opacity-50 cursor-not-allowed" if disabled else ""), ) def _see_solution_mode_display( - *, game_presenter: "DailyChallengeGamePresenter", board_id: str -) -> "dom_tag": + *, game_presenter: DailyChallengeGamePresenter, board_id: str +) -> dom_tag: assert game_presenter.game_state.solution_index is not None is_game_over = game_presenter.is_game_over diff --git a/src/apps/daily_challenge/components/misc_ui/common_styles.py b/src/apps/daily_challenge/components/misc_ui/common_styles.py deleted file mode 100644 index b9fb2f9..0000000 --- a/src/apps/daily_challenge/components/misc_ui/common_styles.py +++ /dev/null @@ -1,8 +0,0 @@ -BUTTON_BASE_BG_COLOR, BUTTON_BASE_TEXT_COLOR = "bg-rose-600", "text-slate-200" -BUTTON_BASE_HOVER_TEXT_COLOR = "hover:text-stone-100" -BUTTON_CLASSES = ( - "inline-block py-1 px-3 rounded-md font-bold whitespace-nowrap " - f"{BUTTON_BASE_TEXT_COLOR} {BUTTON_BASE_BG_COLOR} {BUTTON_BASE_HOVER_TEXT_COLOR}" -) -BUTTON_CONFIRM_CLASSES = BUTTON_CLASSES.replace(BUTTON_BASE_BG_COLOR, "bg-lime-700") -BUTTON_CANCEL_CLASSES = BUTTON_CLASSES.replace(BUTTON_BASE_BG_COLOR, "bg-indigo-500") diff --git a/src/apps/daily_challenge/components/misc_ui/help.py b/src/apps/daily_challenge/components/misc_ui/help.py index 119b488..b792954 100644 --- a/src/apps/daily_challenge/components/misc_ui/help.py +++ b/src/apps/daily_challenge/components/misc_ui/help.py @@ -1,20 +1,21 @@ -import random +from __future__ import annotations + from functools import cache -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from django.conf import settings from dominate.tags import div, h4, p, span from dominate.util import raw -from apps.chess.components.chess_board import SQUARE_COLOR_TAILWIND_CLASSES -from apps.chess.components.chess_helpers import chess_unit_symbol_class -from apps.chess.consts import PIECE_TYPE_TO_NAME -from apps.daily_challenge.components.misc_ui.common_styles import ( - BUTTON_BASE_HOVER_TEXT_COLOR, - BUTTON_CLASSES, +from apps.webui.components.atoms.buttons import zc_button +from apps.webui.components.chess_units import ( + CHARACTER_TYPE_TIP, + chess_status_bar_tip, + unit_display_container, ) -from apps.daily_challenge.components.misc_ui.svg_icons import ( - ICON_SVG_COG, +from apps.webui.components.misc_ui.svg_icons import ICON_SVG_COG + +from .svg_icons import ( ICON_SVG_LIGHT_BULB, ICON_SVG_RESTART, ) @@ -22,48 +23,16 @@ if TYPE_CHECKING: from dominate.tags import dom_tag - from apps.chess.types import ( - Faction, - Factions, - PieceName, - PieceRole, - PieceType, - PlayerSide, - TeamMemberRole, - ) - - -_CHARACTER_TYPE_TIP: dict["PieceType", str] = { - "p": "Characters with swords", - "n": "Mounted characters", - "b": "Characters with a bow", - "r": "Flying characters", - "q": "Characters with a staff", - "k": "Characters wearing heavy armors", -} -_CHARACTER_TYPE_TIP_KEYS = tuple(_CHARACTER_TYPE_TIP.keys()) - -_CHARACTER_TYPE_ROLE_MAPPING: dict["PieceType", "TeamMemberRole"] = { - "p": "p1", - "n": "n1", - "b": "b1", - "r": "r1", - "q": "q", - "k": "k", -} + from apps.chess.models import GameFactions @cache def help_content( *, challenge_solution_turns_count: int, - factions_tuple: "tuple[tuple[PlayerSide, Faction], ...]", -) -> "dom_tag": - # N.B. We use a tuple here for the factions, so they're hashable - # and can be used as cached key - + factions: GameFactions, +) -> dom_tag: spacing = "mb-3" - factions = dict(factions_tuple) return raw( div( @@ -93,10 +62,11 @@ def help_content( div( raw("You can restart from the beginning at any time, "), "by clicking the ", - span( + zc_button( "Retry", - ICON_SVG_RESTART, - cls=f"{BUTTON_CLASSES.replace(BUTTON_BASE_HOVER_TEXT_COLOR, '')} !mx-0", + svg_icon=ICON_SVG_RESTART, + button_type="action", + is_a_help_for_actual_button=True, ), " button.", cls=f"{spacing}", @@ -104,10 +74,11 @@ def help_content( div( "If you can't solve today's challenge ", raw("you can decide to see a solution, by clicking the "), - span( + zc_button( "See solution", - ICON_SVG_LIGHT_BULB, - cls=f"{BUTTON_CLASSES} !inline-block !mx-0", + svg_icon=ICON_SVG_LIGHT_BULB, + button_type="action", + is_a_help_for_actual_button=True, ), " button.", cls=f"{spacing}", @@ -115,10 +86,11 @@ def help_content( div( raw("You can customise some game settings"), " - such as the speed of the game or the appearance of the board - via the ", - span( + zc_button( "Options", - ICON_SVG_COG, - cls=f"{BUTTON_CLASSES} !inline-block !mx-0", + svg_icon=ICON_SVG_COG, + button_type="action", + is_a_help_for_actual_button=True, ), " button.", cls=f"{spacing}", @@ -138,89 +110,10 @@ def help_content( additional_classes="h-20", row_counter=i, ) - for i, piece_type in enumerate(_CHARACTER_TYPE_TIP_KEYS) + for i, piece_type in enumerate(CHARACTER_TYPE_TIP.keys()) ), cls="mt-2", ), cls="w-full text-center", ).render(pretty=settings.DEBUG) ) - - -def chess_status_bar_tip( - *, - factions: "Factions", - piece_type: "PieceType | None" = None, - additional_classes: str = "", - row_counter: int | None = None, -) -> "dom_tag": - if piece_type is None: - piece_type = random.choice(_CHARACTER_TYPE_TIP_KEYS) - piece_name = PIECE_TYPE_TO_NAME[piece_type] - unit_left_side_role = cast( - "PieceRole", _CHARACTER_TYPE_ROLE_MAPPING[piece_type].upper() - ) - unit_right_side_role = _CHARACTER_TYPE_ROLE_MAPPING[piece_type] - unit_display_left = unit_display_container( - piece_role=unit_left_side_role, factions=factions, row_counter=row_counter - ) - unit_display_right = unit_display_container( - piece_role=unit_right_side_role, factions=factions, row_counter=row_counter - ) - - return div( - unit_display_left, - div( - character_type_tip(piece_type), - chess_unit_symbol_display(player_side="w", piece_name=piece_name), - cls="text-center", - ), - unit_display_right, - cls=f"flex w-full justify-between items-center {additional_classes}", - ) - - -def unit_display_container( - *, piece_role: "PieceRole", factions: "Factions", row_counter: int | None = None -) -> "dom_tag": - from apps.chess.components.chess_board import chess_unit_display_with_ground_marker - - unit_display = chess_unit_display_with_ground_marker( - piece_role=piece_role, - factions=factions, - ) - - additional_classes = ( - f"{SQUARE_COLOR_TAILWIND_CLASSES[row_counter%2]} rounded-lg" - if row_counter is not None - else "" - ) - - return div( - unit_display, - cls=f"h-16 aspect-square {additional_classes}", - ) - - -def character_type_tip(piece_type: "PieceType") -> "dom_tag": - return raw( - f"{_CHARACTER_TYPE_TIP[piece_type]} are chess {PIECE_TYPE_TO_NAME[piece_type]}s" - ) - - -def chess_unit_symbol_display( - *, player_side: "PlayerSide", piece_name: "PieceName" -) -> "dom_tag": - classes = ( - "inline-block", - "w-5", - "align-text-bottom", - "aspect-square", - "bg-no-repeat", - "bg-cover", - chess_unit_symbol_class(player_side=player_side, piece_name=piece_name), - ) - - return span( - cls=" ".join(classes), - ) diff --git a/src/apps/daily_challenge/components/misc_ui/help_modal.py b/src/apps/daily_challenge/components/misc_ui/help_modal.py index 7d062da..a962ebc 100644 --- a/src/apps/daily_challenge/components/misc_ui/help_modal.py +++ b/src/apps/daily_challenge/components/misc_ui/help_modal.py @@ -1,11 +1,12 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from dominate.tags import div, h3 from apps.chess.components.misc_ui import modal_container from apps.daily_challenge.components.misc_ui.help import help_content - -from .svg_icons import ICON_SVG_HELP +from apps.webui.components.misc_ui.svg_icons import ICON_SVG_HELP if TYPE_CHECKING: from dominate.tags import dom_tag @@ -15,7 +16,7 @@ # TODO: manage i18n -def help_modal(*, game_presenter: "DailyChallengeGamePresenter") -> "dom_tag": +def help_modal(*, game_presenter: DailyChallengeGamePresenter) -> dom_tag: return modal_container( header=h3( "How to play ", @@ -25,7 +26,7 @@ def help_modal(*, game_presenter: "DailyChallengeGamePresenter") -> "dom_tag": body=div( help_content( challenge_solution_turns_count=game_presenter.challenge_solution_turns_count, - factions_tuple=tuple(game_presenter.factions.items()), + factions=game_presenter.factions, ), cls="p-6 space-y-6", ), diff --git a/src/apps/daily_challenge/components/misc_ui/stats_modal.py b/src/apps/daily_challenge/components/misc_ui/stats_modal.py index 29cfd79..af71ab6 100644 --- a/src/apps/daily_challenge/components/misc_ui/stats_modal.py +++ b/src/apps/daily_challenge/components/misc_ui/stats_modal.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from math import ceil from typing import TYPE_CHECKING @@ -24,8 +26,8 @@ def stats_modal( - *, stats: "PlayerStats", game_state: "PlayerGameState", challenge: "DailyChallenge" -) -> "dom_tag": + *, stats: PlayerStats, game_state: PlayerGameState, challenge: DailyChallenge +) -> dom_tag: return modal_container( header=h3( "Statistics ", @@ -41,8 +43,8 @@ def stats_modal( ) -def _main_stats(stats: "PlayerStats") -> "dom_tag": - def stat(name: str, value: int) -> "dom_tag": +def _main_stats(stats: PlayerStats) -> dom_tag: + def stat(name: str, value: int) -> dom_tag: return div( div(str(value), cls="font-bold text-lg text-center"), div(name, cls="text-sm text-center"), @@ -58,8 +60,8 @@ def stat(name: str, value: int) -> "dom_tag": def _today_s_results( - *, stats: "PlayerStats", game_state: "PlayerGameState", challenge: "DailyChallenge" -) -> "dom_tag": + *, stats: PlayerStats, game_state: PlayerGameState, challenge: DailyChallenge +) -> dom_tag: if not has_player_won_today(stats): return div() # empty
@@ -98,18 +100,18 @@ def _today_s_results( ) -def _wins_distribution(stats: "PlayerStats") -> "dom_tag": +def _wins_distribution(stats: PlayerStats) -> dom_tag: max_value: int = max(stats.wins_distribution.values()) if max_value == 0: - content: "dom_tag" = div( + content: dom_tag = div( "No victories yet", cls="text-center", ) else: min_width_percentage = 8 - def row(distribution_slice: "WinsDistributionSlice", count: int) -> "dom_tag": + def row(distribution_slice: WinsDistributionSlice, count: int) -> dom_tag: slice_label = ( f"{ordinal(distribution_slice)} attempt" if distribution_slice < stats.WINS_DISTRIBUTION_SLICE_COUNT diff --git a/src/apps/daily_challenge/components/misc_ui/svg_icons.py b/src/apps/daily_challenge/components/misc_ui/svg_icons.py index 5979cd0..e6ecd13 100644 --- a/src/apps/daily_challenge/components/misc_ui/svg_icons.py +++ b/src/apps/daily_challenge/components/misc_ui/svg_icons.py @@ -6,42 +6,35 @@ """ ) + # https://heroicons.com/, icon `chart-bar-square` ICON_SVG_STATS = raw( r""" """ ) -# https://heroicons.com/, icon `question-mark-circle` -ICON_SVG_HELP = raw( - r""" - - """ -) -# https://heroicons.com/, icon `cog-8-tooth`, "solid" version -ICON_SVG_COG = raw( - r""" - - """ -) + # https://heroicons.com/, icon `light-bulb` ICON_SVG_LIGHT_BULB = raw( r""" """ ) + # https://heroicons.com/, icon `play` ICON_SVG_PLAY = raw( r""" """ ) + # https://heroicons.com/, icon `forward` ICON_SVG_FORWARD = raw( r""" """ ) + # https://heroicons.com/, icon `backward` ICON_SVG_UNDO = raw( r""" diff --git a/src/apps/daily_challenge/components/pages/daily_chess.py b/src/apps/daily_challenge/components/pages/daily_chess_pages.py similarity index 52% rename from src/apps/daily_challenge/components/pages/daily_chess.py rename to src/apps/daily_challenge/components/pages/daily_chess_pages.py index 1714f30..0813a47 100644 --- a/src/apps/daily_challenge/components/pages/daily_chess.py +++ b/src/apps/daily_challenge/components/pages/daily_chess_pages.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import functools from string import Template from typing import TYPE_CHECKING @@ -5,7 +7,7 @@ from django.conf import settings from django.templatetags.static import static from django.urls import reverse -from dominate.tags import button, div, meta, script +from dominate.tags import div, meta, script from dominate.util import raw from apps.chess.components.chess_board import ( @@ -18,11 +20,18 @@ reset_chess_engine_worker, speech_bubble_container, ) +from apps.daily_challenge.components.companion_bars.bottom_companion_bar import ( + status_bar, +) +from apps.daily_challenge.components.companion_bars.top_companion_bar import ( + daily_challenge_bar, +) +from apps.webui.components.atoms.buttons import zc_header_icon_button from apps.webui.components.layout import page +from apps.webui.components.misc_ui.svg_icons import ICON_SVG_HELP +from apps.webui.components.misc_ui.user_prefs_modal import user_prefs_button -from ..misc_ui.daily_challenge_bar import daily_challenge_bar -from ..misc_ui.status_bar import status_bar -from ..misc_ui.svg_icons import ICON_SVG_COG, ICON_SVG_HELP, ICON_SVG_STATS +from ..misc_ui.svg_icons import ICON_SVG_STATS if TYPE_CHECKING: from typing import Literal @@ -37,95 +46,95 @@ def daily_challenge_page( *, - game_presenter: "DailyChallengeGamePresenter", - request: "HttpRequest", + game_presenter: DailyChallengeGamePresenter, + request: HttpRequest, board_id: str, ) -> str: return page( chess_arena( game_presenter=game_presenter, board_id=board_id, - status_bars=[ - daily_challenge_bar(game_presenter=game_presenter, board_id=board_id), - status_bar( + companion_bars={ + "top": daily_challenge_bar( + game_presenter=game_presenter, board_id=board_id + ), + "bottom": status_bar( game_presenter=game_presenter, board_id=board_id, ), - ], + }, ), _open_help_modal() if game_presenter.is_very_first_game else div(""), request=request, left_side_buttons=[_stats_button()], - right_side_buttons=[_user_prefs_button(), _help_button()], + right_side_buttons=[user_prefs_button(), _help_button()], head_children=_open_graph_meta_tags(), ) def daily_challenge_moving_parts_fragment( *, - game_presenter: "DailyChallengeGamePresenter", - request: "HttpRequest", + game_presenter: DailyChallengeGamePresenter, + request: HttpRequest, board_id: str, ) -> str: return "\n".join( - ( - dom_tag.render(pretty=settings.DEBUG) - for dom_tag in ( - chess_pieces( - game_presenter=game_presenter, - board_id=board_id, - ), - chess_available_targets( + dom_tag.render(pretty=settings.DEBUG) + for dom_tag in ( + chess_pieces( + game_presenter=game_presenter, + board_id=board_id, + ), + chess_available_targets( + game_presenter=game_presenter, + board_id=board_id, + data_hx_swap_oob="outerHTML", + ), + ( + chess_last_move( game_presenter=game_presenter, board_id=board_id, data_hx_swap_oob="outerHTML", - ), - ( - chess_last_move( - game_presenter=game_presenter, - board_id=board_id, - data_hx_swap_oob="outerHTML", - ) - if game_presenter.refresh_last_move - else div("") - ), - daily_challenge_bar( + ) + if game_presenter.refresh_last_move + else div("") + ), + daily_challenge_bar( + game_presenter=game_presenter, + board_id=board_id, + htmx_attrs={"data_hx_swap_oob": "outerHTML"}, + ), + status_bar( + game_presenter=game_presenter, + board_id=board_id, + htmx_attrs={"data_hx_swap_oob": "outerHTML"}, + ), + div( + speech_bubble_container( game_presenter=game_presenter, board_id=board_id, - data_hx_swap_oob="outerHTML", - ), - status_bar( - game_presenter=game_presenter, - board_id=board_id, - data_hx_swap_oob="outerHTML", ), - div( - speech_bubble_container( - game_presenter=game_presenter, - board_id=board_id, - ), - id=f"chess-speech-container-{board_id}", - data_hx_swap_oob="innerHTML", - ), - *( - [reset_chess_engine_worker()] - if game_presenter.challenge_current_attempt_turns_counter == 0 - else [] - ), - *([_open_stats_modal()] if game_presenter.just_won else []), - ) + id=f"chess-speech-container-{board_id}", + data_hx_swap_oob="innerHTML", + ), + *( + [reset_chess_engine_worker()] + if game_presenter.challenge_current_attempt_turns_counter == 0 + else [] + ), + *([_open_stats_modal()] if game_presenter.just_won else []), ) ) -def _stats_button() -> "dom_tag": +def _stats_button() -> dom_tag: htmx_attributes = { "data_hx_get": reverse("daily_challenge:htmx_daily_challenge_modal_stats"), "data_hx_target": "#modals-container", "data_hx_swap": "outerHTML", } - return _header_button( + return zc_header_icon_button( icon=ICON_SVG_STATS, title="Visualise your stats for daily challenges", id_="stats-button", @@ -133,29 +142,14 @@ def _stats_button() -> "dom_tag": ) -def _user_prefs_button() -> "dom_tag": - htmx_attributes = { - "data_hx_get": reverse("daily_challenge:htmx_daily_challenge_modal_user_prefs"), - "data_hx_target": "#modals-container", - "data_hx_swap": "outerHTML", - } - - return _header_button( - icon=ICON_SVG_COG, - title="Edit preferences", - id_="user-prefs-button", - htmx_attributes=htmx_attributes, - ) - - -def _help_button() -> "dom_tag": +def _help_button() -> dom_tag: htmx_attributes = { "data_hx_get": reverse("daily_challenge:htmx_daily_challenge_modal_help"), "data_hx_target": "#modals-container", "data_hx_swap": "outerHTML", } - return _header_button( + return zc_header_icon_button( icon=ICON_SVG_HELP, title="How to play", id_="help-button", @@ -163,26 +157,14 @@ def _help_button() -> "dom_tag": ) -def _header_button( - *, icon: str, title: str, id_: str, htmx_attributes: dict[str, str] -) -> "dom_tag": - return button( - icon, - cls="block px-1 py-1 text-sm text-slate-50 hover:text-slate-400", - title=title, - id=id_, - **htmx_attributes, - ) - - @functools.cache -def _open_stats_modal() -> "dom_tag": +def _open_stats_modal() -> dom_tag: # We open the stats modal 2 seconds after the game is won. return _open_modal("stats", 2_000) @functools.cache -def _open_help_modal() -> "dom_tag": +def _open_help_modal() -> dom_tag: # We open the stats modal 4 seconds after the bot played their first move. return _open_modal("help", 4_000) @@ -196,7 +178,8 @@ def _open_help_modal() -> "dom_tag": ) -def _open_modal(modal_id: "Literal['stats', 'help']", delay: int) -> "dom_tag": +def _open_modal(modal_id: Literal["stats", "help"], delay: int) -> dom_tag: + # TODO: use a web component for this return div( script( raw(_MODAL_TEMPLATE.substitute(MODAL_ID=modal_id, DELAY=delay)), @@ -206,7 +189,7 @@ def _open_modal(modal_id: "Literal['stats', 'help']", delay: int) -> "dom_tag": ) -def _open_graph_meta_tags() -> "tuple[dom_tag, ...]": +def _open_graph_meta_tags() -> tuple[dom_tag, ...]: return ( meta( property="og:image", diff --git a/src/apps/daily_challenge/consts.py b/src/apps/daily_challenge/consts.py index f9bb877..20f9e78 100644 --- a/src/apps/daily_challenge/consts.py +++ b/src/apps/daily_challenge/consts.py @@ -1,8 +1,14 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Final +from apps.chess.models import GameFactions + if TYPE_CHECKING: - from apps.chess.types import Factions, PlayerSide + from apps.chess.types import PlayerSide -PLAYER_SIDE: "Final[PlayerSide]" = "w" -BOT_SIDE: "Final[PlayerSide]" = "b" -FACTIONS: "Final[Factions]" = {"w": "humans", "b": "undeads"} # hard-coded for now +PLAYER_SIDE: Final[PlayerSide] = "w" +BOT_SIDE: Final[PlayerSide] = "b" +FACTIONS: Final[GameFactions] = GameFactions( + w="humans", b="undeads" +) # hard-coded for now diff --git a/src/apps/daily_challenge/cookie_helpers.py b/src/apps/daily_challenge/cookie_helpers.py index e01a1d8..3f36d39 100644 --- a/src/apps/daily_challenge/cookie_helpers.py +++ b/src/apps/daily_challenge/cookie_helpers.py @@ -1,22 +1,21 @@ +from __future__ import annotations + import logging from typing import TYPE_CHECKING, NamedTuple from django.utils.timezone import now from msgspec import MsgspecError -from apps.chess.models import UserPrefs - from .models import PlayerGameState, PlayerSessionContent, PlayerStats if TYPE_CHECKING: - from django.http import HttpRequest, HttpResponse + from django.http import HttpRequest from .models import DailyChallenge _PLAYER_CONTENT_SESSION_KEY = "pc" -_USER_PREFS_COOKIE_NAME = "uprefs" -_USER_PREFS_COOKIE_MAX_AGE = 3600 * 24 * 30 * 6 # approximately 6 months + _logger = logging.getLogger(__name__) @@ -28,7 +27,7 @@ class DailyChallengeStateForPlayer(NamedTuple): def get_or_create_daily_challenge_state_for_player( - *, request: "HttpRequest", challenge: "DailyChallenge" + *, request: HttpRequest, challenge: DailyChallenge ) -> DailyChallengeStateForPlayer: """ Returns the game state for the given challenge, creating it if it doesn't exist yet. @@ -72,7 +71,7 @@ def get_or_create_daily_challenge_state_for_player( def get_player_session_content_from_request( - request: "HttpRequest", + request: HttpRequest, ) -> PlayerSessionContent: def new_content(): return PlayerSessionContent(games={}, stats=PlayerStats()) @@ -94,26 +93,8 @@ def new_content(): return new_content() -def get_user_prefs_from_request(request: "HttpRequest") -> UserPrefs: - def new_content(): - return UserPrefs() - - cookie_content: str | None = request.COOKIES.get(_USER_PREFS_COOKIE_NAME) - if cookie_content is None or len(cookie_content) < 5: - return new_content() - - try: - user_prefs = UserPrefs.from_cookie_content(cookie_content) - return user_prefs - except MsgspecError: - _logger.exception( - "Could not decode cookie content; restarting with a blank one." - ) - return new_content() - - def save_daily_challenge_state_in_session( - *, request: "HttpRequest", game_state: PlayerGameState, player_stats: PlayerStats + *, request: HttpRequest, game_state: PlayerGameState, player_stats: PlayerStats ) -> None: # Erases other games data! challenge_id = today_daily_challenge_id(request) @@ -123,17 +104,8 @@ def save_daily_challenge_state_in_session( _store_player_session_content(request, session_content) -def save_user_prefs(*, user_prefs: "UserPrefs", response: "HttpResponse") -> None: - response.set_cookie( - _USER_PREFS_COOKIE_NAME, - user_prefs.to_cookie_content(), - max_age=_USER_PREFS_COOKIE_MAX_AGE, - httponly=True, - ) - - def clear_daily_challenge_game_state_in_session( - *, request: "HttpRequest", player_stats: PlayerStats + *, request: HttpRequest, player_stats: PlayerStats ) -> None: # Erases current games data! session_content = PlayerSessionContent(games={}, stats=player_stats) @@ -141,7 +113,7 @@ def clear_daily_challenge_game_state_in_session( def clear_daily_challenge_stats_in_session( - *, request: "HttpRequest", game_state: PlayerGameState + *, request: HttpRequest, game_state: PlayerGameState ) -> None: # Erases all-time stats data! challenge_id = today_daily_challenge_id(request) @@ -151,7 +123,7 @@ def clear_daily_challenge_stats_in_session( _store_player_session_content(request, session_content) -def today_daily_challenge_id(request: "HttpRequest") -> str: +def today_daily_challenge_id(request: HttpRequest) -> str: if request.user.is_staff: admin_daily_challenge_lookup_key = request.get_signed_cookie( "admin_daily_challenge_lookup_key", default=None @@ -162,7 +134,7 @@ def today_daily_challenge_id(request: "HttpRequest") -> str: def _store_player_session_content( - request: "HttpRequest", session_content: PlayerSessionContent + request: HttpRequest, session_content: PlayerSessionContent ) -> None: cookie_content = session_content.to_cookie_content() request.session[_PLAYER_CONTENT_SESSION_KEY] = cookie_content diff --git a/src/apps/daily_challenge/management/commands/dailychallenge_create_from_lichess_puzzles_csv.py b/src/apps/daily_challenge/management/commands/dailychallenge_create_from_lichess_puzzles_csv.py index 4e9d15e..baf69e5 100644 --- a/src/apps/daily_challenge/management/commands/dailychallenge_create_from_lichess_puzzles_csv.py +++ b/src/apps/daily_challenge/management/commands/dailychallenge_create_from_lichess_puzzles_csv.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import csv import re from pathlib import Path @@ -134,7 +136,7 @@ def handle( def get_bot_first_move_and_resulting_fen( csv_row: dict, -) -> "BotFirstMoveAndResultingFen": +) -> BotFirstMoveAndResultingFen: fen_before_bot_first_move = csv_row["FEN"] bot_first_move_uci = csv_row["Moves"][0:4] diff --git a/src/apps/daily_challenge/migrations/0016_convert_games_team_members_to_tuples.py b/src/apps/daily_challenge/migrations/0016_convert_games_team_members_to_tuples.py new file mode 100644 index 0000000..f3e144a --- /dev/null +++ b/src/apps/daily_challenge/migrations/0016_convert_games_team_members_to_tuples.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.1 on 2024-09-28 11:00 +from typing import TYPE_CHECKING, cast + +from django.db import migrations + +from apps.chess.consts import PLAYER_SIDES + +if TYPE_CHECKING: + from apps.chess.types import GameTeamsDict + + +def _convert_existing_games_team_members_to_tuples(apps, schema_editor): + DailyChallenge = apps.get_model("daily_challenge", "DailyChallenge") + + for challenge in DailyChallenge.objects.filter(teams__isnull=False).iterator(): + teams = cast("GameTeamsDict", challenge.teams) + + if not isinstance(teams["w"][0], dict): + continue # this game already uses the new "tuples" format + + for side in PLAYER_SIDES: + # Convert the list of team-members-as-dicts to a list of tuples + teams[side] = [tuple(member.values()) for member in teams[side]] + + challenge.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("daily_challenge", "0015_dailychallengestats_returning_players_count"), + ] + + operations = [migrations.RunPython(_convert_existing_games_team_members_to_tuples)] diff --git a/src/apps/daily_challenge/models.py b/src/apps/daily_challenge/models.py index 1343f6d..afe5657 100644 --- a/src/apps/daily_challenge/models.py +++ b/src/apps/daily_challenge/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime as dt import enum import math @@ -17,13 +19,14 @@ PieceRoleBySquare, PlayerSide, ) -from lib.django_helpers import literal_to_django_choices +from lib.django_choices_helpers import literal_to_django_choices from .consts import BOT_SIDE, FACTIONS, PLAYER_SIDE if TYPE_CHECKING: - from apps.chess.types import Factions, GameTeams, Square + from apps.chess.types import GameTeamsDict, Square + from ..chess.models import GameFactions GameID: TypeAlias = str @@ -69,13 +72,13 @@ class DailyChallenge(models.Model): status: DailyChallengeStatus = models.IntegerField( choices=DailyChallengeStatus.choices, default=DailyChallengeStatus.PENDING ) - created_at: "dt.datetime" = models.DateTimeField(auto_now_add=True) - updated_at: "dt.datetime" = models.DateTimeField(auto_now=True) + created_at: dt.datetime = models.DateTimeField(auto_now_add=True) + updated_at: dt.datetime = models.DateTimeField(auto_now=True) # --- # The following 2 fields carry the state of the game we want # the daily challenge to start with... - fen: "FEN" = models.CharField(max_length=_FEN_MAX_LEN) - piece_role_by_square: "PieceRoleBySquare|None" = models.JSONField( + fen: FEN = models.CharField(max_length=_FEN_MAX_LEN) + piece_role_by_square: PieceRoleBySquare | None = models.JSONField( null=True, editable=False ) # --- @@ -93,7 +96,7 @@ class DailyChallenge(models.Model): default=5, help_text="The depth of the player's simulated search. 5 is a good value for modeling a 'casual' chess player (like myself ^_^).", ) - intro_turn_speech_square: "Square|None" = models.CharField(null=True, max_length=2) + intro_turn_speech_square: Square | None = models.CharField(null=True, max_length=2) starting_advantage: int | None = models.IntegerField( null=True, help_text="positive number means the human player has an advantage, " @@ -111,13 +114,13 @@ class DailyChallenge(models.Model): # Fields that are inferred from the above fields: # We want the bot to play first, in a deterministic way, # so we also need to store the state of the game before that first move. - fen_before_bot_first_move: "FEN | None" = models.CharField( + fen_before_bot_first_move: FEN | None = models.CharField( max_length=_FEN_MAX_LEN, null=True, editable=False ) - piece_role_by_square_before_bot_first_move: "PieceRoleBySquare | None" = ( + piece_role_by_square_before_bot_first_move: PieceRoleBySquare | None = ( models.JSONField(null=True, editable=False) ) - teams: "GameTeams|None" = models.JSONField(null=True, editable=False) + teams: GameTeamsDict | None = models.JSONField(null=True, editable=False) intro_turn_speech_text: str = models.CharField(max_length=100, blank=True) solution_turns_count: int = models.PositiveSmallIntegerField( null=True, editable=False @@ -135,7 +138,7 @@ def bot_side(self) -> PlayerSide: return BOT_SIDE @property - def factions(self) -> "Factions": + def factions(self) -> GameFactions: return FACTIONS def clean(self) -> None: @@ -184,7 +187,7 @@ def _set_inferred_fields_for_published_daily_challenge( teams, piece_role_by_square = set_daily_challenge_teams_and_pieces_roles( fen=self.fen ) - self.teams = teams + self.teams = teams.to_dict() self.piece_role_by_square = piece_role_by_square # Set `*_before_bot_first_move` fields. Can raise validation errors. @@ -265,7 +268,7 @@ def _increment_counter(self, field_name: str) -> None: self.filter(day=self._today()).update(**{field_name: F(field_name) + 1}) @staticmethod - def _today() -> "dt.date": + def _today() -> dt.date: return now().date() @@ -358,7 +361,7 @@ class PlayerGameState( # These are the moves *of the current attempt* only. moves: str undo_used: bool = False - game_over: "PlayerGameOverState" = PlayerGameOverState.PLAYING + game_over: PlayerGameOverState = PlayerGameOverState.PLAYING victory_turns_count: int | None = None # is a half-move index when the player gave up to see the solution: solution_index: int | None = None diff --git a/src/apps/daily_challenge/presenters.py b/src/apps/daily_challenge/presenters.py index 43f5c08..50e13ff 100644 --- a/src/apps/daily_challenge/presenters.py +++ b/src/apps/daily_challenge/presenters.py @@ -1,10 +1,13 @@ +from __future__ import annotations + from functools import cached_property from typing import TYPE_CHECKING from urllib.parse import urlencode from django.urls import reverse -from apps.chess.helpers import uci_move_squares +from apps.chess.chess_helpers import uci_move_squares +from apps.chess.models import GameTeams from apps.chess.presenters import GamePresenter, GamePresenterUrls from .business_logic import get_speech_bubble @@ -12,9 +15,15 @@ if TYPE_CHECKING: import chess - from apps.chess.models import UserPrefs + from apps.chess.models import GameFactions, UserPrefs from apps.chess.presenters import SpeechBubbleData - from apps.chess.types import Factions, GamePhase, PieceRole, PlayerSide, Square + from apps.chess.types import ( + BoardOrientation, + GamePhase, + PieceRole, + PlayerSide, + Square, + ) from .models import DailyChallenge, PlayerGameState @@ -25,22 +34,21 @@ class DailyChallengeGamePresenter(GamePresenter): def __init__( self, *, - challenge: "DailyChallenge", - game_state: "PlayerGameState", + challenge: DailyChallenge, + game_state: PlayerGameState, refresh_last_move: bool, is_htmx_request: bool, - forced_bot_move: tuple["Square", "Square"] | None = None, - forced_speech_bubble: tuple["Square", str] | None = None, - selected_square: "Square | None" = None, - selected_piece_square: "Square | None" = None, - target_to_confirm: "Square | None" = None, + forced_bot_move: tuple[Square, Square] | None = None, + forced_speech_bubble: tuple[Square, str] | None = None, + selected_piece_square: Square | None = None, + target_to_confirm: Square | None = None, is_bot_move: bool = False, force_square_info: bool = False, - captured_team_member_role: "PieceRole | None" = None, + captured_team_member_role: PieceRole | None = None, just_won: bool = False, is_preview: bool = False, is_very_first_game: bool = False, - user_prefs: "UserPrefs | None" = None, + user_prefs: UserPrefs | None = None, ): # A published challenge always has a `teams` non-null field: assert challenge.teams @@ -48,10 +56,9 @@ def __init__( super().__init__( fen=game_state.fen, piece_role_by_square=game_state.piece_role_by_square, - teams=challenge.teams, + teams=GameTeams.from_dict(challenge.teams), refresh_last_move=refresh_last_move, is_htmx_request=is_htmx_request, - selected_square=selected_square, selected_piece_square=selected_piece_square, target_to_confirm=target_to_confirm, forced_bot_move=forced_bot_move, @@ -70,12 +77,26 @@ def __init__( self._forced_speech_bubble = forced_speech_bubble @cached_property - def urls(self) -> "DailyChallengeGamePresenterUrls": + def board_orientation(self) -> BoardOrientation: + return "1->8" if self._challenge.my_side == "w" else "8->1" + + @cached_property + def urls(self) -> DailyChallengeGamePresenterUrls: return DailyChallengeGamePresenterUrls(game_presenter=self) + @property + def moves_must_be_confirmed(self) -> bool: + # Daily challenges are meant to be played quickly, with infinite number of + # attempts, so we shouldn't need to confirm the moves. + return False + @cached_property def is_my_turn(self) -> bool: - return self._challenge.my_side == self.active_player + return not self.is_bot_turn + + @cached_property + def my_side(self) -> PlayerSide | None: + return self._challenge.my_side @cached_property def challenge_current_attempt_turns_counter(self) -> int: @@ -94,7 +115,7 @@ def challenge_attempts_counter(self) -> int: return self.game_state.attempts_counter @cached_property - def game_phase(self) -> "GamePhase": + def game_phase(self) -> GamePhase: if (winner := self.winner) is not None: return ( "game_over:won" @@ -116,11 +137,7 @@ def game_phase(self) -> "GamePhase": def can_select_pieces(self) -> bool: # During the bot's turn we're not allowed to select any piece, as we're waiting # for the delayed HTMX request to play the bot's move. - return self.is_player_turn and not self.is_game_over - - @cached_property - def is_player_turn(self) -> bool: - return self.active_player_side != self._challenge.bot_side + return self.is_my_turn and not self.is_game_over @cached_property def is_bot_turn(self) -> bool: @@ -135,7 +152,7 @@ def game_id(self) -> str: return str(self._challenge.id) @cached_property - def factions(self) -> "Factions": + def factions(self) -> GameFactions: return self._challenge.factions @cached_property @@ -151,31 +168,31 @@ def is_player_move(self) -> bool: return not self.is_bot_move @cached_property - def player_side_to_highlight_all_pieces_for(self) -> "PlayerSide | None": + def player_side_to_highlight_all_pieces_for(self) -> PlayerSide | None: if self.is_intro_turn: return self._challenge.my_side return None @cached_property - def speech_bubble(self) -> "SpeechBubbleData | None": + def speech_bubble(self) -> SpeechBubbleData | None: return get_speech_bubble(self) @property - def chess_board(self) -> "chess.Board": + def chess_board(self) -> chess.Board: return self._chess_board @property - def challenge(self) -> "DailyChallenge": + def challenge(self) -> DailyChallenge: return self._challenge @property - def forced_speech_bubble(self) -> tuple["Square", str] | None: + def forced_speech_bubble(self) -> tuple[Square, str] | None: return self._forced_speech_bubble @staticmethod def _last_move_from_game_state( - game_state: "PlayerGameState", - ) -> tuple["Square", "Square"] | None: + game_state: PlayerGameState, + ) -> tuple[Square, Square] | None: if (moves := game_state.moves) and len(moves) >= 4: return uci_move_squares(moves[-4:]) return None @@ -191,7 +208,7 @@ def htmx_game_no_selection_url(self, *, board_id: str) -> str: ) ) - def htmx_game_select_piece_url(self, *, square: "Square", board_id: str) -> str: + def htmx_game_select_piece_url(self, *, square: Square, board_id: str) -> str: return "".join( ( reverse( @@ -205,8 +222,15 @@ def htmx_game_select_piece_url(self, *, square: "Square", board_id: str) -> str: ) ) - def htmx_game_move_piece_url(self, *, square: "Square", board_id: str) -> str: - assert self._game_presenter.selected_piece is not None + def htmx_game_move_piece_confirmation_dialog_url( + self, *, square: Square, board_id: str + ) -> str: + raise NotImplementedError( + "Daily challenges don't have a move confirmation dialog" + ) + + def htmx_game_move_piece_url(self, *, square: Square, board_id: str) -> str: + assert self._game_presenter.selected_piece is not None # type checker: happy return "".join( ( reverse( diff --git a/src/apps/daily_challenge/tests/_helpers.py b/src/apps/daily_challenge/tests/_helpers.py index b59b87d..a757011 100644 --- a/src/apps/daily_challenge/tests/_helpers.py +++ b/src/apps/daily_challenge/tests/_helpers.py @@ -1,10 +1,12 @@ +from __future__ import annotations + import re from http import HTTPStatus from typing import TYPE_CHECKING, Literal from django.utils.timezone import now -from apps.chess.helpers import uci_move_squares +from apps.chess.chess_helpers import uci_move_squares from ..models import DailyChallengeStats, PlayerSessionContent @@ -19,7 +21,7 @@ ) -def assert_response_waiting_for_bot_move(response: "HttpResponse") -> None: +def assert_response_waiting_for_bot_move(response: HttpResponse) -> None: response_html = response.content.decode() assert_response_contains_a_bot_move_to_play(response_html) assert_response_does_not_contain_pieces_selection(response_html) @@ -35,11 +37,11 @@ def assert_response_does_not_contain_pieces_selection(response_content: str) -> def play_player_move( - client: "DjangoClient", - move: "str | MoveTuple", + client: DjangoClient, + move: str | MoveTuple, *, expected_status_code: HTTPStatus = HTTPStatus.OK, -) -> "HttpResponse": +) -> HttpResponse: if isinstance(move, str): move = uci_move_squares(move) assert isinstance(move, tuple) and len(move) == 2 @@ -52,11 +54,11 @@ def play_player_move( def play_bot_move( - client: "DjangoClient", - move: "str | MoveTuple", + client: DjangoClient, + move: str | MoveTuple, *, expected_status_code: HTTPStatus = HTTPStatus.OK, -) -> "HttpResponse": +) -> HttpResponse: if isinstance(move, str): move = uci_move_squares(move) assert isinstance(move, tuple) and len(move) == 2 @@ -68,7 +70,7 @@ def play_bot_move( return response -def start_new_attempt(client: "DjangoClient") -> None: +def start_new_attempt(client: DjangoClient) -> None: restarts_count = get_today_server_stats().restarts_count response = client.post("/htmx/daily-challenge/restart/do/") assert response.status_code == HTTPStatus.OK @@ -76,8 +78,8 @@ def start_new_attempt(client: "DjangoClient") -> None: def play_moves( - client: "DjangoClient", - moves: list["MoveTuple"], + client: DjangoClient, + moves: list[MoveTuple], starting_side=Literal["bot", "player"], ) -> None: current_side = starting_side @@ -91,6 +93,6 @@ def get_today_server_stats() -> DailyChallengeStats: return DailyChallengeStats.objects.get(day=now().date()) -def get_session_content(client: "DjangoClient") -> PlayerSessionContent: +def get_session_content(client: DjangoClient) -> PlayerSessionContent: session_cookie_content: str = client.session.get("pc") return PlayerSessionContent.from_cookie_content(session_cookie_content) diff --git a/src/apps/daily_challenge/tests/business_logic/test_manage_daily_challenge_victory_logic.py b/src/apps/daily_challenge/tests/business_logic/test_manage_daily_challenge_victory_logic.py index 0d735ed..0794838 100644 --- a/src/apps/daily_challenge/tests/business_logic/test_manage_daily_challenge_victory_logic.py +++ b/src/apps/daily_challenge/tests/business_logic/test_manage_daily_challenge_victory_logic.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime as dt from typing import TYPE_CHECKING from unittest import mock @@ -30,7 +32,7 @@ def dummy_daily_challenge(): def test_manage_daily_challenge_victory_wins_count( # Test dependencies dummy_daily_challenge, - player_game_state_minimalist: "PlayerGameState", + player_game_state_minimalist: PlayerGameState, ): game_state = player_game_state_minimalist game_state.turns_counter = 8 @@ -72,7 +74,7 @@ def test_manage_daily_challenge_victory_wins_count( def test_manage_daily_challenge_victory_logic_wins_distribution( # Test dependencies dummy_daily_challenge, - player_game_state_minimalist: "PlayerGameState", + player_game_state_minimalist: PlayerGameState, # Test parameters attempts_counter: int, expected_wins_distribution: list[int], @@ -115,7 +117,7 @@ def test_manage_daily_challenge_victory_logic_wins_distribution( def test_manage_daily_challenge_victory_logic_streak_management( # Test dependencies dummy_daily_challenge, - player_game_state_minimalist: "PlayerGameState", + player_game_state_minimalist: PlayerGameState, # Test parameters current_streak: int, max_streak: int, diff --git a/src/apps/daily_challenge/tests/conftest.py b/src/apps/daily_challenge/tests/conftest.py index 772e95e..c4b1990 100644 --- a/src/apps/daily_challenge/tests/conftest.py +++ b/src/apps/daily_challenge/tests/conftest.py @@ -1,5 +1,6 @@ import pytest +from ...chess.models import TeamMember from ..models import ( DailyChallenge, DailyChallengeStatus, @@ -21,13 +22,6 @@ } -@pytest.fixture -def cleared_django_cache(): - from django.core.cache import cache - - cache.clear() - - @pytest.fixture def challenge_minimalist() -> DailyChallenge: """ @@ -61,15 +55,15 @@ def challenge_minimalist() -> DailyChallenge: piece_role_by_square=_MINIMALIST_GAME["piece_role_by_square"], teams={ "w": [ - {"role": "Q", "name": ["QUEEN", "1"], "faction": "humans"}, - {"role": "B1", "name": ["BISHOP", "1"], "faction": "humans"}, - {"role": "K", "name": ["KING", "1"], "faction": "humans"}, + TeamMember("q", ("QUEEN", "1"), "humans"), + TeamMember("b1", ("BISHOP", "1"), "humans"), + TeamMember("k", ("KING",), "humans"), ], "b": [ - {"role": "k", "name": "", "faction": "undeads"}, - {"role": "p1", "name": "", "faction": "undeads"}, - {"role": "p2", "name": "", "faction": "undeads"}, - {"role": "p3", "name": "", "faction": "undeads"}, + TeamMember("k", [], "undeads"), + TeamMember("p1", [], "undeads"), + TeamMember("p2", [], "undeads"), + TeamMember("p3", [], "undeads"), ], }, fen_before_bot_first_move="1k6/pp3Q2/7p/8/8/8/7B/K7 b - - 0 1", diff --git a/src/apps/daily_challenge/tests/test_models.py b/src/apps/daily_challenge/tests/test_models.py index 7a0f799..9691360 100644 --- a/src/apps/daily_challenge/tests/test_models.py +++ b/src/apps/daily_challenge/tests/test_models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from contextlib import nullcontext as noraise from typing import TYPE_CHECKING @@ -25,9 +27,9 @@ ], ) def test_solution_turns_counter_computation( - challenge_minimalist: "DailyChallenge", + challenge_minimalist: DailyChallenge, solution: str, - context: "AbstractContextManager", + context: AbstractContextManager, expected_moves_count: int | None, ): assert challenge_minimalist.solution_turns_count == 1 diff --git a/src/apps/daily_challenge/tests/test_server_stats.py b/src/apps/daily_challenge/tests/test_server_stats.py index fa2eb4c..dea5615 100644 --- a/src/apps/daily_challenge/tests/test_server_stats.py +++ b/src/apps/daily_challenge/tests/test_server_stats.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from unittest import mock @@ -25,8 +27,8 @@ def test_server_stats_played_challenges_count( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, cleared_django_default_cache, ): # TODO: simplify this test? 😅 @@ -40,16 +42,16 @@ def play_first_2_turns(expected_played_challenges_count: int) -> None: assert sut() == expected_played_challenges_count # player 1st move: - player_move_1: "MoveTuple" = ("a1", "b1") + player_move_1: MoveTuple = ("a1", "b1") play_player_move(client, player_move_1) assert sut() == expected_played_challenges_count - bot_move: "MoveTuple" = ("a7", "a6") + bot_move: MoveTuple = ("a7", "a6") play_bot_move(client, bot_move) assert sut() == expected_played_challenges_count # player 2nd move: - player_move_2: "MoveTuple" = ("b1", "a1") + player_move_2: MoveTuple = ("b1", "a1") play_player_move(client, player_move_2) def play_day_session(): @@ -116,8 +118,8 @@ def test_server_stats_returning_players_count( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, cleared_django_default_cache, # Test parameters previous_game_date: str | None, @@ -141,16 +143,16 @@ def sut() -> int: assert sut() == 0 # player 1st move: - player_move_1: "MoveTuple" = ("a1", "b1") + player_move_1: MoveTuple = ("a1", "b1") play_player_move(client, player_move_1) assert sut() == 0 - bot_move: "MoveTuple" = ("a7", "a6") + bot_move: MoveTuple = ("a7", "a6") play_bot_move(client, bot_move) assert sut() == 0 # player 2nd move: # --> that's where we keep a record of whether it's a returning player or not - player_move_2: "MoveTuple" = ("b1", "a1") + player_move_2: MoveTuple = ("b1", "a1") play_player_move(client, player_move_2) assert sut() == expected_returning_players_count diff --git a/src/apps/daily_challenge/tests/test_views.py b/src/apps/daily_challenge/tests/test_views.py index a2eb48a..4477142 100644 --- a/src/apps/daily_challenge/tests/test_views.py +++ b/src/apps/daily_challenge/tests/test_views.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from http import HTTPStatus from typing import TYPE_CHECKING from unittest import mock @@ -35,8 +37,8 @@ def test_game_smoke_test( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, ): get_current_challenge_mock.return_value = challenge_minimalist @@ -108,8 +110,8 @@ def test_htmx_game_select_piece_input_validation( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, # Test parameters location: str, expected_status_code: int, @@ -130,7 +132,7 @@ def test_htmx_game_select_piece_input_validation( "expected_team_member_name_display", ), ( - ("a1", "KING 1"), + ("a1", "KING"), ("f7", "QUEEN 1"), ), ) @@ -140,10 +142,10 @@ def test_htmx_game_select_piece_returned_html( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, # Test parameters - square: "Square", + square: Square, expected_team_member_name_display: str, ): get_current_challenge_mock.return_value = challenge_minimalist @@ -157,7 +159,7 @@ def test_htmx_game_select_piece_returned_html( response_html = response.content.decode() assert expected_team_member_name_display in response_html - not_expected_team_member_names_display = {"KING 1", "QUEEN 1", "BISHOP 1"} - { + not_expected_team_member_names_display = {"KING", "QUEEN 1", "BISHOP 1"} - { expected_team_member_name_display } for other_team_member_name in not_expected_team_member_names_display: @@ -184,8 +186,8 @@ def test_htmx_game_move_piece_input_validation( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, # Test parameters input_: dict, expected_status_code: HTTPStatus, @@ -230,8 +232,8 @@ def test_htmx_game_play_bot_move_validation( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, # Test parameters input_: dict, expected_status_code: int, @@ -251,8 +253,8 @@ def test_htmx_game_select_piece_should_fail_on_empty_square( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, ): get_current_challenge_mock.return_value = challenge_minimalist @@ -271,8 +273,8 @@ def test_stats_modal_smoke_test( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, ): get_current_challenge_mock.return_value = challenge_minimalist @@ -289,9 +291,9 @@ def test_stats_modal_can_display_todays_victory_metrics_test( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", - cleared_django_cache, + challenge_minimalist: DailyChallenge, + client: DjangoClient, + cleared_django_default_cache, ): get_current_challenge_mock.return_value = challenge_minimalist @@ -311,12 +313,12 @@ def _open_stats_modal() -> str: # Now let's win the game in 2 attempts, and re-open that modal: # 1st attempt: - attempt_1_moves: "list[MoveTuple]" = [("b8", "a8"), ("h2", "g1"), ("a8", "b8")] + attempt_1_moves: list[MoveTuple] = [("b8", "a8"), ("h2", "g1"), ("a8", "b8")] play_moves(client, attempt_1_moves, starting_side="bot") start_new_attempt(client) # 2nd attempt, ends with a mate: # fmt:off - attempt_2_moves:"list[MoveTuple]" = [("b8", "a8"), ("h2", "g1"), ("a8", "b8"), ("g1", "h2"), ("b8", "a8"), ("f7", "f8")] + attempt_2_moves:list[MoveTuple] = [("b8", "a8"), ("h2", "g1"), ("a8", "b8"), ("g1", "h2"), ("b8", "a8"), ("f7", "f8")] # fmt:on play_moves(client, attempt_2_moves, starting_side="bot") @@ -335,8 +337,8 @@ def test_help_modal_smoke_test( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, ): get_current_challenge_mock.return_value = challenge_minimalist diff --git a/src/apps/daily_challenge/urls.py b/src/apps/daily_challenge/urls.py index 434c459..15119fa 100644 --- a/src/apps/daily_challenge/urls.py +++ b/src/apps/daily_challenge/urls.py @@ -32,11 +32,6 @@ views.htmx_daily_challenge_stats_modal, name="htmx_daily_challenge_modal_stats", ), - path( - "htmx/daily-challenge/modals/user-prefs/", - views.htmx_daily_challenge_user_prefs_modal, - name="htmx_daily_challenge_modal_user_prefs", - ), path( "htmx/daily-challenge/modals/help/", views.htmx_daily_challenge_help_modal, @@ -44,9 +39,9 @@ ), # Restart views path( - "htmx/daily-challenge/restart/ask-confirmation/", - views.htmx_restart_daily_challenge_ask_confirmation, - name="htmx_restart_daily_challenge_ask_confirmation", + "htmx/daily-challenge/restart/confirmation-dialog/", + views.htmx_restart_daily_challenge_confirmation_dialog, + name="htmx_restart_daily_challenge_confirmation_dialog", ), path( "htmx/daily-challenge/restart/do/", @@ -55,26 +50,20 @@ ), # Undo views path( - "htmx/daily-challenge/undo/ask-confirmation/", - views.htmx_undo_last_move_ask_confirmation, - name="htmx_undo_last_move_ask_confirmation", + "htmx/daily-challenge/undo/confirmation-dialog/", + views.htmx_undo_last_move_confirmation_dialog, + name="htmx_undo_last_move_confirmation_dialog", ), path( "htmx/daily-challenge/undo/do/", views.htmx_undo_last_move_do, name="htmx_undo_last_move_do", ), - # User prefs views - path( - "htmx/daily-challenge/user-prefs/", - views.htmx_daily_challenge_user_prefs_save, - name="htmx_daily_challenge_user_prefs_save", - ), # "See the solution" views path( - "htmx/daily-challenge/see-solution/ask-confirmation/", - views.htmx_see_daily_challenge_solution_ask_confirmation, - name="htmx_see_daily_challenge_solution_ask_confirmation", + "htmx/daily-challenge/see-solution/confirmation-dialog/", + views.htmx_see_daily_challenge_solution_confirmation_dialog, + name="htmx_see_daily_challenge_solution_confirmation_dialog", ), path( "htmx/daily-challenge/see-solution/do/", diff --git a/src/apps/daily_challenge/view_helpers.py b/src/apps/daily_challenge/view_helpers.py index 4e2d028..656b28c 100644 --- a/src/apps/daily_challenge/view_helpers.py +++ b/src/apps/daily_challenge/view_helpers.py @@ -1,11 +1,12 @@ +from __future__ import annotations + import dataclasses from typing import TYPE_CHECKING, cast +from apps.webui.cookie_helpers import get_user_prefs_from_request + +from . import cookie_helpers from .business_logic import manage_new_daily_challenge_stats_logic -from .cookie_helpers import ( - get_or_create_daily_challenge_state_for_player, - get_user_prefs_from_request, -) if TYPE_CHECKING: from django.http import HttpRequest @@ -22,24 +23,26 @@ class GameContext: and some other data that is useful for our Views (aka "Controllers" in MVC). """ - challenge: "DailyChallenge" + challenge: DailyChallenge is_preview: bool is_staff_user: bool """`is_preview` is True if we're in admin preview mode""" - game_state: "PlayerGameState" - stats: "PlayerStats" - user_prefs: "UserPrefs" + game_state: PlayerGameState + stats: PlayerStats + user_prefs: UserPrefs created: bool """if the game state was created on the fly as we were initialising that object""" board_id: str = "main" @classmethod - def create_from_request(cls, request: "HttpRequest") -> "GameContext": + def create_from_request(cls, request: HttpRequest) -> GameContext: is_staff_user: bool = request.user.is_staff challenge, is_preview = get_current_daily_challenge_or_admin_preview(request) - game_state, stats, created = get_or_create_daily_challenge_state_for_player( - request=request, challenge=challenge + game_state, stats, created = ( + cookie_helpers.get_or_create_daily_challenge_state_for_player( + request=request, challenge=challenge + ) ) user_prefs = get_user_prefs_from_request(request) # TODO: validate the "board_id" data? @@ -63,8 +66,8 @@ def create_from_request(cls, request: "HttpRequest") -> "GameContext": def get_current_daily_challenge_or_admin_preview( - request: "HttpRequest", -) -> tuple["DailyChallenge", bool]: + request: HttpRequest, +) -> tuple[DailyChallenge, bool]: from .business_logic import get_current_daily_challenge from .models import DailyChallenge diff --git a/src/apps/daily_challenge/views.py b/src/apps/daily_challenge/views.py index c64517c..5ac9c60 100644 --- a/src/apps/daily_challenge/views.py +++ b/src/apps/daily_challenge/views.py @@ -1,15 +1,16 @@ +from __future__ import annotations + import functools import logging from typing import TYPE_CHECKING from django.contrib.auth.decorators import user_passes_test from django.http import HttpResponse -from django.shortcuts import redirect, resolve_url +from django.shortcuts import redirect from django.views.decorators.http import require_POST, require_safe -from django_htmx.http import HttpResponseClientRedirect -from apps.chess.helpers import get_active_player_side_from_fen, uci_move_squares -from apps.chess.types import ChessInvalidActionException, ChessInvalidMoveException +from apps.chess.chess_helpers import get_active_player_side_from_fen, uci_move_squares +from apps.chess.exceptions import ChessInvalidActionException, ChessInvalidMoveException from apps.utils.view_decorators import user_is_staff from apps.utils.views_helpers import htmx_aware_redirect @@ -20,12 +21,11 @@ move_daily_challenge_piece, restart_daily_challenge, see_daily_challenge_solution, + undo_last_move, ) -from .business_logic._undo_last_move import undo_last_move from .components.misc_ui.help_modal import help_modal from .components.misc_ui.stats_modal import stats_modal -from .components.misc_ui.user_prefs_modal import user_prefs_modal -from .components.pages.daily_chess import ( +from .components.pages.daily_chess_pages import ( daily_challenge_moving_parts_fragment, daily_challenge_page, ) @@ -33,9 +33,7 @@ clear_daily_challenge_game_state_in_session, get_or_create_daily_challenge_state_for_player, save_daily_challenge_state_in_session, - save_user_prefs, ) -from .forms import UserPrefsForm from .models import PlayerGameOverState from .presenters import DailyChallengeGamePresenter from .view_helpers import get_current_daily_challenge_or_admin_preview @@ -58,7 +56,7 @@ @require_safe @with_game_context -def game_view(request: "HttpRequest", *, ctx: "GameContext") -> HttpResponse: +def game_view(request: HttpRequest, *, ctx: GameContext) -> HttpResponse: if ctx.created: # The player hasn't played this challenge before, # so we need to start from the beginning, with the bot's first move: @@ -67,6 +65,7 @@ def game_view(request: "HttpRequest", *, ctx: "GameContext") -> HttpResponse: assert ( ctx.challenge.fen_before_bot_first_move and ctx.challenge.piece_role_by_square_before_bot_first_move + and ctx.challenge.bot_first_move ) ctx.game_state.fen = ctx.challenge.fen_before_bot_first_move @@ -109,9 +108,7 @@ def game_view(request: "HttpRequest", *, ctx: "GameContext") -> HttpResponse: @require_safe @with_game_context @redirect_if_game_not_started -def htmx_game_no_selection( - request: "HttpRequest", *, ctx: "GameContext" -) -> HttpResponse: +def htmx_game_no_selection(request: HttpRequest, *, ctx: GameContext) -> HttpResponse: game_presenter = DailyChallengeGamePresenter( challenge=ctx.challenge, game_state=ctx.game_state, @@ -130,7 +127,7 @@ def htmx_game_no_selection( @with_game_context @redirect_if_game_not_started def htmx_game_select_piece( - request: "HttpRequest", *, ctx: "GameContext", location: "Square" + request: HttpRequest, *, ctx: GameContext, location: Square ) -> HttpResponse: game_presenter = DailyChallengeGamePresenter( challenge=ctx.challenge, @@ -151,7 +148,7 @@ def htmx_game_select_piece( @with_game_context @redirect_if_game_not_started def htmx_game_move_piece( - request: "HttpRequest", *, ctx: "GameContext", from_: "Square", to: "Square" + request: HttpRequest, *, ctx: GameContext, from_: Square, to: Square ) -> HttpResponse: if from_ == to: raise ChessInvalidMoveException("Not a move") @@ -226,7 +223,7 @@ def htmx_game_move_piece( @require_safe @with_game_context def htmx_daily_challenge_stats_modal( - request: "HttpRequest", *, ctx: "GameContext" + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: modal_content = stats_modal( stats=ctx.stats, game_state=ctx.game_state, challenge=ctx.challenge @@ -238,7 +235,7 @@ def htmx_daily_challenge_stats_modal( @require_safe @with_game_context def htmx_daily_challenge_help_modal( - request: "HttpRequest", *, ctx: "GameContext" + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: game_presenter = DailyChallengeGamePresenter( challenge=ctx.challenge, @@ -255,44 +252,26 @@ def htmx_daily_challenge_help_modal( @require_safe @with_game_context -def htmx_daily_challenge_user_prefs_modal( - request: "HttpRequest", *, ctx: "GameContext" -) -> HttpResponse: - modal_content = user_prefs_modal(user_prefs=ctx.user_prefs) - - return HttpResponse(str(modal_content)) - - -@require_POST -@with_game_context @redirect_if_game_not_started -def htmx_restart_daily_challenge_ask_confirmation( - request: "HttpRequest", *, ctx: "GameContext" +def htmx_restart_daily_challenge_confirmation_dialog( + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: - from .components.misc_ui.daily_challenge_bar import ( - daily_challenge_bar, - retry_confirmation_display, + from .components.companion_bars.top_companion_bar import ( + retry_confirmation_dialog_bar, ) - daily_challenge_bar_inner_content = retry_confirmation_display( - board_id=ctx.board_id - ) - - return HttpResponse( - daily_challenge_bar( - game_presenter=None, - inner_content=daily_challenge_bar_inner_content, - board_id=ctx.board_id, - ) - ) + return HttpResponse(retry_confirmation_dialog_bar(board_id=ctx.board_id)) @require_POST @with_game_context @redirect_if_game_not_started def htmx_restart_daily_challenge_do( - request: "HttpRequest", *, ctx: "GameContext" + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: + # This field is always set on a published challenge: + assert ctx.challenge.bot_first_move + new_game_state = restart_daily_challenge( challenge=ctx.challenge, game_state=ctx.game_state, @@ -321,34 +300,23 @@ def htmx_restart_daily_challenge_do( ) -@require_POST +@require_safe @with_game_context @redirect_if_game_not_started -def htmx_undo_last_move_ask_confirmation( - request: "HttpRequest", *, ctx: "GameContext" +def htmx_undo_last_move_confirmation_dialog( + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: - from .components.misc_ui.daily_challenge_bar import ( - daily_challenge_bar, - undo_confirmation_display, + from .components.companion_bars.top_companion_bar import ( + undo_confirmation_dialog_bar, ) - daily_challenge_bar_inner_content = undo_confirmation_display(board_id=ctx.board_id) - - return HttpResponse( - daily_challenge_bar( - game_presenter=None, - inner_content=daily_challenge_bar_inner_content, - board_id=ctx.board_id, - ) - ) + return HttpResponse(undo_confirmation_dialog_bar(board_id=ctx.board_id)) @require_POST @with_game_context @redirect_if_game_not_started -def htmx_undo_last_move_do( - request: "HttpRequest", *, ctx: "GameContext" -) -> HttpResponse: +def htmx_undo_last_move_do(request: HttpRequest, *, ctx: GameContext) -> HttpResponse: new_game_state = undo_last_move( challenge=ctx.challenge, game_state=ctx.game_state, @@ -374,51 +342,24 @@ def htmx_undo_last_move_do( ) -@require_POST -def htmx_daily_challenge_user_prefs_save(request: "HttpRequest") -> HttpResponse: - # As user preferences updates can have an impact on any part of the UI - # (changing the way the chess board is displayed, for example), we'd better - # reload the whole page after having saved preferences. - response = HttpResponseClientRedirect( - resolve_url("daily_challenge:daily_game_view") - ) - - form = UserPrefsForm(request.POST) - if user_prefs := form.to_user_prefs(): - save_user_prefs(user_prefs=user_prefs, response=response) - - return response - - -@require_POST +@require_safe @with_game_context @redirect_if_game_not_started -def htmx_see_daily_challenge_solution_ask_confirmation( - request: "HttpRequest", *, ctx: "GameContext" +def htmx_see_daily_challenge_solution_confirmation_dialog( + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: - from .components.misc_ui.daily_challenge_bar import ( - daily_challenge_bar, - see_solution_confirmation_display, - ) - - daily_challenge_bar_inner_content = see_solution_confirmation_display( - board_id=ctx.board_id + from .components.companion_bars.top_companion_bar import ( + see_solution_confirmation_dialog_bar, ) - return HttpResponse( - daily_challenge_bar( - game_presenter=None, - inner_content=daily_challenge_bar_inner_content, - board_id=ctx.board_id, - ) - ) + return HttpResponse(see_solution_confirmation_dialog_bar(board_id=ctx.board_id)) @require_POST @with_game_context @redirect_if_game_not_started def htmx_see_daily_challenge_solution_do( - request: "HttpRequest", *, ctx: "GameContext" + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: new_game_state = see_daily_challenge_solution( challenge=ctx.challenge, @@ -453,7 +394,7 @@ def htmx_see_daily_challenge_solution_do( @with_game_context @redirect_if_game_not_started def htmx_see_daily_challenge_solution_play( - request: "HttpRequest", *, ctx: "GameContext" + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: if (solution_index := ctx.game_state.solution_index) is None: # This is a fishy request 😅 @@ -500,7 +441,7 @@ def htmx_see_daily_challenge_solution_play( @with_game_context @redirect_if_game_not_started def htmx_game_bot_move( - request: "HttpRequest", *, ctx: "GameContext", from_: "Square", to: "Square" + request: HttpRequest, *, ctx: GameContext, from_: Square, to: Square ) -> HttpResponse: if from_ == to: raise ChessInvalidMoveException("Not a move") @@ -524,7 +465,7 @@ def htmx_game_bot_move( @require_safe @user_passes_test(user_is_staff) @with_game_context -def debug_reset_today(request: "HttpRequest", *, ctx: "GameContext") -> HttpResponse: +def debug_reset_today(request: HttpRequest, *, ctx: GameContext) -> HttpResponse: clear_daily_challenge_game_state_in_session(request=request, player_stats=ctx.stats) return redirect("daily_challenge:daily_game_view") @@ -533,7 +474,7 @@ def debug_reset_today(request: "HttpRequest", *, ctx: "GameContext") -> HttpResp @require_safe @user_passes_test(user_is_staff) @with_game_context -def debug_reset_stats(request: "HttpRequest", *, ctx: "GameContext") -> HttpResponse: +def debug_reset_stats(request: HttpRequest, *, ctx: GameContext) -> HttpResponse: # This function is VERY dangerous, so let's make sure we're not using it # in another view accidentally 😅 from .cookie_helpers import clear_daily_challenge_stats_in_session @@ -545,7 +486,7 @@ def debug_reset_stats(request: "HttpRequest", *, ctx: "GameContext") -> HttpResp @require_safe @user_passes_test(user_is_staff) -def debug_view_cookie(request: "HttpRequest") -> HttpResponse: +def debug_view_cookie(request: HttpRequest) -> HttpResponse: import msgspec from .cookie_helpers import get_player_session_content_from_request @@ -574,9 +515,9 @@ def format_struct(struct): def _play_bot_move( *, - request: "HttpRequest", - ctx: "GameContext", - move: "MoveTuple", + request: HttpRequest, + ctx: GameContext, + move: MoveTuple, board_id: str, ) -> HttpResponse: game_over_already = ctx.game_state.game_over != PlayerGameOverState.PLAYING @@ -621,7 +562,7 @@ def _play_bot_move( def _daily_challenge_moving_parts_fragment_response( *, game_presenter: DailyChallengeGamePresenter, - request: "HttpRequest", + request: HttpRequest, board_id: str, ) -> HttpResponse: return HttpResponse( @@ -634,5 +575,5 @@ def _daily_challenge_moving_parts_fragment_response( @functools.lru_cache(maxsize=20) def _daily_challenge_move_for_solution_index( challenge_solution: str, solution_index: int -) -> tuple["Square", "Square"]: +) -> tuple[Square, Square]: return uci_move_squares(challenge_solution.split(",")[solution_index]) diff --git a/src/apps/daily_challenge/views_decorators.py b/src/apps/daily_challenge/views_decorators.py index 2d54b2f..14bc64d 100644 --- a/src/apps/daily_challenge/views_decorators.py +++ b/src/apps/daily_challenge/views_decorators.py @@ -1,10 +1,11 @@ +from __future__ import annotations + import functools from typing import TYPE_CHECKING from django.core.exceptions import BadRequest -from apps.chess.types import ChessLogicException - +from ..chess.exceptions import ChessLogicException from ..utils.views_helpers import htmx_aware_redirect from .cookie_helpers import clear_daily_challenge_game_state_in_session from .view_helpers import GameContext @@ -28,7 +29,7 @@ def wrapper(*args, **kwargs): def with_game_context(func): @functools.wraps(func) - def wrapper(request: "HttpRequest", *args, **kwargs): + def wrapper(request: HttpRequest, *args, **kwargs): ctx = GameContext.create_from_request(request) return func(request, *args, ctx=ctx, **kwargs) @@ -37,7 +38,7 @@ def wrapper(request: "HttpRequest", *args, **kwargs): def redirect_if_game_not_started(func): @functools.wraps(func) - def wrapper(request: "HttpRequest", *args, ctx: GameContext, **kwargs): + def wrapper(request: HttpRequest, *args, ctx: GameContext, **kwargs): if ctx.created: return _redirect_to_game_view_screen_with_brand_new_game(request, ctx.stats) return func(request, *args, ctx=ctx, **kwargs) @@ -46,8 +47,8 @@ def wrapper(request: "HttpRequest", *args, ctx: GameContext, **kwargs): def _redirect_to_game_view_screen_with_brand_new_game( - request: "HttpRequest", player_stats: "PlayerStats" -) -> "HttpResponse": + request: HttpRequest, player_stats: PlayerStats +) -> HttpResponse: clear_daily_challenge_game_state_in_session( request=request, player_stats=player_stats ) diff --git a/src/apps/lichess_bridge/__init__.py b/src/apps/lichess_bridge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/lichess_bridge/authentication.py b/src/apps/lichess_bridge/authentication.py new file mode 100644 index 0000000..a087a7a --- /dev/null +++ b/src/apps/lichess_bridge/authentication.py @@ -0,0 +1,171 @@ +# Packages such as django-allauth or AuthLib do provide turnkey Django integrations +# for OAuth2 - or even with Lichess specifically, for the former. +# However, in my case I don't want to use Django's "auth" machinery to manage Lichess +# users: all I want is to store in an HTTP-only cookie that they have attached +# a Lichess account, alongside with the token we'll need to communicate with Lichess. +# Hence, the following low level code, written after the Flask example given by Lichess: +# https://github.com/lakinwecker/lichess-oauth-flask/blob/master/app.py +# Authlib "vanilla Python" usage: +# https://docs.authlib.org/en/latest/client/oauth2.html +from __future__ import annotations + +import functools +from typing import TYPE_CHECKING, Literal + +import msgspec +from authlib.common.security import generate_token +from authlib.integrations.requests_client import OAuth2Session +from django.conf import settings +from django.core.exceptions import SuspiciousOperation +from django.urls import reverse + +if TYPE_CHECKING: + from typing import Self + + from django.http import HttpRequest + + from .models import LichessAccessToken + +LICHESS_OAUTH2_SCOPES = ("board:play",) + + +class LichessTokenRetrievalProcessContext( + msgspec.Struct, + kw_only=True, # type: ignore[call-arg] +): + """ + Short-lived data required to complete the retrieval of an API token + from Lichess' OAuth2 process. + """ + + csrf_state: str + code_verifier: str + zakuchess_redirect_url: str # an absolute HTTP/HTTPS URL + + def to_cookie_content(self) -> str: + cookie_content = { + # We don't encode the redirect URL into the cookie, so let's customise + # what we need by encoding a dict, rather than "self" + "csrf": self.csrf_state, + "verif": self.code_verifier, + } + return msgspec.json.encode(cookie_content).decode() + + @classmethod + def from_cookie_content( + cls, + cookie_content: str, + *, + zakuchess_hostname: str, + zakuchess_protocol: str = "https", + ) -> Self: + cookie_content_dict = msgspec.json.decode(cookie_content) + redirect_uri = _get_lichess_oauth2_zakuchess_redirect_uri( + zakuchess_protocol, + zakuchess_hostname, + ) + + return cls( + csrf_state=cookie_content_dict["csrf"], + code_verifier=cookie_content_dict["verif"], + zakuchess_redirect_url=redirect_uri, + ) + + @classmethod + def create_afresh( + cls, + *, + zakuchess_hostname: str, + zakuchess_protocol: str = "https", + ) -> Self: + """ + Returns a context with randomly generated "CSRF state" and "code verifier". + """ + redirect_uri = _get_lichess_oauth2_zakuchess_redirect_uri( + zakuchess_protocol, zakuchess_hostname + ) + + csrf_state = generate_token() + code_verifier = generate_token(48) + + return cls( + csrf_state=csrf_state, + code_verifier=code_verifier, + zakuchess_redirect_url=redirect_uri, + ) + + +class LichessToken(msgspec.Struct): + token_type: Literal["Bearer"] + access_token: LichessAccessToken + expires_in: int # number of seconds + expires_at: int # a Unix timestamp + + +def get_lichess_token_retrieval_via_oauth2_process_starting_url( + *, + context: LichessTokenRetrievalProcessContext, +) -> str: + lichess_authorization_endpoint = f"{settings.LICHESS_HOST}/oauth" + + client = _get_lichess_oauth2_client() + uri, state = client.create_authorization_url( + lichess_authorization_endpoint, + response_type="code", + state=context.csrf_state, + redirect_uri=context.zakuchess_redirect_url, + code_verifier=context.code_verifier, + ) + assert state == context.csrf_state + + return uri + + +def check_csrf_state_from_oauth2_callback( + *, request: HttpRequest, context: LichessTokenRetrievalProcessContext +): + """ + Raises a SuspiciousOperation if the state from the request's query string + doesn't match the state from the short-lived cookie. + """ + csrf_state_from_request = request.GET["state"] + csrf_state_from_short_lived_cookie = context.csrf_state + if csrf_state_from_short_lived_cookie != csrf_state_from_request: + raise SuspiciousOperation("OAuth2 CSRF state mismatch") + + +def fetch_lichess_token_from_oauth2_callback( + *, + authorization_callback_response_url: str, + context: LichessTokenRetrievalProcessContext, +) -> LichessToken: + lichess_token_endpoint = f"{settings.LICHESS_HOST}/api/token" + + client = _get_lichess_oauth2_client() + token_as_dict = client.fetch_token( + lichess_token_endpoint, + authorization_response=authorization_callback_response_url, + redirect_uri=context.zakuchess_redirect_url, + code_verifier=context.code_verifier, + ) + + return LichessToken( + **token_as_dict, + ) + + +@functools.lru_cache +def _get_lichess_oauth2_zakuchess_redirect_uri( + zakuchess_protocol: str, zakuchess_hostname: str +) -> str: + return f"{zakuchess_protocol}://{zakuchess_hostname}" + reverse( + "lichess_bridge:oauth2_token_callback" + ) + + +def _get_lichess_oauth2_client() -> OAuth2Session: + return OAuth2Session( + client_id=settings.LICHESS_CLIENT_ID, + code_challenge_method="S256", + scope=" ".join(LICHESS_OAUTH2_SCOPES), + ) diff --git a/src/apps/lichess_bridge/business_logic/__init__.py b/src/apps/lichess_bridge/business_logic/__init__.py new file mode 100644 index 0000000..e82fc65 --- /dev/null +++ b/src/apps/lichess_bridge/business_logic/__init__.py @@ -0,0 +1,13 @@ +# ruff: noqa: F401 + +from ._create_teams_and_piece_role_by_square_for_starting_position import ( + create_teams_and_piece_role_by_square_for_starting_position, +) +from ._rebuild_game_from_moves import ( + RebuildGameFromMovesResult, + rebuild_game_from_moves, +) +from ._rebuild_game_from_pgn import ( + RebuildGameFromPgnResult, + rebuild_game_from_pgn, +) diff --git a/src/apps/lichess_bridge/business_logic/_create_teams_and_piece_role_by_square_for_starting_position.py b/src/apps/lichess_bridge/business_logic/_create_teams_and_piece_role_by_square_for_starting_position.py new file mode 100644 index 0000000..6603b20 --- /dev/null +++ b/src/apps/lichess_bridge/business_logic/_create_teams_and_piece_role_by_square_for_starting_position.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import functools +from typing import TYPE_CHECKING, cast + +import chess + +from apps.chess.chess_helpers import ( + chess_lib_color_to_player_side, + chess_lib_square_to_square, + team_member_role_from_piece_role, +) +from apps.chess.models import GameTeams, TeamMember + +if TYPE_CHECKING: + from apps.chess.models import GameFactions + from apps.chess.types import ( + PieceRole, + PieceRoleBySquare, + PieceSymbol, + PlayerSide, + Square, + ) + +# Since we cache the result of this function, we want to return an immutable object. +# --> instead of returning a PieceRoleBySquare dict, we return a tuple of tuples - from +# which we can easily re-create a PieceRoleBySquare dict. +PieceRoleBySquareTuple = tuple[tuple["Square", "PieceRole"], ...] + + +@functools.cache +def create_teams_and_piece_role_by_square_for_starting_position( + factions: GameFactions, +) -> tuple[GameTeams, PieceRoleBySquareTuple]: + # fmt: off + piece_counters: dict[PieceSymbol, int | None] = { + "P": 0, "R": 0, "N": 0, "B": 0, "Q": None, "K": None, + "p": 0, "r": 0, "n": 0, "b": 0, "q": None, "k": None, + } + # fmt: on + + teams: dict[PlayerSide, list[TeamMember]] = {"w": [], "b": []} + piece_role_by_square: PieceRoleBySquare = {} + chess_board = chess.Board() + for chess_square in chess.SQUARES: + piece = chess_board.piece_at(chess_square) + if not piece: + continue + + player_side = chess_lib_color_to_player_side(piece.color) + symbol = cast("PieceSymbol", piece.symbol()) # e.g. "P", "p", "R", "r"... + if piece_counters[symbol] is not None: + piece_counters[symbol] += 1 # type: ignore[operator] + piece_role = cast( + "PieceRole", f"{symbol}{piece_counters[symbol]}" + ) # e.g "P1", "r2".... + else: + piece_role = cast("PieceRole", symbol) # e.g. "Q", "k"... + + team_member_role = team_member_role_from_piece_role(piece_role) + team_member = TeamMember( + role=team_member_role, + name=("",), + faction=factions.get_faction_for_side(player_side), + ) + teams[player_side].append(team_member) + + square = chess_lib_square_to_square(chess_square) + piece_role_by_square[square] = piece_role + + return ( + GameTeams(w=tuple(teams["w"]), b=tuple(teams["b"])), + tuple(piece_role_by_square.items()), + ) diff --git a/src/apps/lichess_bridge/business_logic/_rebuild_game_from_moves.py b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_moves.py new file mode 100644 index 0000000..2c3b1e8 --- /dev/null +++ b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_moves.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import logging +import time +from typing import TYPE_CHECKING, NamedTuple + +import chess + +from ...chess.business_logic import do_chess_move_with_piece_role_by_square +from ...chess.chess_helpers import uci_move_squares + +if TYPE_CHECKING: + from collections.abc import Sequence + + import chess.pgn + + from apps.chess.types import PieceRoleBySquare, UCIMove + + from ...chess.models import GameFactions, GameTeams + +_logger = logging.getLogger(__name__) + + +class RebuildGameFromMovesResult(NamedTuple): + chess_board: chess.Board + teams: GameTeams + piece_role_by_square: PieceRoleBySquare + moves: Sequence[UCIMove] + + +def rebuild_game_from_moves( + *, uci_moves: Sequence[UCIMove], factions: GameFactions +) -> RebuildGameFromMovesResult: + from ._create_teams_and_piece_role_by_square_for_starting_position import ( + create_teams_and_piece_role_by_square_for_starting_position, + ) + + start_time = time.monotonic() + + # We start with a "starting position "chess board"... + teams, piece_role_by_square_tuple = ( + create_teams_and_piece_role_by_square_for_starting_position(factions) + ) + piece_role_by_square = dict(piece_role_by_square_tuple) + + # ...and then we apply the moves from the game data to it, one by one: + # (while keeping track of the piece roles on the board, so if "p1" moves, + # we can "follow" that pawn) + chess_board = chess.Board() + for move in uci_moves: + from_, to = uci_move_squares(move) + move_result, piece_role_by_square, captured_piece = ( + do_chess_move_with_piece_role_by_square( + from_=from_, + to=to, + piece_role_by_square=piece_role_by_square, + chess_board=chess_board, + ) + ) + + _logger.info( + "`rebuild_game_from_moves` took %d ms. for %d moves", + (time.monotonic() - start_time) * 1000, + len(uci_moves), + ) + + return RebuildGameFromMovesResult( + chess_board=chess_board, + teams=teams, + piece_role_by_square=piece_role_by_square, + moves=uci_moves, + ) diff --git a/src/apps/lichess_bridge/business_logic/_rebuild_game_from_pgn.py b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_pgn.py new file mode 100644 index 0000000..febc12a --- /dev/null +++ b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_pgn.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import logging +import time +from typing import TYPE_CHECKING, NamedTuple + +import chess + +from ...chess.business_logic import do_chess_move_with_piece_role_by_square +from ...chess.chess_helpers import chess_lib_square_to_square + +if TYPE_CHECKING: + from collections.abc import Sequence + + import chess.pgn + + from apps.chess.types import PieceRoleBySquare, UCIMove + + from ...chess.models import GameFactions, GameTeams + +_logger = logging.getLogger(__name__) + + +class RebuildGameFromPgnResult(NamedTuple): + chess_board: chess.Board + teams: GameTeams + piece_role_by_square: PieceRoleBySquare + moves: Sequence[UCIMove] + + +def rebuild_game_from_pgn( + *, pgn_game: chess.pgn.Game, factions: GameFactions +) -> RebuildGameFromPgnResult: + from ._create_teams_and_piece_role_by_square_for_starting_position import ( + create_teams_and_piece_role_by_square_for_starting_position, + ) + + start_time = time.monotonic() + + # We start with a "starting position "chess board"... + teams, piece_role_by_square_tuple = ( + create_teams_and_piece_role_by_square_for_starting_position(factions) + ) + piece_role_by_square = dict(piece_role_by_square_tuple) + + # ...and then we apply the moves from the game data to it, one by one: + # (while keeping track of the piece roles on the board, so if "p1" moves, + # we can "follow" that pawn) + chess_board = chess.Board() + uci_moves: list[str] = [] + for move in pgn_game.mainline_moves(): + from_, to = ( + chess_lib_square_to_square(move.from_square), + chess_lib_square_to_square(move.to_square), + ) + move_result, piece_role_by_square, captured_piece = ( + do_chess_move_with_piece_role_by_square( + from_=from_, + to=to, + piece_role_by_square=piece_role_by_square, + chess_board=chess_board, + ) + ) + uci_moves.append(move.uci()) + + _logger.info( + "`rebuild_game_from_pgn` took %d ms. for %d moves", + (time.monotonic() - start_time) * 1000, + len(uci_moves), + ) + + return RebuildGameFromPgnResult( + chess_board=chess_board, + teams=teams, + piece_role_by_square=piece_role_by_square, + moves=uci_moves, + ) diff --git a/src/apps/lichess_bridge/components/__init__.py b/src/apps/lichess_bridge/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/lichess_bridge/components/companion_bars/__init__.py b/src/apps/lichess_bridge/components/companion_bars/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/lichess_bridge/components/companion_bars/top_companion_bar.py b/src/apps/lichess_bridge/components/companion_bars/top_companion_bar.py new file mode 100644 index 0000000..66a7c04 --- /dev/null +++ b/src/apps/lichess_bridge/components/companion_bars/top_companion_bar.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from urllib.parse import urlencode + +from django.urls import reverse +from dominate.tags import b, div, p + +from apps.webui.components.molecules.chess_arena_companion_bars import ( + companion_bar, + confirmation_dialog_bar, +) + +if TYPE_CHECKING: + from dominate.tags import dom_tag + + from ...presenters import LichessCorrespondenceGamePresenter + + +def lichess_bridge_bar( + *, + game_presenter: LichessCorrespondenceGamePresenter, + board_id: str, + htmx_attrs: dict[str, str] | None = None, +) -> dom_tag: + if game_presenter.target_square_to_confirm is not None: + return move_piece_confirmation_dialog_bar( + game_presenter=game_presenter, htmx_attrs=htmx_attrs, board_id=board_id + ) + + inner_content = div( + p( + "Game against ", + b(game_presenter.opponent_username), + " on Lichess. ", + f"Turn #{game_presenter.chess_board.fullmove_number}", + cls="text-center", + ), + p( + "Your turn! 🙂" + if game_presenter.is_my_turn + else "Waiting for them to move. ⏳", + cls="text-center", + ), + ) + + return companion_bar( + inner_content, + id_=f"chess-board-lichess-bridge-bar-{board_id}", + position="top", + htmx_attrs=htmx_attrs, + ) + + +def move_piece_confirmation_dialog_bar( + *, + game_presenter: LichessCorrespondenceGamePresenter, + htmx_attrs: dict[str, str] | None = None, + board_id: str, +) -> dom_tag: + assert game_presenter.selected_piece is not None + assert game_presenter.target_square_to_confirm is not None + + htmx_attrs_confirm = { + "data_hx_post": "".join( + ( + reverse( + "lichess_bridge:htmx_game_move_piece", + kwargs={ + "game_id": game_presenter.game_id, + "from_": game_presenter.selected_piece.square, + "to": game_presenter.target_square_to_confirm, + }, + ), + "?", + urlencode({"board_id": board_id}), + ) + ), + "data_hx_target": f"#chess-board-pieces-{board_id}", + "data_hx_swap": "outerHTML", + } + htmx_attrs_cancel = { + "data_hx_get": "".join( + ( + reverse( + "lichess_bridge:htmx_game_no_selection", + kwargs={"game_id": game_presenter.game_id}, + ), + "?", + urlencode({"board_id": board_id}), + ) + ), + "data_hx_target": f"#chess-board-pieces-{board_id}", + "data_hx_swap": "outerHTML", + } + + return confirmation_dialog_bar( + question=div( + "Move this ", + b(game_presenter.selected_piece.piece_name), + " from ", + b(game_presenter.selected_piece.square), + " to ", + b(game_presenter.target_square_to_confirm), + "?", + cls="text-center", + ), + htmx_attrs_confirm=htmx_attrs_confirm, + htmx_attrs_cancel=htmx_attrs_cancel, + id_=f"chess-board-lichess-bridge-bar-{board_id}", + htmx_attrs=htmx_attrs, + ) diff --git a/src/apps/lichess_bridge/components/game_creation.py b/src/apps/lichess_bridge/components/game_creation.py new file mode 100644 index 0000000..8a69f14 --- /dev/null +++ b/src/apps/lichess_bridge/components/game_creation.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.urls import reverse +from dominate.tags import div, fieldset, form, input_, label, legend, p + +from apps.lichess_bridge.components.svg_icons import ICON_SVG_CREATE +from apps.lichess_bridge.models import LichessCorrespondenceGameDaysChoice +from apps.webui.components.atoms.buttons import zc_button +from apps.webui.components.forms_common import csrf_hidden_input + +if TYPE_CHECKING: + from django.http import HttpRequest + + +def game_creation_form(*, request: HttpRequest, form_errors: dict) -> form: + return form( + csrf_hidden_input(request), + div( + fieldset( + legend("Days per move:", cls="font-bold"), + ( + p(form_errors["days_per_turn"], cls="text-red-600 ") + if "days_per_turn" in form_errors + else "" + ), + div( + *[ + div( + input_( + id=f"days-per-turn-{value}", + type="radio", + name="days_per_turn", + value=value, + checked=( + value + == LichessCorrespondenceGameDaysChoice.THREE_DAYS.value # type: ignore[attr-defined] + ), + ), + label(display, html_for=f"days-per-turn-{value}"), + cls="w-1/4 flex-none", + ) + for value, display in LichessCorrespondenceGameDaysChoice.choices + ], + cls="flex flex-wrap", + ), + cls="block text-sm font-bold mb-2", + ), + cls="mb-8", + ), + div( + zc_button( + "Create", + svg_icon=ICON_SVG_CREATE, + button_type="action", + html_type="submit", + ), + cls="text-center", + ), + action=reverse("lichess_bridge:create_game"), + method="POST", + ) diff --git a/src/apps/lichess_bridge/components/game_info.py b/src/apps/lichess_bridge/components/game_info.py new file mode 100644 index 0000000..dffd15f --- /dev/null +++ b/src/apps/lichess_bridge/components/game_info.py @@ -0,0 +1,17 @@ +_ONE_DAY = 86_400 +_TWO_DAYS = _ONE_DAY * 2 + + +def time_left_display(time_left_seconds: int) -> str: + # TODO: write a test for this + if time_left_seconds < 1: + return "time's up" + if time_left_seconds < 60: + return f"{time_left_seconds} seconds" + if time_left_seconds < 3600: + return f"{round(time_left_seconds/60)} minutes" + if time_left_seconds < _ONE_DAY: + return f"{round(time_left_seconds/3600)} hours" + if time_left_seconds < _TWO_DAYS: + return f"1 day and {round((time_left_seconds-_ONE_DAY)/3600)} hours" + return f"{round(time_left_seconds/_ONE_DAY)} days" diff --git a/src/apps/lichess_bridge/components/misc_ui/__init__.py b/src/apps/lichess_bridge/components/misc_ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py b/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py new file mode 100644 index 0000000..fd27c90 --- /dev/null +++ b/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.urls import reverse +from dominate.tags import div, form, h3, h4, p, span + +from apps.chess.components.misc_ui import modal_container +from apps.webui.components.atoms.buttons import zc_button +from apps.webui.components.forms_common import csrf_hidden_input + +from ..svg_icons import ICON_SVG_LOG_OUT, ICON_SVG_USER + +if TYPE_CHECKING: + from django.http import HttpRequest + from dominate.tags import dom_tag + + from apps.lichess_bridge.models import LichessAccountInformation + + +def user_profile_modal( + *, request: HttpRequest, me: LichessAccountInformation +) -> dom_tag: + return modal_container( + header=h3( + "Lichess account ", + ICON_SVG_USER, + cls="text-xl", + ), + body=div( + _user_profile_form(request, me), + cls="p-6 space-y-6", + ), + ) + + +def _user_profile_form(request: HttpRequest, me: LichessAccountInformation) -> form: + spacing = "mb-3" + + return form( + csrf_hidden_input(request), + h4( + "Your Lichess account '", + span(me.username, cls="text-yellow-400"), + "' is connected to ZakuChess.", + cls=f"{spacing} text-center font-bold ", + ), + p( + "ZakuChess doesn't store anything related to your Lichess account: " + "this connection only exists in your web browser.", + cls=f"{spacing} text-center", + ), + p( + "If you want to disconnect your Lichess account from Zakuchess, ", + "Use the following button:", + cls=f"{spacing} text-center", + ), + p( + zc_button( + "Disconnect Lichess account", + svg_icon=ICON_SVG_LOG_OUT, + button_type="action", + html_type="submit", + ), + cls=f"{spacing} text-center", + ), + p("You can always reconnect it later 🙂", cls=f"{spacing} text-center"), + action=reverse("lichess_bridge:detach_lichess_account"), + method="POST", + ) diff --git a/src/apps/lichess_bridge/components/no_linked_account.py b/src/apps/lichess_bridge/components/no_linked_account.py new file mode 100644 index 0000000..59ccc11 --- /dev/null +++ b/src/apps/lichess_bridge/components/no_linked_account.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.urls import reverse +from dominate.tags import b, br, div, form, p + +from apps.chess.models import GameFactions +from apps.lichess_bridge.components.svg_icons import ICON_SVG_LOG_IN +from apps.webui.components.atoms.buttons import zc_button +from apps.webui.components.chess_units import unit_display_container +from apps.webui.components.forms_common import csrf_hidden_input + +if TYPE_CHECKING: + from django.http import HttpRequest + from dominate.tags import dom_tag + +_SHOWN_UNITS_FACTIONS = GameFactions(w="humans", b="undeads") + + +def no_linked_account_content(request: HttpRequest) -> dom_tag: + return div( + p( + "You can play games with your friends and other people all around the world on ZakuChess, " + "by linking your Lichess account.", + cls="mb-4 text-center", + ), + p( + "This will allow you to play Lichess games via ZakuChess' boards, " + "where chess pieces are played by pixel art characters 🙂", + cls="mb-4 text-center", + ), + div( + unit_display_container( + piece_role="K", factions=_SHOWN_UNITS_FACTIONS, row_counter=0 + ), + unit_display_container( + piece_role="Q", factions=_SHOWN_UNITS_FACTIONS, row_counter=1 + ), + unit_display_container( + piece_role="N1", + factions=_SHOWN_UNITS_FACTIONS, + row_counter=0, + # We don't have enough space on small screens to display all the units + additional_classes="hidden md:block", + ), + div("VS", cls="grow px-4 text-center"), + unit_display_container( + piece_role="n1", + factions=_SHOWN_UNITS_FACTIONS, + row_counter=0, + # ditto + additional_classes="hidden md:block", + ), + unit_display_container( + piece_role="q", factions=_SHOWN_UNITS_FACTIONS, row_counter=1 + ), + unit_display_container( + piece_role="k", factions=_SHOWN_UNITS_FACTIONS, row_counter=0 + ), + cls="flex justify-center items-center gap-1 md:gap-3", + ), + form( + csrf_hidden_input(request), + p( + b("Click here to log in to Lichess"), + cls="mb-4 text-center font-bold", + ), + p( + zc_button( + "Log in via Lichess", + svg_icon=ICON_SVG_LOG_IN, + html_type="submit", + button_type="action", + ), + cls="mb-4 text-center", + ), + action=reverse("lichess_bridge:oauth2_start_flow"), + method="POST", + cls="my-8", + ), + p( + "You will be able to disconnect your Lichess account from ZakuChess at any time.", + br(), + b( + "None of your Lichess data is stored on our end: it is only stored in your web browser." + ), + cls="mt-8 text-center text-sm", + ), + ) diff --git a/src/apps/lichess_bridge/components/ongoing_games.py b/src/apps/lichess_bridge/components/ongoing_games.py new file mode 100644 index 0000000..4423fcf --- /dev/null +++ b/src/apps/lichess_bridge/components/ongoing_games.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from textwrap import dedent +from typing import TYPE_CHECKING + +from django.urls import reverse +from dominate.tags import a, b, caption, div, script, table, tbody, td, th, thead, tr +from dominate.util import raw + +from apps.chess.chess_helpers import get_turns_counter_from_fen + +from .game_info import time_left_display + +if TYPE_CHECKING: + from dominate.tags import html_tag + + from ..models import LichessOngoingGameData + +_CLICK_ON_TR_SCRIPT = script( + # quick-n-dirty script to allow one to click anywhere on a
+ # to go to the game page + raw( + dedent( + """ + { + function onRowClick(event) { + const row = event.currentTarget; + const link = row.querySelector("a"); + if (link) { + link.click(); + } + } + + function initRowClickBehaviour() { + const rows = document.querySelectorAll("#lichess-ongoing-games tbody tr"); + rows.forEach(row => { + row.addEventListener("click", onRowClick); + row.classList.add("cursor-pointer"); + }); + } + + document.addEventListener("DOMContentLoaded", initRowClickBehaviour); + } + """ + ) + ) +) + + +def lichess_ongoing_games(ongoing_games: list[LichessOngoingGameData]) -> html_tag: + th_classes = "p-2" + + return div( + table( + caption("Correspondence games", cls="mb-2 text-slate-50 "), + thead( + tr( + th("Opponent", cls=th_classes), + th("Moves", cls=th_classes), + th("Time left for next move", cls=th_classes), + th("Turn", cls=th_classes), + cls="bg-rose-900 text-slate-200 font-bold", + ), + ), + tbody( + *[_ongoing_game_row(game) for game in ongoing_games] + if ongoing_games + else tr( + td( + div( + "You have no ongoing games on Lichess at the moment", + cls="italic p-8 text-center", + ), + colspan=4, + ) + ), + ), + id="lichess-ongoing-games", + cls="w-full border-separate border-spacing-0 border border-slate-500 rounded-md", + ), + _CLICK_ON_TR_SCRIPT, + cls="my-4 px-1 ", + ) + + +def _ongoing_game_row(game: LichessOngoingGameData) -> tr: + td_classes = "border border-slate-300 dark:border-slate-700 p-1 text-slate-500 dark:text-slate-400" + return tr( + td( + a( + game.opponent.username, + href=reverse( + "lichess_bridge:correspondence_game", + kwargs={"game_id": game.gameId}, + ), + cls="font-bold underline", + ), + cls=td_classes, + ), + td(get_turns_counter_from_fen(game.fen), cls=f"{td_classes} text-right"), + td(time_left_display(game.secondsLeft), cls=f"{td_classes} text-right"), + td( + b("Mine", cls="text-slate-50") if game.isMyTurn else "Theirs", + cls=f"{td_classes} text-right", + ), + ) diff --git a/src/apps/lichess_bridge/components/pages/__init__.py b/src/apps/lichess_bridge/components/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/lichess_bridge/components/pages/lichess_pages.py b/src/apps/lichess_bridge/components/pages/lichess_pages.py new file mode 100644 index 0000000..271ab4c --- /dev/null +++ b/src/apps/lichess_bridge/components/pages/lichess_pages.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict + +from django.conf import settings +from django.urls import reverse +from dominate.tags import ( + div, + h3, + p, + section, + span, +) + +from apps.chess.components.chess_board import ( + chess_arena, + chess_available_targets, + chess_last_move, + chess_pieces, +) +from apps.chess.components.misc_ui import speech_bubble_container +from apps.webui.components.atoms.buttons import zc_button, zc_header_icon_button +from apps.webui.components.layout import page +from apps.webui.components.misc_ui.user_prefs_modal import user_prefs_button + +from ..companion_bars.top_companion_bar import lichess_bridge_bar +from ..game_creation import game_creation_form +from ..no_linked_account import no_linked_account_content +from ..ongoing_games import lichess_ongoing_games +from ..svg_icons import ICON_SVG_USER + +if TYPE_CHECKING: + from django.http import HttpRequest + from dominate.tags import dom_tag + + from ...models import ( + LichessAccountInformation, + LichessOngoingGameData, + ) + from ...presenters import LichessCorrespondenceGamePresenter + +_PAGE_TITLE_CSS = "mb-8 text-center font-bold text-yellow-400" +_NON_GAME_PAGE_MAIN_SECTION_BASE_CSS = ( + "w-full mx-auto py-4 bg-slate-900 text-slate-50 min-h-48 md:max-w-3xl" +) +_NON_GAME_PAGE_SECTION_INNER_CONTAINER_CSS = "px-8 pb-8 md:px-0 md:w-8/12 md:mx-auto" + + +def lichess_no_account_linked_page( + *, + request: HttpRequest, +) -> str: + return page( + section( + div( + h3( + "Play games on ZakuChess with your Lichess account", + cls=_PAGE_TITLE_CSS, + ), + no_linked_account_content(request), + cls=_NON_GAME_PAGE_SECTION_INNER_CONTAINER_CSS, + ), + cls=_NON_GAME_PAGE_MAIN_SECTION_BASE_CSS, + ), + request=request, + title="Lichess - no account linked", + **_get_page_header_buttons(lichess_profile_linked=False), + ) + + +def lichess_my_current_games_list_page( + *, + request: HttpRequest, + me: LichessAccountInformation, + ongoing_games: list[LichessOngoingGameData], +) -> str: + return page( + section( + div( + h3( + "Your ongoing games on Lichess", + cls=_PAGE_TITLE_CSS, + ), + lichess_ongoing_games(ongoing_games), + p( + zc_button( + "Create a new game", + href=reverse("lichess_bridge:create_game"), + button_type="action", + ), + cls="my-8 text-center text-slate-50", + ), + _lichess_account_footer(me), + cls=_NON_GAME_PAGE_SECTION_INNER_CONTAINER_CSS, + ), + cls=_NON_GAME_PAGE_MAIN_SECTION_BASE_CSS, + ), + request=request, + title="Lichess - account linked", + **_get_page_header_buttons(lichess_profile_linked=True), + ) + + +def lichess_correspondence_game_creation_page( + request: HttpRequest, + *, + me: LichessAccountInformation, + form_errors: dict, +) -> str: + return page( + section( + div( + h3( + "New correspondence game, via Lichess", + cls=_PAGE_TITLE_CSS, + ), + game_creation_form(request=request, form_errors=form_errors), + _lichess_account_footer(me), + cls=_NON_GAME_PAGE_SECTION_INNER_CONTAINER_CSS, + ), + cls=_NON_GAME_PAGE_MAIN_SECTION_BASE_CSS, + ), + request=request, + title="Lichess - new correspondence game", + **_get_page_header_buttons(lichess_profile_linked=True), + ) + + +def lichess_correspondence_game_page( + *, + request: HttpRequest, + me: LichessAccountInformation, + game_presenter: LichessCorrespondenceGamePresenter, +) -> str: + return page( + chess_arena( + game_presenter=game_presenter, + companion_bars={ + "top": lichess_bridge_bar( + game_presenter=game_presenter, board_id="main" + ), + }, + board_id="main", + ), + _lichess_account_footer(me), + request=request, + title=f"Lichess - correspondence game {game_presenter.game_id}", + **_get_page_header_buttons(lichess_profile_linked=True), + ) + + +def lichess_game_moving_parts_fragment( + *, + game_presenter: LichessCorrespondenceGamePresenter, + request: HttpRequest, + board_id: str, +) -> str: + return "\n".join( + dom_tag.render(pretty=settings.DEBUG) + for dom_tag in ( + chess_pieces( + game_presenter=game_presenter, + board_id=board_id, + ), + chess_available_targets( + game_presenter=game_presenter, + board_id=board_id, + data_hx_swap_oob="outerHTML", + ), + ( + chess_last_move( + game_presenter=game_presenter, + board_id=board_id, + data_hx_swap_oob="outerHTML", + ) + if game_presenter.refresh_last_move + else div("") + ), + lichess_bridge_bar( + game_presenter=game_presenter, + board_id=board_id, + htmx_attrs={"data_hx_swap_oob": "outerHTML"}, + ), + div( + speech_bubble_container( + game_presenter=game_presenter, + board_id=board_id, + ), + id=f"chess-speech-container-{board_id}", + data_hx_swap_oob="innerHTML", + ), + ) + ) + + +def _lichess_account_footer(me: LichessAccountInformation) -> dom_tag: + return div( + p( + "Your Lichess account: ", + span(me.username, cls="text-yellow-400"), + cls="w-9/12 mx-auto mt-8 mb-4 text-slate-50 text-center text-sm", + ), + p( + "You can disconnect your Lichess account from ZakuChess at any time " + "in your 'user account' ", + ICON_SVG_USER, + " settings, accessible from the top menu.", + cls="w-9/12 mx-auto text-slate-50 text-center text-sm", + ), + ) + + +class _PageHeaderButtons(TypedDict): + left_side_buttons: list[dom_tag] + right_side_buttons: list[dom_tag] + + +def _get_page_header_buttons(lichess_profile_linked: bool) -> _PageHeaderButtons: + return _PageHeaderButtons( + left_side_buttons=[_user_account_button()] if lichess_profile_linked else [], + right_side_buttons=[user_prefs_button()], + ) + + +def _user_account_button() -> dom_tag: + htmx_attributes = { + "data_hx_get": reverse("lichess_bridge:htmx_modal_user_account"), + "data_hx_target": "#modals-container", + "data_hx_swap": "outerHTML", + } + + return zc_header_icon_button( + icon=ICON_SVG_USER, + title="Manage your Lichess account", + id_="stats-button", + htmx_attributes=htmx_attributes, + ) diff --git a/src/apps/lichess_bridge/components/svg_icons.py b/src/apps/lichess_bridge/components/svg_icons.py new file mode 100644 index 0000000..8875405 --- /dev/null +++ b/src/apps/lichess_bridge/components/svg_icons.py @@ -0,0 +1,27 @@ +from dominate.util import raw + +# https://heroicons.com/, icon `user` +ICON_SVG_LOG_IN = raw( + r""" + + """ +) + +# https://heroicons.com/, icon `arrow-left-start-on-rectangle` +ICON_SVG_LOG_OUT = raw( + r""" + + """ +) + +# https://heroicons.com/, icon `plus-circle` +ICON_SVG_CREATE = raw( + r""" + + """ +) +ICON_SVG_USER = raw( + r""" + + """ +) diff --git a/src/apps/lichess_bridge/cookie_helpers.py b/src/apps/lichess_bridge/cookie_helpers.py new file mode 100644 index 0000000..01eb57c --- /dev/null +++ b/src/apps/lichess_bridge/cookie_helpers.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import datetime as dt +import logging +from typing import TYPE_CHECKING, cast + +from django.core.exceptions import SuspiciousOperation +from msgspec import MsgspecError + +from lib.http_cookies_helpers import ( + HttpCookieAttributes, + set_http_cookie_on_django_response, +) + +from .authentication import LichessTokenRetrievalProcessContext +from .lichess_api import is_lichess_api_access_token_valid + +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse + + from .authentication import LichessToken + from .models import LichessAccessToken + +_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_ATTRS = HttpCookieAttributes( + name="lichess.oauth2.ctx", + # This cookie only has to be valid while the user is redirected to Lichess + # and press the "Authorize" button there. + max_age=dt.timedelta(hours=1), + http_only=True, + same_site="Lax", +) + +_API_ACCESS_TOKEN_COOKIE_ATTRS = HttpCookieAttributes( + name="lichess.access_token", + # Access tokens delivered by Lichess "are long-lived (expect one year)". + # As Lichess gives us the expiry date of the tokens it gives us, we can use that + # for our own cookie - so no "max-age" entry here, but we'll specify one at runtime. + max_age=None, + http_only=True, + same_site="Lax", +) + + +_logger = logging.getLogger(__name__) + + +def store_oauth2_token_retrieval_context_in_response_cookie( + *, context: LichessTokenRetrievalProcessContext, response: HttpResponse +) -> None: + """ + Store OAuth2 token retrieval context into a short-lived response cookie. + """ + + set_http_cookie_on_django_response( + response=response, + attributes=_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_ATTRS, + value=context.to_cookie_content(), + ) + + +def get_oauth2_token_retrieval_context_from_request( + request: HttpRequest, +) -> LichessTokenRetrievalProcessContext | None: + """ + Returns a context created from the "CSRF state" and "code verifier" found in the request's cookies. + """ + cookie_content: str | None = request.COOKIES.get( + _OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_ATTRS.name + ) + if not cookie_content: + return None + + try: + context = LichessTokenRetrievalProcessContext.from_cookie_content( + cookie_content, + zakuchess_hostname=request.get_host(), + zakuchess_protocol=request.scheme, + ) + return context + except MsgspecError: + _logger.exception("Could not decode cookie content.") + return None + + +def delete_oauth2_token_retrieval_context_from_cookies( + response: HttpResponse, +) -> None: + response.delete_cookie(_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_ATTRS.name) + + +def store_lichess_api_access_token_in_response_cookie( + *, token: LichessToken, response: HttpResponse +) -> None: + """ + Store a Lichess API token into a long-lived response cookie. + """ + # TODO: use a secured cookie here? + + # Our cookie will expire when the access token given by Lichess will: + cookie_attributes = _API_ACCESS_TOKEN_COOKIE_ATTRS._replace( + max_age=dt.timedelta(seconds=token.expires_in), + ) + + set_http_cookie_on_django_response( + response=response, + attributes=cookie_attributes, + value=token.access_token, + ) + + +def get_lichess_api_access_token_from_request( + request: HttpRequest, +) -> LichessAccessToken | None: + """ + Returns a Lichess API token found in the request's cookies. + """ + cookie_content: str | None = request.COOKIES.get( + _API_ACCESS_TOKEN_COOKIE_ATTRS.name + ) + if not cookie_content: + return None + + if not is_lichess_api_access_token_valid(cookie_content): + raise SuspiciousOperation( + f"Suspicious Lichess API token value '{cookie_content}'" + ) + + return cast("LichessAccessToken", cookie_content) + + +def delete_lichess_api_access_token_from_cookies( + response: HttpResponse, +) -> None: + response.delete_cookie(_API_ACCESS_TOKEN_COOKIE_ATTRS.name) diff --git a/src/apps/lichess_bridge/forms.py b/src/apps/lichess_bridge/forms.py new file mode 100644 index 0000000..67cf9e5 --- /dev/null +++ b/src/apps/lichess_bridge/forms.py @@ -0,0 +1,9 @@ +from django import forms + +from .models import LichessCorrespondenceGameDaysChoice + + +class LichessCorrespondenceGameCreationForm(forms.Form): + days_per_turn = forms.ChoiceField( + choices=LichessCorrespondenceGameDaysChoice.choices + ) diff --git a/src/apps/lichess_bridge/lichess_api.py b/src/apps/lichess_bridge/lichess_api.py new file mode 100644 index 0000000..1f541d7 --- /dev/null +++ b/src/apps/lichess_bridge/lichess_api.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +import asyncio +import contextlib +import datetime as dt +import logging +import time +from typing import TYPE_CHECKING +from zlib import adler32 + +import httpx +import msgspec +from django.conf import settings +from django.core.cache import cache + +from .models import ( + LICHESS_ACCESS_TOKEN_PREFIX, + LichessAccountInformation, + LichessGameExport, + LichessGameFullFromStream, + LichessOngoingGameData, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + + from apps.chess.types import Square + + from .models import LichessAccessToken, LichessGameId, LichessGameSeekId + +_logger = logging.getLogger(__name__) + + +_GET_MY_ACCOUNT_CACHE = { + "KEY_PATTERN": "lichess_bridge::get_my_account::{lichess_access_token_hash}", + "DURATION": dt.timedelta(minutes=30).total_seconds(), +} + +_GET_GAME_BY_ID_FROM_STREAM_CACHE = { + "KEY_PATTERN": "lichess_bridge::get_game_by_id_from_stream::{game_id}", + "DURATION": dt.timedelta(seconds=30).total_seconds(), +} + +_GET_EXPORT_BY_ID_CACHE = { + "KEY_PATTERN": "lichess_bridge::get_game_export_by_id::{game_id}", + "DURATION": dt.timedelta(seconds=30).total_seconds(), +} + + +def is_lichess_api_access_token_valid(token: str) -> bool: + return token.startswith(LICHESS_ACCESS_TOKEN_PREFIX) and len(token) > 10 + + +async def get_my_account(*, api_client: httpx.AsyncClient) -> LichessAccountInformation: + """ + This is cached for a short amount of time. + """ + # Let's not expose any access tokens in our cache keys, and instead use a quick hash + # of the "Authorization" header (which contains the token). + # "An Adler-32 checksum is almost as reliable as a CRC32 but can be computed much more quickly." + # --> should be enough for our case :-) + lichess_access_token_hash = adler32(api_client.headers["Authorization"].encode()) + + cache_key = _GET_MY_ACCOUNT_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] + lichess_access_token_hash=lichess_access_token_hash + ) + if cached_data := await cache.aget(cache_key): + _logger.info("Using cached data for 'get_my_account'.") + response_content = cached_data + else: + # https://lichess.org/api#tag/Account/operation/accountMe + endpoint = "/api/account" + with _lichess_api_monitoring("GET", endpoint): + response = await api_client.get(endpoint) + response.raise_for_status() + + response_content = response.content + await cache.aset(cache_key, response_content, _GET_MY_ACCOUNT_CACHE["DURATION"]) + + return msgspec.json.decode(response_content, type=LichessAccountInformation) + + +async def get_my_ongoing_games( + *, + api_client: httpx.AsyncClient, + count: int = 5, +) -> list[LichessOngoingGameData]: + # https://lichess.org/api#tag/Games/operation/apiAccountPlaying + endpoint = "/api/account/playing" + with _lichess_api_monitoring("GET", endpoint): + response = await api_client.get(endpoint, params={"nb": count}) + response.raise_for_status() + + class ResponseDataWrapper(msgspec.Struct): + """The ongoing games are wrapped in a "nowPlaying" root object's key""" + + nowPlaying: list[LichessOngoingGameData] + + return msgspec.json.decode(response.content, type=ResponseDataWrapper).nowPlaying + + +async def get_game_export_by_id( + *, + api_client: httpx.AsyncClient, + game_id: LichessGameId, + try_fetching_from_cache: bool = True, +) -> LichessGameExport: + """ + This is cached for a short amount of time, so we don't re-fetch the same games again + while the player is selecting pieces. + """ + cache_key = _GET_EXPORT_BY_ID_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] + game_id=game_id, + ) + + response_content: bytes | None = None + if try_fetching_from_cache: + if cached_data := await cache.aget(cache_key): + _logger.info("Using cached data for 'get_game_export_by_id'.") + response_content = cached_data + + if not response_content: + # https://lichess.org/api#tag/Games/operation/gamePgn + # An important aspect to keep in mind: + # > Ongoing games are delayed by a few seconds ranging from 3 to 60 + # > depending on the time control, as to prevent cheat bots from using this API. + endpoint = f"/game/export/{game_id}" + with _lichess_api_monitoring("GET", endpoint): + # We only need the FEN, but it seems that the Lichess "game by ID" API endpoints + # can only return the full PGN - which will require a bit more work to parse. + response = await api_client.get( + endpoint, + params={"pgnInJson": "1", "tags": "0", "moves": "0", "evals": "0"}, + ) + response.raise_for_status() + + response_content = response.content + await cache.aset( + cache_key, response_content, _GET_EXPORT_BY_ID_CACHE["DURATION"] + ) + + return msgspec.json.decode(response_content, type=LichessGameExport) + + +async def get_game_by_id_from_stream( + *, + api_client: httpx.AsyncClient, + game_id: LichessGameId, + try_fetching_from_cache: bool = True, +) -> LichessGameFullFromStream: + """ + This is cached for a short amount of time, so we don't re-fetch the same games again + while the player is selecting pieces. + """ + cache_key = _GET_GAME_BY_ID_FROM_STREAM_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] + game_id=game_id, + ) + + response_content: str | None = None + if try_fetching_from_cache: + if cached_data := await cache.aget(cache_key): + _logger.info("Using cached data for 'get_game_by_id_from_stream'.") + response_content = cached_data + + if not response_content: + # https://lichess.org/api#tag/Board/operation/boardGameStream + endpoint = f"/api/board/game/stream/{game_id}" + with _lichess_api_monitoring("GET (stream)", endpoint): + async with api_client.stream("GET", endpoint) as response: + async for line in response.aiter_lines(): + if line and '"gameFull"' in line: + response_content = line + # We got what we need, let's break that loop - HTTPX will + # automatically close the stream as we exit the `.stream` block. + break + response.raise_for_status() + + await cache.aset( + cache_key, response_content, _GET_GAME_BY_ID_FROM_STREAM_CACHE["DURATION"] + ) + + assert type(response_content) == str # for type checkers + return msgspec.json.decode(response_content, type=LichessGameFullFromStream) + + +async def clear_game_by_id_cache(game_id: LichessGameId) -> None: + """ + Clear the cached data of `get_game_export_by_id` and `get_game_by_id_from_stream` for + a given game ID. + """ + get_game_export_by_id_cache_key = _GET_EXPORT_BY_ID_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] + game_id=game_id, + ) + get_game_by_id_from_stream_cache_key = _GET_GAME_BY_ID_FROM_STREAM_CACHE[ + "KEY_PATTERN" + ].format( # type: ignore[attr-defined] + game_id=game_id, + ) + async with asyncio.TaskGroup() as tg: + tg.create_task(cache.adelete(get_game_export_by_id_cache_key)) + tg.create_task(cache.adelete(get_game_by_id_from_stream_cache_key)) + + +async def move_lichess_game_piece( + *, + api_client: httpx.AsyncClient, + game_id: LichessGameId, + from_: Square, + to: Square, + offering_draw: bool = False, +) -> bool: + """ + Calling this function will make a move in a Lichess game. + As a side effect, it will also clear the cached data of `get_game_export_by_id` + and `get_game_by_id_from_stream` for that game. + """ + # https://lichess.org/api#tag/Board/operation/boardGameMove + move_uci = f"{from_}{to}" + endpoint = f"/api/board/game/{game_id}/move/{move_uci}" + with _lichess_api_monitoring("POST", endpoint): + response = await api_client.post( + endpoint, params={"offeringDraw": "true"} if offering_draw else None + ) + response.raise_for_status() + + await clear_game_by_id_cache(game_id) + + return response.json()["ok"] + + +async def create_correspondence_game( + *, api_client: httpx.AsyncClient, days_per_turn: int +) -> LichessGameSeekId: + # https://lichess.org/api#tag/Board/operation/apiBoardSeek + # TODO: give more customisation options to the user + endpoint = "/api/board/seek" + with _lichess_api_monitoring("POST", endpoint): + response = await api_client.post( + endpoint, + json={ + "rated": False, + "days": days_per_turn, + "variant": "standard", + "color": "random", + }, + ) + response.raise_for_status() + + return str(response.json()["id"]) + + +def get_lichess_api_client(access_token: LichessAccessToken) -> httpx.AsyncClient: + return _create_lichess_api_client(access_token) + + +@contextlib.contextmanager +def _lichess_api_monitoring(method, target_endpoint) -> Iterator[None]: + start_time = time.monotonic() + yield + _logger.info( + "Lichess API: %s '%s' took %i ms.", + method, + target_endpoint, + (time.monotonic() - start_time) * 1000, + ) + + +# This is the function we'll mock during tests - as it's private, we don't have to +# mind about it being directly imported by other modules when we mock it. +def _create_lichess_api_client(access_token: LichessAccessToken) -> httpx.AsyncClient: + client = httpx.AsyncClient( + base_url=settings.LICHESS_HOST, + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + }, + ) + + return client diff --git a/src/apps/lichess_bridge/models.py b/src/apps/lichess_bridge/models.py new file mode 100644 index 0000000..7c14a9a --- /dev/null +++ b/src/apps/lichess_bridge/models.py @@ -0,0 +1,490 @@ +from __future__ import annotations + +import dataclasses +import functools +import io +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Literal, NamedTuple, TypeAlias + +import chess.pgn +import msgspec +from django.db import models + +from apps.chess.models import GameFactions +from apps.chess.types import FEN + +from .business_logic import rebuild_game_from_moves, rebuild_game_from_pgn + +if TYPE_CHECKING: + from collections.abc import Sequence + + import chess + + from apps.chess.models import GameTeams + from apps.chess.types import ( + BoardOrientation, + Faction, + PieceRoleBySquare, + PlayerSide, + UCIMove, + ) + + from .business_logic import RebuildGameFromMovesResult, RebuildGameFromPgnResult + +LichessAccessToken: TypeAlias = str # e.g. "lio_6EeGimHMalSVH9qMcfUc2JJ3xdBPlqrL" + +LichessPlayerId: TypeAlias = str # e.g. "dunsap" +LichessPlayerFullId: TypeAlias = str # e.g. "Dunsap" + +LichessGameSeekId: TypeAlias = str # e.g. "oIsGhJaf" + +LichessGameId: TypeAlias = str # e.g. "tFfGsEpb" (always 8 chars) +LichessGameFullId: TypeAlias = str # e.g. "tFfGsEpbd0mL" (always 12 chars?) + +# > By convention tokens have a recognizable prefix, but do not rely on this. +# Well... Let's still rely on this for now ^^ +# TODO: remove this, as it may break one day? +LICHESS_ACCESS_TOKEN_PREFIX = "lio_" + + +# The values of these enums can be found in the OpenAPI spec one can download +# by clicking the "Download" button at the top of this page: +# https://lichess.org/api +# (for some reason the JSON file is not directly linkable) +LichessChessVariant = Literal[ + # we only support "standard" for now 😅 + # But as python-chess supports many variants, we could support more in the future. + # (https://python-chess.readthedocs.io/en/latest/variant.html) + "standard", + "chess960", + "crazyhouse", + "antichess", + "atomic", + "horde", + "kingOfTheHill", + "racingKings", + "threeCheck", + "fromPosition", +] +LichessPlayerSide = Literal["white", "black"] +LichessGameSource = Literal["lobby", "friend"] # TODO: other values? +LichessGameSpeed = Literal[ + "ultraBullet", "bullet", "blitz", "rapid", "classical", "correspondence" +] +LichessGamePerf = Literal[ + "ultraBullet", + "bullet", + "blitz", + "rapid", + "classical", + "correspondence", + "chess960", + "crazyhouse", + "antichess", + "atomic", + "horde", + "kingOfTheHill", + "racingKings", + "threeCheck", +] +LichessGameStatus = Literal[ + "created", + "started", + "aborted", + "mate", + "resign", + "stalemate", + "timeout", + "draw", + "outoftime", + "cheat", + "noStart", + "unknownFinish", + "variantEnd", +] + +# For now we hard-code the fact that "me" always plays the "humans" faction, +# and "them" always plays the "undeads" faction. +_FACTIONS_BY_BOARD_ORIENTATION: dict[BoardOrientation, GameFactions] = { + "1->8": GameFactions(w="humans", b="undeads"), + "8->1": GameFactions(w="undeads", b="humans"), +} + +# Presenters are the objects we pass to our templates. +_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING: dict[LichessPlayerSide, PlayerSide] = { + "white": "w", + "black": "b", +} + + +class LichessCorrespondenceGameDaysChoice(models.IntegerChoices): + # https://lichess.org/api#tag/Board/operation/apiBoardSeek + ONE_DAY = (1, "1 days") + TWO_DAYS = (2, "2 days") + THREE_DAYS = (3, "3 days") + FIVE_DAYS = (5, "5 days") + SEVEN_DAYS = (7, "7 days") + TEN_DAYS = (10, "10 days") + FOURTEEN_DAYS = (14, "14 days") + + +class LichessAccountInformation( + msgspec.Struct, +): + """Information about an account, as returned by the Lichess API.""" + + # N.B. There are many more fields than this - but we only use these at the moment + + id: LichessPlayerId + username: LichessPlayerFullId + url: str # e.g. "https://lichess.org/@/dunsap" + + +class LichessOpponentData(msgspec.Struct): + """Information about an opponent, as returned by the Lichess API.""" + + id: LichessPlayerId + username: LichessPlayerFullId + rating: int # e.g. 1790 + + +class LichessOngoingGameData(msgspec.Struct): + """Information about an ongoing game, as returned by the Lichess API.""" + + gameId: LichessGameId + fullId: LichessGameFullId + color: LichessPlayerSide + fen: FEN + hasMoved: bool + isMyTurn: bool + lastMove: str # e.g. "b8c6" + opponent: LichessOpponentData + perf: LichessGamePerf + rated: bool + secondsLeft: int + source: LichessGameSource + speed: LichessGameSpeed + variant: dict[str, str] + + +class LichessGameUser(msgspec.Struct): + id: LichessPlayerId + name: LichessGameFullId + + +class LichessGameOpening(msgspec.Struct): + eco: str # e.g. "B01" + name: str # e.g. "Scandinavian Defense" + ply: int # e.g. 2 + + +class LichessGamePlayer(msgspec.Struct): + user: LichessGameUser + rating: int + provisional: bool = False + + +class LichessGamePlayers(msgspec.Struct): + white: LichessGamePlayer + black: LichessGamePlayer + + +class LichessGameExport(msgspec.Struct): + """ + Information about a game as given by an "export game" endpoint, + as returned by the Lichess API. + """ + + id: LichessGameId + fullId: LichessGameFullId + rated: bool + variant: str + speed: LichessGameSpeed + perf: LichessGamePerf + createdAt: int + lastMoveAt: int + status: LichessGameStatus + source: LichessGameSource + players: LichessGamePlayers + opening: LichessGameOpening + moves: str + pgn: str + daysPerTurn: int + division: dict # ??? + + +class LichessVariantStruct(msgspec.Struct): + key: str # e.g. "standard" + name: str # e.g. "Standard" + short: str # e.g. "Std" + + +class LichessPerfStruct(msgspec.Struct): + name: str # Translated perf name (e.g. "Correspondence", "Classical" or "Blitz") + + +class LichessGameEventPlayer(msgspec.Struct): + id: LichessPlayerId + name: LichessGameFullId + rating: int + title: str | None = None # ?? + provisional: bool | None = None + + +class LichessGameState(msgspec.Struct): + type: str # e.g. "gameState" + moves: str # e.g. "e2e4 d7d5 f2f3 e7e5 f1d3 f8c5 b2b3" + wtime: int # e.g. 259200000 + btime: int # e.g. 255151000 + winc: int # e.g. 0 + binc: int # e.g. 0 + status: LichessGameStatus + + +class LichessGameFullFromStream(msgspec.Struct): + id: str + variant: LichessVariantStruct + speed: str + perf: LichessPerfStruct + rated: bool + createdAt: int + white: LichessGameEventPlayer + black: LichessGameEventPlayer + initialFen: FEN | Literal["startpos"] + daysPerTurn: int + type: Literal["gameFull"] + state: LichessGameState + + @property + def is_ongoing_game(self) -> bool: + return self.state.status == "started" + + +class LichessGameWithMetadataBase(ABC): + @property + @abstractmethod + def chess_board(self) -> chess.Board: ... + + @property + @abstractmethod + def moves(self) -> Sequence[UCIMove]: ... + + @property + @abstractmethod + def piece_role_by_square(self) -> PieceRoleBySquare: ... + + @property + @abstractmethod + def teams(self) -> GameTeams: ... + + @functools.cached_property + def active_player_side(self) -> LichessPlayerSide: + return "white" if self.chess_board.turn else "black" + + @property + @abstractmethod + def players_from_my_perspective(self) -> LichessGameMetadataPlayers: ... + + @functools.cached_property + def board_orientation(self) -> BoardOrientation: + return self._players_sides.board_orientation + + @functools.cached_property + def game_factions(self) -> GameFactions: + return _FACTIONS_BY_BOARD_ORIENTATION[self.board_orientation] + + @property + @abstractmethod + def _players_sides(self) -> LichessGameMetadataPlayerSides: ... + + +@dataclasses.dataclass(frozen=True) +class LichessGameFullFromStreamWithMetadata(LichessGameWithMetadataBase): + """ + Wraps a LichessGameFullFromStream object with some additional metadata related to the + current player, and some cached properties describing the state of the game. + """ + + raw_data: LichessGameFullFromStream + my_player_id: LichessPlayerId + + @functools.cached_property + def chess_board(self) -> chess.Board: + return self._rebuilt_game.chess_board + + @functools.cached_property + def moves(self) -> Sequence[UCIMove]: + return self._rebuilt_game.moves + + @functools.cached_property + def piece_role_by_square(self) -> PieceRoleBySquare: + return self._rebuilt_game.piece_role_by_square + + @functools.cached_property + def teams(self) -> GameTeams: + return self._rebuilt_game.teams + + @functools.cached_property + def active_player_side(self) -> LichessPlayerSide: + return "white" if self.chess_board.turn else "black" + + @functools.cached_property + def players_from_my_perspective(self) -> LichessGameMetadataPlayers: + my_side, their_side, _ = self._players_sides + + my_player: LichessGameEventPlayer = getattr(self.raw_data, my_side) + their_player: LichessGameEventPlayer = getattr(self.raw_data, their_side) + + result = LichessGameMetadataPlayers( + me=LichessGameMetadataPlayer( + id=my_player.id, + username=my_player.name, + player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[my_side], + faction="humans", + ), + them=LichessGameMetadataPlayer( + id=their_player.id, + username=their_player.name, + player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[their_side], + faction="undeads", + ), + active_player="me" if self.active_player_side == my_side else "them", + ) + + return result + + @functools.cached_property + def _players_sides(self) -> LichessGameMetadataPlayerSides: + my_side: LichessPlayerSide = ( + "white" if self.raw_data.white.id == self.my_player_id else "black" + ) + their_side: LichessPlayerSide = "black" if my_side == "white" else "white" + board_orientation: BoardOrientation = "1->8" if my_side == "white" else "8->1" + + return LichessGameMetadataPlayerSides( + me=my_side, + them=their_side, + board_orientation=board_orientation, + ) + + @functools.cached_property + def _rebuilt_game(self) -> RebuildGameFromMovesResult: + moves_str = self.raw_data.state.moves.strip() + + return rebuild_game_from_moves( + uci_moves=moves_str.split(" ") if moves_str else [], + factions=self.game_factions, + ) + + +@dataclasses.dataclass(frozen=True) +class LichessGameExportWithMetadata(LichessGameWithMetadataBase): + """ + Wraps a LichessGameExport object with some additional metadata related to the + current player, and some cached properties describing the state of the game. + """ + + raw_data: LichessGameExport + my_player_id: LichessPlayerId + + @functools.cached_property + def pgn_game(self) -> chess.pgn.Game: + pgn_game = chess.pgn.read_game(io.StringIO(self.raw_data.pgn)) + if not pgn_game: + raise ValueError("Could not read PGN game") + return pgn_game + + @functools.cached_property + def chess_board(self) -> chess.Board: + return self._rebuilt_game.chess_board + + @functools.cached_property + def moves(self) -> Sequence[UCIMove]: + return self._rebuilt_game.moves + + @functools.cached_property + def piece_role_by_square(self) -> PieceRoleBySquare: + return self._rebuilt_game.piece_role_by_square + + @functools.cached_property + def teams(self) -> GameTeams: + return self._rebuilt_game.teams + + @functools.cached_property + def active_player_side(self) -> LichessPlayerSide: + return "white" if self.chess_board.turn else "black" + + @functools.cached_property + def players_from_my_perspective(self) -> LichessGameMetadataPlayers: + my_side, their_side, _ = self._players_sides + + my_player: LichessGameUser = getattr(self.raw_data.players, my_side).user + their_player: LichessGameUser = getattr(self.raw_data.players, their_side).user + + result = LichessGameMetadataPlayers( + me=LichessGameMetadataPlayer( + id=my_player.id, + username=my_player.name, + player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[my_side], + faction="humans", + ), + them=LichessGameMetadataPlayer( + id=their_player.id, + username=their_player.name, + player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[their_side], + faction="undeads", + ), + active_player="me" if self.active_player_side == my_side else "them", + ) + + return result + + @functools.cached_property + def _players_sides(self) -> LichessGameMetadataPlayerSides: + my_side: LichessPlayerSide = ( + "white" + if self.raw_data.players.white.user.id == self.my_player_id + else "black" + ) + their_side: LichessPlayerSide = "black" if my_side == "white" else "white" + board_orientation: BoardOrientation = "1->8" if my_side == "white" else "8->1" + + return LichessGameMetadataPlayerSides( + me=my_side, + them=their_side, + board_orientation=board_orientation, + ) + + @functools.cached_property + def _rebuilt_game(self) -> RebuildGameFromPgnResult: + return rebuild_game_from_pgn( + pgn_game=self.pgn_game, factions=self.game_factions + ) + + +class LichessGameMetadataPlayerSides(NamedTuple): + me: LichessPlayerSide + them: LichessPlayerSide + board_orientation: BoardOrientation + + +class LichessGameMetadataPlayer(NamedTuple): + id: LichessPlayerId + username: LichessGameFullId + player_side: PlayerSide + faction: Faction + + +class LichessGameMetadataPlayers(NamedTuple): + """ + Information about the players of a game, structured in a "me" and "them" way, and + giving us the "active player" as well. + + (as opposed to the "players" field in the LichessGameExport class, which tells us + who the "white" and "black" players are but without telling us which one is "me", + or which one is the current active player) + """ + + me: LichessGameMetadataPlayer + them: LichessGameMetadataPlayer + active_player: Literal["me", "them"] diff --git a/src/apps/lichess_bridge/presenters.py b/src/apps/lichess_bridge/presenters.py new file mode 100644 index 0000000..674cedc --- /dev/null +++ b/src/apps/lichess_bridge/presenters.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from functools import cached_property +from typing import TYPE_CHECKING, cast +from urllib.parse import urlencode + +from django.urls import reverse + +from apps.chess.chess_helpers import uci_move_squares +from apps.chess.presenters import GamePresenter, GamePresenterUrls + +if TYPE_CHECKING: + from apps.chess.models import GameFactions, UserPrefs + from apps.chess.presenters import SpeechBubbleData + from apps.chess.types import ( + FEN, + BoardOrientation, + GamePhase, + PlayerSide, + Square, + ) + + from .models import LichessGameFullFromStreamWithMetadata + + +class LichessCorrespondenceGamePresenter(GamePresenter): + def __init__( + self, + *, + game_data: LichessGameFullFromStreamWithMetadata, + refresh_last_move: bool, + is_htmx_request: bool, + target_square_to_confirm: Square | None = None, + selected_piece_square: Square | None = None, + user_prefs: UserPrefs | None = None, + ): + self._game_data = game_data + + self._chess_board = game_data.chess_board + fen = cast("FEN", self._chess_board.fen()) + + if self._game_data.moves: + last_move = uci_move_squares(self._game_data.moves[-1]) + else: + last_move = None + + super().__init__( + fen=fen, + piece_role_by_square=game_data.piece_role_by_square, + teams=game_data.teams, + refresh_last_move=refresh_last_move, + target_square_to_confirm=target_square_to_confirm, + is_htmx_request=is_htmx_request, + selected_piece_square=selected_piece_square, + last_move=last_move, + user_prefs=user_prefs, + ) + + @cached_property + def board_orientation(self) -> BoardOrientation: + return self._game_data.board_orientation + + @cached_property + def urls(self) -> GamePresenterUrls: + return LichessCorrespondenceGamePresenterUrls(game_presenter=self) + + @property + def moves_must_be_confirmed(self) -> bool: + # TODO: make this dynamic, via a user setting? + return True + + @cached_property + def is_my_turn(self) -> bool: + return self._game_data.players_from_my_perspective.active_player == "me" + + @cached_property + def my_side(self) -> PlayerSide | None: + return self._game_data.players_from_my_perspective.me.player_side + + @cached_property + def game_phase(self) -> GamePhase: + # TODO: manage "game over" situations + if self.is_my_turn: + if self.selected_piece is None: + return "waiting_for_player_selection" + if self.selected_piece.target_to_confirm is None: + return "waiting_for_player_target_choice" + return "waiting_for_player_target_choice_confirmation" + return "waiting_for_opponent_turn" + + @cached_property + def is_bot_turn(self) -> bool: + return False # no bots involved in Lichess correspondence games + + @property + def solution_index(self) -> int | None: + return None + + @cached_property + def game_id(self) -> str: + return self._game_data.raw_data.id + + @cached_property + def factions(self) -> GameFactions: + return self._game_data.game_factions + + @property + def is_intro_turn(self) -> bool: + return False + + @cached_property + def player_side_to_highlight_all_pieces_for(self) -> PlayerSide | None: + return None + + @cached_property + def speech_bubble(self) -> SpeechBubbleData | None: + return None + + @cached_property + def opponent_username(self) -> str: + return self._game_data.players_from_my_perspective.them.username + + +class LichessCorrespondenceGamePresenterUrls(GamePresenterUrls): + def htmx_game_no_selection_url(self, *, board_id: str) -> str: + return "".join( + ( + reverse( + "lichess_bridge:htmx_game_no_selection", + kwargs={ + "game_id": self._game_presenter.game_id, + }, + ), + "?", + urlencode({"board_id": board_id}), + ) + ) + + def htmx_game_select_piece_url(self, *, square: Square, board_id: str) -> str: + return "".join( + ( + reverse( + "lichess_bridge:htmx_game_select_piece", + kwargs={ + "game_id": self._game_presenter.game_id, + "location": square, + }, + ), + "?", + urlencode({"board_id": board_id}), + ) + ) + + def htmx_game_move_piece_confirmation_dialog_url( + self, *, square: Square, board_id: str + ) -> str: + assert self._game_presenter.selected_piece is not None # type checker: happy + return "".join( + ( + reverse( + "lichess_bridge:htmx_game_move_piece_confirmation_dialog", + kwargs={ + "game_id": self._game_presenter.game_id, + "from_": self._game_presenter.selected_piece.square, + "to": square, + }, + ), + "?", + urlencode({"board_id": board_id}), + ) + ) + + def htmx_game_move_piece_url(self, *, square: Square, board_id: str) -> str: + assert self._game_presenter.selected_piece is not None # type checker: happy + return "".join( + ( + reverse( + "lichess_bridge:htmx_game_move_piece", + kwargs={ + "game_id": self._game_presenter.game_id, + "from_": self._game_presenter.selected_piece.square, + "to": square, + }, + ), + "?", + urlencode({"board_id": board_id}), + ) + ) + + def htmx_game_play_bot_move_url(self, *, board_id: str) -> str: + raise NotImplementedError("No bots on Lichess games") + + def htmx_game_play_solution_move_url(self, *, board_id: str) -> str: + raise NotImplementedError("No game solution on Lichess games") diff --git a/src/apps/lichess_bridge/tests/__init__.py b/src/apps/lichess_bridge/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/lichess_bridge/tests/test_views.py b/src/apps/lichess_bridge/tests/test_views.py new file mode 100644 index 0000000..58b8d6b --- /dev/null +++ b/src/apps/lichess_bridge/tests/test_views.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +import contextlib +import json +from http import HTTPStatus +from typing import TYPE_CHECKING, Any +from unittest import mock + +import pytest + +if TYPE_CHECKING: + from django.test import AsyncClient as DjangoAsyncClient, Client as DjangoClient + + +class HttpClientMockBase: + def __init__(self, access_token): + self.lichess_access_token = access_token + + @property + def headers(self): + return {"Authorization": f"Bearer {self.lichess_access_token}"} + + +class HttpClientResponseMockBase: + def __init__(self, path): + self.path = path + + def raise_for_status(self): + pass + + +def test_lichess_homepage_no_access_token_smoke_test(client: DjangoClient): + """Just a quick smoke test for now""" + + response = client.get("/lichess/") + assert response.status_code == HTTPStatus.OK + + response_html = response.content.decode("utf-8") + assert "Log in via Lichess" in response_html + assert "Manage your Lichess account" not in response_html + + +@pytest.mark.django_db # just because we use the DatabaseCache +async def test_lichess_homepage_with_access_token_smoke_test( + async_client: DjangoAsyncClient, + acleared_django_default_cache, +): + """Just a quick smoke test for now""" + + access_token = "lio_123456789" + async_client.cookies["lichess.access_token"] = access_token + + class HttpClientMock(HttpClientMockBase): + class HttpClientResponseMock(HttpClientResponseMockBase): + @property + def content(self) -> str: + # The client's response's `content` is a property + result: dict[str, Any] = {} + match self.path: + case "/api/account": + result = { + "id": "chesschampion", + "url": "https://lichess.org/@/chesschampion", + "username": "ChessChampion", + } + case "/api/account/playing": + result = {"nowPlaying": []} + case _: + raise ValueError(f"Unexpected path: {self.path}") + return json.dumps(result) + + async def get(self, path, **kwargs): + # The client's `get` method is async + assert path.startswith("/api/") + return self.HttpClientResponseMock(path) + + with mock.patch( + "apps.lichess_bridge.lichess_api._create_lichess_api_client", + ) as create_lichess_api_client_mock: + create_lichess_api_client_mock.return_value.__aenter__.return_value = ( + HttpClientMock(access_token) + ) + + response = await async_client.get("/lichess/") + + assert create_lichess_api_client_mock.call_count == 1 + + assert response.status_code == HTTPStatus.OK + + response_html = response.content.decode("utf-8") + assert "Log in via Lichess" not in response_html + assert "Manage your Lichess account" in response_html + assert "ChessChampion" in response_html + + +async def test_lichess_create_game_without_access_token_should_redirect( + async_client: DjangoAsyncClient, +): + response = await async_client.get("/lichess/games/new/") + + assert response.status_code == HTTPStatus.FOUND + + +@pytest.mark.django_db # just because we use the DatabaseCache +async def test_lichess_create_game_with_access_token_smoke_test( + async_client: DjangoAsyncClient, +): + """Just a quick smoke test for now""" + + access_token = "lio_123456789" + async_client.cookies["lichess.access_token"] = access_token + + class HttpClientMock(HttpClientMockBase): + class HttpClientResponseMock(HttpClientResponseMockBase): + @property + def content(self) -> str: + # The client's response's `content` is a property + result: dict[str, Any] = {} + match self.path: + case "/api/account": + result = { + "id": "chesschampion", + "url": "https://lichess.org/@/chesschampion", + "username": "ChessChampion", + } + case _: + raise ValueError(f"Unexpected path: {self.path}") + return json.dumps(result) + + async def get(self, path, **kwargs): + # The client's `get` method is async + assert path.startswith("/api/") + return self.HttpClientResponseMock(path) + + with mock.patch( + "apps.lichess_bridge.lichess_api._create_lichess_api_client", + ) as create_lichess_api_client_mock: + create_lichess_api_client_mock.return_value.__aenter__.return_value = ( + HttpClientMock(access_token) + ) + response = await async_client.get("/lichess/games/new/") + + assert response.status_code == HTTPStatus.OK + + +async def test_lichess_correspondence_game_without_access_token_should_redirect( + async_client: DjangoAsyncClient, +): + response = await async_client.get("/lichess/games/correspondence/tFXGsEvq/") + + assert response.status_code == HTTPStatus.FOUND + + +_LICHESS_CORRESPONDENCE_GAME_FROM_STREAM_JSON_RESPONSE = { + "id": "tFfGsEpb", + "variant": {"key": "standard", "name": "Standard", "short": "Std"}, + "speed": "correspondence", + "perf": {"name": "Correspondence"}, + "rated": False, + "createdAt": 1725637044590, + "white": { + "id": "chesschampion", + "name": "ChessChampion", + "title": None, + "rating": 1500, + "provisional": True, + }, + "black": { + "id": "chessmaster74960", + "name": "ChessMaster74960", + "title": None, + "rating": 2078, + }, + "initialFen": "startpos", + "daysPerTurn": 3, + "type": "gameFull", + "state": { + "type": "gameState", + "moves": "e2e4 d7d5 f2f3 e7e5 f1d3 f8c5 b2b3", + "wtime": 259200000, + "btime": 255151000, + "winc": 0, + "binc": 0, + "status": "started", + }, +} + +_LICHESS_CORRESPONDENCE_GAME_EXPORT_JSON_RESPONSE = { + "id": "tFfGsEpb", + "fullId": "tFfGsEpbd0mL", + "rated": False, + "variant": "standard", + "speed": "correspondence", + "perf": "correspondence", + "createdAt": 1725637044590, + "lastMoveAt": 1725714989179, + "status": "started", + "source": "lobby", + "players": { + "white": { + "user": {"name": "ChessChampion", "id": "chesschampion"}, + "rating": 1500, + "provisional": True, + }, + "black": { + "user": {"name": "ChessMaster74960", "id": "chessmaster74960"}, + "rating": 2078, + }, + }, + "opening": {"eco": "B01", "name": "Scandinavian Defense", "ply": 2}, + "moves": "e4 d5", + "pgn": "\n".join( + # https://en.wikipedia.org/wiki/Portable_Game_Notation + ( + '[Event "Casual correspondence game"]', + '[Site "https://lichess.org/tFXGsEcq"]', + '[Date "2024.09.06"]', + '[White "ChessChampion"]', + '[Black "ChessMaster74960"]', + '[Result "*"]', + '[UTCDate "2024.09.06"]', + '[UTCTime "15:37:24"]', + '[WhiteElo "1500"]', + '[BlackElo "2078"]', + '[Variant "Standard"]', + '[TimeControl "-"]', + '[ECO "B01"]', + '[Opening "Scandinavian Defense"]', + '[Termination "Unterminated"]', + "\n1. e4 d5 *", + "\n\n", + ) + ), + "daysPerTurn": 3, + "division": {}, +} + + +@pytest.mark.django_db # just because we use the DatabaseCache +async def test_lichess_correspondence_game_with_access_token_smoke_test( + async_client: DjangoAsyncClient, + acleared_django_default_cache, +): + """Just a quick smoke test for now""" + + access_token = "lio_123456789" + async_client.cookies["lichess.access_token"] = access_token + + class HttpClientMock(HttpClientMockBase): + class HttpClientResponseMock(HttpClientResponseMockBase): + @property + def content(self) -> str: + # The client's response's `content` is a property + result: dict[str, Any] = {} + match self.path: + case "/api/account": + result = { + "id": "chesschampion", + "url": "https://lichess.org/@/chesschampion", + "username": "ChessChampion", + } + case _: + raise ValueError(f"Unexpected path: {self.path}") + return json.dumps(result) + + async def aiter_lines(self): + yield json.dumps(_LICHESS_CORRESPONDENCE_GAME_FROM_STREAM_JSON_RESPONSE) + raise ValueError( + "aiterlines should be stopped after the 1st line was read" + ) + + async def get(self, path, **kwargs): + # The client's `get` method is async + assert path.startswith("/api/") + return self.HttpClientResponseMock(path) + + @contextlib.asynccontextmanager + async def stream(self, method, path, **kwargs): + assert method == "GET" and path.startswith("/api/board/game/stream/") + yield self.HttpClientResponseMock(path) + + with mock.patch( + "apps.lichess_bridge.lichess_api._create_lichess_api_client", + ) as create_lichess_api_client_mock: + client_mock = HttpClientMock(access_token) + create_lichess_api_client_mock.return_value.__aenter__.return_value = ( + client_mock + ) + + response = await async_client.get("/lichess/games/correspondence/tFfGsEpb/") + + assert create_lichess_api_client_mock.call_count == 1 + + assert response.status_code == HTTPStatus.OK diff --git a/src/apps/lichess_bridge/urls.py b/src/apps/lichess_bridge/urls.py new file mode 100644 index 0000000..6da2736 --- /dev/null +++ b/src/apps/lichess_bridge/urls.py @@ -0,0 +1,63 @@ +from django.urls import path + +from . import views + +app_name = "lichess_bridge" + +urlpatterns = [ + path("", views.lichess_home_page, name="homepage"), + path("games/", views.lichess_my_games_list_page, name="my_games"), + # Gameplay Views: + path( + "games/new/", + views.lichess_game_create_form_page, + name="create_game", + ), + path( + "games/correspondence//", + views.lichess_correspondence_game_page, + name="correspondence_game", + ), + path( + "htmx/games/correspondence//no-selection/", + views.htmx_lichess_correspondence_game_no_selection, + name="htmx_game_no_selection", + ), + path( + "htmx/games/correspondence//pieces//select/", + views.htmx_game_select_piece, + name="htmx_game_select_piece", + ), + path( + "htmx/games/correspondence//pieces//move//confirmation-dialog/", + views.htmx_game_move_piece_confirmation_dialog, + name="htmx_game_move_piece_confirmation_dialog", + ), + path( + "htmx/games/correspondence//pieces//move//", + views.htmx_game_move_piece, + name="htmx_game_move_piece", + ), + # Modals: + path( + "htmx/modals/user-account/", + views.htmx_user_account_modal, + name="htmx_modal_user_account", + ), + # OAuth2 Views: + path( + "oauth2/start-flow/", + views.lichess_redirect_to_oauth2_flow_starting_url, + name="oauth2_start_flow", + ), + path( + "oauth2/webhook/token-callback/", + views.lichess_webhook_oauth2_token_callback, + name="oauth2_token_callback", + ), + path( + "account/detach/", + views.lichess_detach_account, + name="detach_lichess_account", + ), +] diff --git a/src/apps/lichess_bridge/views.py b/src/apps/lichess_bridge/views.py new file mode 100644 index 0000000..3584189 --- /dev/null +++ b/src/apps/lichess_bridge/views.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from django.http import HttpResponse, HttpResponseRedirect +from django.shortcuts import redirect +from django.views.decorators.http import ( + require_http_methods, + require_POST, + require_safe, +) + +from apps.chess.exceptions import ( + ChessInvalidActionException, + ChessInvalidMoveException, +) + +from . import cookie_helpers, lichess_api +from .authentication import ( + LichessTokenRetrievalProcessContext, + check_csrf_state_from_oauth2_callback, + fetch_lichess_token_from_oauth2_callback, + get_lichess_token_retrieval_via_oauth2_process_starting_url, +) +from .components.misc_ui.user_profile_modal import user_profile_modal +from .components.pages import lichess_pages as lichess_pages +from .components.pages.lichess_pages import lichess_game_moving_parts_fragment +from .forms import LichessCorrespondenceGameCreationForm +from .models import LichessGameFullFromStreamWithMetadata +from .presenters import LichessCorrespondenceGamePresenter +from .views_decorators import ( + handle_chess_logic_exceptions, + redirect_if_no_lichess_access_token, + with_lichess_access_token, + with_user_prefs, +) + +if TYPE_CHECKING: + from django.http import HttpRequest + + from apps.chess.models import UserPrefs + from apps.chess.types import Square + + from .models import ( + LichessAccessToken, + LichessAccountInformation, + LichessGameId, + ) + +# TODO: use Django message framework for everything that happens outside of the chess +# board, so we can notify users of what's going on +# (we don't use HTMX for these steps, which will make the display of such messages easier) + + +@require_safe +@with_lichess_access_token +async def lichess_home_page( + request: HttpRequest, lichess_access_token: LichessAccessToken | None +) -> HttpResponse: + if not lichess_access_token: + page_content = lichess_pages.lichess_no_account_linked_page(request=request) + else: + page_content = await _get_my_games_list_page_content( + request=request, + lichess_access_token=lichess_access_token, + ) + + return HttpResponse(page_content) + + +@require_safe +@redirect_if_no_lichess_access_token +async def lichess_my_games_list_page( + request: HttpRequest, lichess_access_token: LichessAccessToken +) -> HttpResponse: + page_content = await _get_my_games_list_page_content( + request=request, + lichess_access_token=lichess_access_token, + ) + + return HttpResponse(page_content) + + +@require_http_methods(["GET", "POST"]) +@redirect_if_no_lichess_access_token +async def lichess_game_create_form_page( + request: HttpRequest, *, lichess_access_token: LichessAccessToken +) -> HttpResponse: + me = await _get_me_from_lichess(lichess_access_token) + + form_errors = {} + if request.method == "POST": + form = LichessCorrespondenceGameCreationForm(request.POST) + if not form.is_valid(): + form_errors = form.errors + else: + async with lichess_api.get_lichess_api_client( + access_token=lichess_access_token + ) as lichess_api_client: + # N.B. This function returns a Lichess "Seek ID", + # but we don't use it atm. + await lichess_api.create_correspondence_game( + api_client=lichess_api_client, + days_per_turn=form.cleaned_data["days_per_turn"], + ) + + return redirect("lichess_bridge:homepage") + + return HttpResponse( + lichess_pages.lichess_correspondence_game_creation_page( + request=request, me=me, form_errors=form_errors + ) + ) + + +@require_safe +@with_user_prefs +@redirect_if_no_lichess_access_token +async def lichess_correspondence_game_page( + request: HttpRequest, + *, + lichess_access_token: LichessAccessToken, + game_id: LichessGameId, + user_prefs: UserPrefs | None, +) -> HttpResponse: + me, game_data = await _get_game_context_from_lichess( + lichess_access_token, game_id, use_game_cache=False + ) + game_presenter = LichessCorrespondenceGamePresenter( + game_data=game_data, + is_htmx_request=False, + refresh_last_move=True, + user_prefs=user_prefs, + ) + + return HttpResponse( + lichess_pages.lichess_correspondence_game_page( + request=request, + me=me, + game_presenter=game_presenter, + ) + ) + + +@require_safe +@with_user_prefs +@redirect_if_no_lichess_access_token +async def htmx_lichess_correspondence_game_no_selection( + request: HttpRequest, + *, + lichess_access_token: LichessAccessToken, + game_id: LichessGameId, + user_prefs: UserPrefs | None, +) -> HttpResponse: + me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) + game_presenter = LichessCorrespondenceGamePresenter( + game_data=game_data, + is_htmx_request=True, + refresh_last_move=False, + user_prefs=user_prefs, + ) + + return _lichess_game_moving_parts_fragment_response( + game_presenter=game_presenter, request=request, board_id="main" + ) + + +@require_safe +@with_user_prefs +@redirect_if_no_lichess_access_token +@handle_chess_logic_exceptions +async def htmx_game_select_piece( + request: HttpRequest, + *, + lichess_access_token: LichessAccessToken, + game_id: LichessGameId, + location: Square, + user_prefs: UserPrefs | None, +) -> HttpResponse: + me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) + game_presenter = LichessCorrespondenceGamePresenter( + game_data=game_data, + selected_piece_square=location, + is_htmx_request=True, + refresh_last_move=False, + user_prefs=user_prefs, + ) + + return _lichess_game_moving_parts_fragment_response( + game_presenter=game_presenter, request=request, board_id="main" + ) + + +@require_safe +@with_user_prefs +@redirect_if_no_lichess_access_token +@handle_chess_logic_exceptions +async def htmx_game_move_piece_confirmation_dialog( + request: HttpRequest, + *, + lichess_access_token: LichessAccessToken, + game_id: LichessGameId, + from_: Square, + to: Square, + user_prefs: UserPrefs | None, +) -> HttpResponse: + if from_ == to: + raise ChessInvalidMoveException("Not a move") + + me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) + + if not game_data.raw_data.is_ongoing_game: + raise ChessInvalidActionException("Game is over, cannot move pieces") + + is_my_turn = game_data.players_from_my_perspective.active_player == "me" + if not is_my_turn: + raise ChessInvalidMoveException("Not my turn") + + game_presenter = LichessCorrespondenceGamePresenter( + game_data=game_data, + selected_piece_square=from_, + target_square_to_confirm=to, + is_htmx_request=True, + refresh_last_move=False, + user_prefs=user_prefs, + ) + + return _lichess_game_moving_parts_fragment_response( + game_presenter=game_presenter, request=request, board_id="main" + ) + + +@require_POST +@with_user_prefs +@redirect_if_no_lichess_access_token +@handle_chess_logic_exceptions +async def htmx_game_move_piece( + request: HttpRequest, + *, + lichess_access_token: LichessAccessToken, + game_id: LichessGameId, + from_: Square, + to: Square, + user_prefs: UserPrefs | None, +) -> HttpResponse: + if from_ == to: + raise ChessInvalidMoveException("Not a move") + + me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) + + if not game_data.raw_data.is_ongoing_game: + raise ChessInvalidActionException("Game is over, cannot move pieces") + + is_my_turn = game_data.players_from_my_perspective.active_player == "me" + if not is_my_turn: + raise ChessInvalidMoveException("Not my turn") + + async with lichess_api.get_lichess_api_client( + access_token=lichess_access_token + ) as lichess_api_client: + move_was_successful = await lichess_api.move_lichess_game_piece( + api_client=lichess_api_client, game_id=game_id, from_=from_, to=to + ) + if not move_was_successful: + raise ChessInvalidMoveException( + f"Move '{from_}{to}' on game '{game_id}' was not successful on Lichess' side" + ) + # The move was successful, let's re-fetch the updated game state: + # (the cache for this game's data has be cleared by `move_lichess_game_piece`) + game_data_raw = await lichess_api.get_game_by_id_from_stream( + api_client=lichess_api_client, + game_id=game_id, + try_fetching_from_cache=False, + ) + + # TODO: handle end of game after move! + + game_data = LichessGameFullFromStreamWithMetadata( + raw_data=game_data_raw, my_player_id=me.id + ) + game_presenter = LichessCorrespondenceGamePresenter( + game_data=game_data, + is_htmx_request=True, + refresh_last_move=True, + user_prefs=user_prefs, + ) + + return _lichess_game_moving_parts_fragment_response( + game_presenter=game_presenter, request=request, board_id="main" + ) + + +@require_safe +@redirect_if_no_lichess_access_token +async def htmx_user_account_modal( + request: HttpRequest, + *, + lichess_access_token: LichessAccessToken, +) -> HttpResponse: + me = await _get_me_from_lichess(lichess_access_token) + + modal_content = user_profile_modal(request=request, me=me) + + return HttpResponse(str(modal_content)) + + +@require_POST +def lichess_redirect_to_oauth2_flow_starting_url( + request: HttpRequest, +) -> HttpResponse: + lichess_oauth2_process_context = LichessTokenRetrievalProcessContext.create_afresh( + zakuchess_hostname=request.get_host(), + zakuchess_protocol=request.scheme, + ) + target_url = get_lichess_token_retrieval_via_oauth2_process_starting_url( + context=lichess_oauth2_process_context + ) + + response = HttpResponseRedirect(target_url) + # We will need to re-use some of this context's data in the webhook below: + # --> let's store that in an HTTP-only cookie + cookie_helpers.store_oauth2_token_retrieval_context_in_response_cookie( + context=lichess_oauth2_process_context, response=response + ) + + return response + + +@require_safe +def lichess_webhook_oauth2_token_callback(request: HttpRequest) -> HttpResponse: + # Retrieve a context from the HTTP-only cookie we created above: + lichess_oauth2_process_context = ( + cookie_helpers.get_oauth2_token_retrieval_context_from_request(request) + ) + if lichess_oauth2_process_context is None: + # TODO: Do something with that error + return redirect("lichess_bridge:homepage") + + # We have to check the "CSRF state": + # ( https://stack-auth.com/blog/oauth-from-first-principles#attack-4 ) + # TODO: add a test that checks that it does fail if the state doesn't match + check_csrf_state_from_oauth2_callback( + request=request, context=lichess_oauth2_process_context + ) + + # Ok, now let's fetch an API access token from Lichess! + token = fetch_lichess_token_from_oauth2_callback( + authorization_callback_response_url=request.get_full_path(), + context=lichess_oauth2_process_context, + ) + + response = redirect("lichess_bridge:homepage") + + # OAuth2 flow is done: let's delete the cookie related to this flow: + cookie_helpers.delete_oauth2_token_retrieval_context_from_cookies(response) + + # Now that we have an access token to interact with Lichess' API on behalf + # of the user, let's store it into a long-lived HTTP-only cookie: + cookie_helpers.store_lichess_api_access_token_in_response_cookie( + token=token, + response=response, + ) + + return response + + +@require_POST +def lichess_detach_account(request: HttpRequest) -> HttpResponse: + response = redirect("lichess_bridge:homepage") + + cookie_helpers.delete_lichess_api_access_token_from_cookies(response=response) + + return response + + +def _lichess_game_moving_parts_fragment_response( + *, + game_presenter: LichessCorrespondenceGamePresenter, + request: HttpRequest, + board_id: str, +) -> HttpResponse: + return HttpResponse( + lichess_game_moving_parts_fragment( + game_presenter=game_presenter, request=request, board_id=board_id + ), + ) + + +async def _get_my_games_list_page_content( + *, + request: HttpRequest, + lichess_access_token: LichessAccessToken, +) -> str: + async with lichess_api.get_lichess_api_client( + access_token=lichess_access_token + ) as lichess_api_client: + # As the queries are unrelated, let's run them in parallel: + async with asyncio.TaskGroup() as tg: + me = tg.create_task( + lichess_api.get_my_account(api_client=lichess_api_client) + ) + ongoing_games = tg.create_task( + lichess_api.get_my_ongoing_games(api_client=lichess_api_client) + ) + + return lichess_pages.lichess_my_current_games_list_page( + request=request, + me=me.result(), + ongoing_games=ongoing_games.result(), + ) + + +async def _get_me_from_lichess( + lichess_access_token: LichessAccessToken, +) -> LichessAccountInformation: + async with lichess_api.get_lichess_api_client( + access_token=lichess_access_token + ) as lichess_api_client: + return await lichess_api.get_my_account(api_client=lichess_api_client) + + +async def _get_game_context_from_lichess( + lichess_access_token: LichessAccessToken, + game_id: LichessGameId, + use_game_cache: bool = True, +) -> tuple[LichessAccountInformation, LichessGameFullFromStreamWithMetadata]: + async with lichess_api.get_lichess_api_client( + access_token=lichess_access_token + ) as lichess_api_client: + # As the queries are unrelated, let's run them in parallel: + async with asyncio.TaskGroup() as tg: + me_task = tg.create_task( + lichess_api.get_my_account(api_client=lichess_api_client) + ) + game_data_task = tg.create_task( + lichess_api.get_game_by_id_from_stream( + api_client=lichess_api_client, + game_id=game_id, + try_fetching_from_cache=use_game_cache, + ) + ) + + me, game_data = me_task.result(), game_data_task.result() + + return me, LichessGameFullFromStreamWithMetadata( + raw_data=game_data, my_player_id=me.id + ) diff --git a/src/apps/lichess_bridge/views_decorators.py b/src/apps/lichess_bridge/views_decorators.py new file mode 100644 index 0000000..398185c --- /dev/null +++ b/src/apps/lichess_bridge/views_decorators.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import functools +from typing import TYPE_CHECKING + +from asgiref.sync import iscoroutinefunction +from django.core.exceptions import BadRequest +from django.shortcuts import redirect + +from ..chess.exceptions import ChessLogicException +from ..webui.cookie_helpers import get_user_prefs_from_request +from . import cookie_helpers + +if TYPE_CHECKING: + from django.http import HttpRequest + + +def with_lichess_access_token(func): + if iscoroutinefunction(func): + + @functools.wraps(func) + async def wrapper(request: HttpRequest, *args, **kwargs): + lichess_access_token = ( + cookie_helpers.get_lichess_api_access_token_from_request(request) + ) + return await func( + request, *args, lichess_access_token=lichess_access_token, **kwargs + ) + + else: + + @functools.wraps(func) + def wrapper(request: HttpRequest, *args, **kwargs): + lichess_access_token = ( + cookie_helpers.get_lichess_api_access_token_from_request(request) + ) + return func( + request, *args, lichess_access_token=lichess_access_token, **kwargs + ) + + return wrapper + + +def with_user_prefs(func): + if iscoroutinefunction(func): + + @functools.wraps(func) + async def wrapper(request: HttpRequest, *args, **kwargs): + user_prefs = get_user_prefs_from_request(request) + return await func(request, *args, user_prefs=user_prefs, **kwargs) + + else: + + @functools.wraps(func) + def wrapper(request: HttpRequest, *args, **kwargs): + user_prefs = get_user_prefs_from_request(request) + return func(request, *args, user_prefs=user_prefs, **kwargs) + + return wrapper + + +def redirect_if_no_lichess_access_token(func): + if iscoroutinefunction(func): + + @functools.wraps(func) + async def wrapper( + request: HttpRequest, + *args, + **kwargs, + ): + assert "lichess_access_token" not in kwargs + lichess_access_token = ( + cookie_helpers.get_lichess_api_access_token_from_request(request) + ) + if not lichess_access_token: + return redirect("lichess_bridge:homepage") + return await func( + request, *args, lichess_access_token=lichess_access_token, **kwargs + ) + + else: + + @functools.wraps(func) + def wrapper( + request: HttpRequest, + *args, + **kwargs, + ): + assert "lichess_access_token" not in kwargs + lichess_access_token = ( + cookie_helpers.get_lichess_api_access_token_from_request(request) + ) + if not lichess_access_token: + return redirect("lichess_bridge:homepage") + return func( + request, *args, lichess_access_token=lichess_access_token, **kwargs + ) + + return wrapper + + +def handle_chess_logic_exceptions(func): + if iscoroutinefunction(func): + + @functools.wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except ChessLogicException as exc: + raise BadRequest(str(exc)) from exc + + else: + + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except ChessLogicException as exc: + raise BadRequest(str(exc)) from exc + + return wrapper diff --git a/src/apps/utils/view_decorators.py b/src/apps/utils/view_decorators.py index 9ae6240..015c997 100644 --- a/src/apps/utils/view_decorators.py +++ b/src/apps/utils/view_decorators.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -6,5 +8,5 @@ from apps.authentication.models import User -def user_is_staff(user: "User | AnonymousUser") -> bool: +def user_is_staff(user: User | AnonymousUser) -> bool: return user.is_staff diff --git a/src/apps/utils/views_helpers.py b/src/apps/utils/views_helpers.py index eed43ae..e82f1db 100644 --- a/src/apps/utils/views_helpers.py +++ b/src/apps/utils/views_helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, cast from django.shortcuts import redirect, resolve_url @@ -8,7 +10,7 @@ from django_htmx.middleware import HtmxDetails -def htmx_aware_redirect(request: "HttpRequest", url: str) -> "HttpResponse": +def htmx_aware_redirect(request: HttpRequest, url: str) -> HttpResponse: htmx_details = cast("HtmxDetails", getattr(request, "htmx")) if htmx_details: return HttpResponseClientRedirect(resolve_url(url)) diff --git a/src/apps/webui/components/atoms/__init__.py b/src/apps/webui/components/atoms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/webui/components/atoms/buttons.py b/src/apps/webui/components/atoms/buttons.py new file mode 100644 index 0000000..614dad1 --- /dev/null +++ b/src/apps/webui/components/atoms/buttons.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +from dominate.dom_tag import dom_tag +from dominate.tags import a, button, span + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from dominate.tags import dom_tag + +ButtonType = Literal["action", "confirm", "cancel"] + +_BUTTON_BASE_BG_COLOR, _BUTTON_BASE_TEXT_COLOR = "bg-rose-600", "text-slate-200" +_BUTTON_BASE_HOVER_TEXT_COLOR = "hover:text-stone-100" +_BUTTON_CLASSES = ( + "inline-block py-1 px-3 rounded-md font-bold whitespace-nowrap " + f"{_BUTTON_BASE_TEXT_COLOR} {_BUTTON_BASE_BG_COLOR} {_BUTTON_BASE_HOVER_TEXT_COLOR}" +) +_BUTTON_CONFIRM_CLASSES = _BUTTON_CLASSES.replace(_BUTTON_BASE_BG_COLOR, "bg-lime-700") +_BUTTON_CANCEL_CLASSES = _BUTTON_CLASSES.replace(_BUTTON_BASE_BG_COLOR, "bg-indigo-500") + +_BUTTON_TYPE_TO_CLASS_MAPPING: dict[ButtonType, str] = { + "action": _BUTTON_CLASSES, + "confirm": _BUTTON_CONFIRM_CLASSES, + "cancel": _BUTTON_CANCEL_CLASSES, +} + + +def zc_button( + label: str, + *, + button_type: ButtonType, + svg_icon: str | None = None, + href: str | None = None, # if href is not None, the button will be a + id_: str | None = None, + title: str | None = None, + html_type: str | None = None, + extra_classes: Sequence[str] | None = None, + extra_attrs: Mapping[str, str | bool] | None = None, + htmx_attrs: Mapping[str, str | bool] | None = None, + is_a_help_for_actual_button: bool = False, # if True, the button will be a
+) -> dom_tag: + """A 'zakuchess' (`zc_*`) button.""" + + if is_a_help_for_actual_button and htmx_attrs is not None: + raise ValueError( + "Elements that are not actual buttons but " + "an help for them should not have htmx attributes" + ) + + children: list[str] = [label] + if svg_icon: + children.extend((" ", svg_icon)) + + classes: list[str] = [_BUTTON_TYPE_TO_CLASS_MAPPING[button_type]] + if extra_classes: + classes.extend(extra_classes) + + attributes: dict = { + **(extra_attrs or {}), + **(htmx_attrs or {}), + } + if id_: + attributes["id"] = id_ + if title: + attributes["title"] = title + if html_type: + attributes["type"] = html_type + + if is_a_help_for_actual_button: + classes.extend(("!inline-block", "!mx-0")) + return span( + *children, + cls=" ".join(classes), + **attributes, + ) + + if href: + return a( + *children, + href=href, + cls=" ".join(classes), + **attributes, + ) + + return button( + *children, + cls=" ".join(classes), + **attributes, + ) + + +def zc_header_icon_button( + *, icon: str, title: str, id_: str, htmx_attributes: dict[str, str] +) -> dom_tag: + """A 'zakuchess' (`zc_*`) header button, visually displayed as an icon.""" + return button( + icon, + cls="block px-1 py-1 text-sm text-slate-50 hover:text-slate-400", + title=title, + id=id_, + **htmx_attributes, + ) diff --git a/src/apps/webui/components/chess_units.py b/src/apps/webui/components/chess_units.py new file mode 100644 index 0000000..e11ab3c --- /dev/null +++ b/src/apps/webui/components/chess_units.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import random +from typing import TYPE_CHECKING, cast + +from dominate.tags import div, span +from dominate.util import raw + +from apps.chess.components.chess_board import SQUARE_COLOR_TAILWIND_CLASSES +from apps.chess.components.chess_helpers import chess_unit_symbol_class +from apps.chess.consts import PIECE_TYPE_TO_NAME + +if TYPE_CHECKING: + from dominate.tags import dom_tag + + from apps.chess.models import GameFactions + from apps.chess.types import ( + PieceName, + PieceRole, + PieceType, + PlayerSide, + TeamMemberRole, + ) + +CHARACTER_TYPE_TIP: dict[PieceType, str] = { + # TODO: i18n + "p": "Characters with swords", + "n": "Mounted characters", + "b": "Characters with a bow", + "r": "Flying characters", + "q": "Characters with a staff", + "k": "Characters wearing heavy armors", +} +_CHARACTER_TYPE_TIP_KEYS = tuple(CHARACTER_TYPE_TIP.keys()) + +_CHARACTER_TYPE_ROLE_MAPPING: dict[PieceType, TeamMemberRole] = { + "p": "p1", + "n": "n1", + "b": "b1", + "r": "r1", + "q": "q", + "k": "k", +} + + +def chess_status_bar_tip( + *, + factions: GameFactions, + piece_type: PieceType | None = None, + additional_classes: str = "", + row_counter: int | None = None, +) -> dom_tag: + if piece_type is None: + piece_type = random.choice(_CHARACTER_TYPE_TIP_KEYS) + piece_name = PIECE_TYPE_TO_NAME[piece_type] + unit_left_side_role = cast( + "PieceRole", _CHARACTER_TYPE_ROLE_MAPPING[piece_type].upper() + ) + unit_right_side_role = _CHARACTER_TYPE_ROLE_MAPPING[piece_type] + unit_display_left = unit_display_container( + piece_role=unit_left_side_role, factions=factions, row_counter=row_counter + ) + unit_display_right = unit_display_container( + piece_role=unit_right_side_role, factions=factions, row_counter=row_counter + ) + + return div( + unit_display_left, + div( + character_type_tip(piece_type), + chess_unit_symbol_display(player_side="w", piece_name=piece_name), + cls="text-center", + ), + unit_display_right, + cls=f"flex w-full justify-between items-center {additional_classes}", + ) + + +def unit_display_container( + *, + piece_role: PieceRole, + factions: GameFactions, + row_counter: int | None = None, + additional_classes: str = "", +) -> dom_tag: + from apps.chess.components.chess_board import chess_unit_display_with_ground_marker + + unit_display = chess_unit_display_with_ground_marker( + piece_role=piece_role, + factions=factions, + ) + + rounded_square_classes = ( + f"{SQUARE_COLOR_TAILWIND_CLASSES[row_counter%2]} rounded-lg" + if row_counter is not None + else "" + ) + + return div( + unit_display, + cls=f"h-16 aspect-square {rounded_square_classes} {additional_classes}", + ) + + +def character_type_tip(piece_type: PieceType) -> dom_tag: + return raw( + f"{CHARACTER_TYPE_TIP[piece_type]} are chess {PIECE_TYPE_TO_NAME[piece_type]}s" + ) + + +def chess_unit_symbol_display( + *, player_side: PlayerSide, piece_name: PieceName +) -> dom_tag: + classes = ( + "inline-block", + "w-5", + "align-text-bottom", + "aspect-square", + "bg-no-repeat", + "bg-cover", + chess_unit_symbol_class(player_side=player_side, piece_name=piece_name), + ) + + return span( + cls=" ".join(classes), + ) diff --git a/src/apps/webui/components/forms_common.py b/src/apps/webui/components/forms_common.py new file mode 100644 index 0000000..c0f1d86 --- /dev/null +++ b/src/apps/webui/components/forms_common.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.middleware.csrf import get_token as get_csrf_token +from dominate.tags import input_ + +if TYPE_CHECKING: + from django.http import HttpRequest + + +def csrf_hidden_input(request: HttpRequest) -> input_: + return input_( + type="hidden", name="csrfmiddlewaretoken", value=get_csrf_token(request) + ) diff --git a/src/apps/webui/components/layout.py b/src/apps/webui/components/layout.py index f38021e..740edb0 100644 --- a/src/apps/webui/components/layout.py +++ b/src/apps/webui/components/layout.py @@ -1,14 +1,17 @@ +from __future__ import annotations + import base64 import json from functools import cache from typing import TYPE_CHECKING from django.conf import settings -from django.template.backends.utils import get_token # type: ignore[attr-defined] +from django.template.backends.utils import get_token as get_csrf_token from django.templatetags.static import static from dominate.tags import ( a, body, + comment, div, footer as base_footer, h1, @@ -33,12 +36,7 @@ from dominate.util import text # We'll do something cleaner later -# TODO: subset the OpenSans font, once we have extracted text in i18n files. _FONTS_CSS = """ -@font-face { - font-family: 'OpenSans'; - src: url('/static/webui/fonts/OpenSans.woff2') format('woff2'); -} @font-face { font-family: 'PixelFont'; src: url('/static/webui/fonts/fibberish.ttf') format('truetype'); @@ -55,12 +53,12 @@ def page( - *children: "dom_tag", - request: "HttpRequest", + *children: dom_tag, + request: HttpRequest, title: str = _META_TITLE, - left_side_buttons: "list[dom_tag] | None" = None, - right_side_buttons: "list[dom_tag] | None" = None, - head_children: "Sequence[dom_tag] | None" = None, + left_side_buttons: list[dom_tag] | None = None, + right_side_buttons: list[dom_tag] | None = None, + head_children: Sequence[dom_tag] | None = None, ) -> str: return "" + str( document( @@ -75,13 +73,13 @@ def page( def document( - *children: "dom_tag", - request: "HttpRequest", + *children: dom_tag, + request: HttpRequest, title: str, - left_side_buttons: "list[dom_tag] | None", - right_side_buttons: "list[dom_tag] | None" = None, - head_children: "Sequence[dom_tag] | None" = None, -) -> "dom_tag": + left_side_buttons: list[dom_tag] | None, + right_side_buttons: list[dom_tag] | None = None, + head_children: Sequence[dom_tag] | None = None, +) -> dom_tag: return html( head(*(head_children or []), title=title), body( @@ -94,7 +92,7 @@ def document( modals_container(), cls=_DOCUMENT_BG_COLOR, data_hx_headers=json.dumps( - {"X-CSRFToken": get_token(request) if request else "[no request]"} + {"X-CSRFToken": get_csrf_token(request) if request else "[no request]"} ), data_hx_ext="class-tools", # enable CSS class transitions on the whole page ), @@ -104,8 +102,11 @@ def document( ) -def head(*children: "dom_tag", title: str) -> "dom_tag": +def head(*children: dom_tag, title: str) -> dom_tag: return base_head( + comment( + "ZakuChess is open source! See https://github.com/olivierphi/zakuchess" + ), meta(charset="utf-8"), base_title(title), meta(name="viewport", content="width=device-width, initial-scale=1"), @@ -124,7 +125,14 @@ def head(*children: "dom_tag", title: str) -> "dom_tag": sizes="32x32", href=static("webui/img/favicon-32x32.png"), ), + # Fonts: style(_FONTS_CSS), + link( + # automatically created by `django-google-fonts` + rel="stylesheet", + href=static("fonts/opensans:ital,wght@0,300..800;1,300..800.css"), + ), + # CSS & JS link(rel="stylesheet", href=static("webui/css/zakuchess.css")), script(src=static("webui/js/main.js")), script(src=static("chess/js/chess-main.js")), @@ -144,10 +152,10 @@ def head(*children: "dom_tag", title: str) -> "dom_tag": def header( *, - left_side_buttons: "list[dom_tag] | None", - right_side_buttons: "list[dom_tag] | None" = None, -) -> "dom_tag": - def side_wrapper(*children: "dom_tag", align: str) -> "dom_tag": + left_side_buttons: list[dom_tag] | None, + right_side_buttons: list[dom_tag] | None = None, +) -> dom_tag: + def side_wrapper(*children: dom_tag, align: str) -> dom_tag: return div( *children, cls=f"flex w-1/6 {align}", @@ -175,7 +183,7 @@ def side_wrapper(*children: "dom_tag", align: str) -> "dom_tag": @cache -def footer() -> "text": +def footer() -> text: GITHUB_SVG = b"""""" svg_b64 = base64.b64encode(GITHUB_SVG).decode("utf-8") @@ -219,7 +227,7 @@ def footer() -> "text": @cache -def modals_container() -> "text": +def modals_container() -> text: return raw( div( script( diff --git a/src/apps/webui/components/misc_ui/__init__.py b/src/apps/webui/components/misc_ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/webui/components/misc_ui/svg_icons.py b/src/apps/webui/components/misc_ui/svg_icons.py new file mode 100644 index 0000000..0324af5 --- /dev/null +++ b/src/apps/webui/components/misc_ui/svg_icons.py @@ -0,0 +1,15 @@ +from dominate.util import raw + +# https://heroicons.com/, icon `question-mark-circle` +ICON_SVG_HELP = raw( + r""" + + """ +) + +# https://heroicons.com/, icon `cog-8-tooth`, "solid" version +ICON_SVG_COG = raw( + r""" + + """ +) diff --git a/src/apps/daily_challenge/components/misc_ui/user_prefs_modal.py b/src/apps/webui/components/misc_ui/user_prefs_modal.py similarity index 77% rename from src/apps/daily_challenge/components/misc_ui/user_prefs_modal.py rename to src/apps/webui/components/misc_ui/user_prefs_modal.py index 7692d5d..e9eb3ee 100644 --- a/src/apps/daily_challenge/components/misc_ui/user_prefs_modal.py +++ b/src/apps/webui/components/misc_ui/user_prefs_modal.py @@ -1,13 +1,15 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from django.urls import reverse -from dominate.tags import button, div, fieldset, form, h3, h4, input_, label, legend +from dominate.tags import div, fieldset, form, h3, h4, input_, label, legend from apps.chess.components.misc_ui import modal_container from apps.chess.components.svg_icons import ICON_SVG_CONFIRM from apps.chess.models import UserPrefsBoardTextureChoices, UserPrefsGameSpeedChoices -from .common_styles import BUTTON_CONFIRM_CLASSES +from ..atoms.buttons import zc_button, zc_header_icon_button from .svg_icons import ICON_SVG_COG if TYPE_CHECKING: @@ -22,7 +24,22 @@ # TODO: manage i18n -def user_prefs_modal(*, user_prefs: "UserPrefs") -> "dom_tag": +def user_prefs_button() -> dom_tag: + htmx_attributes = { + "data_hx_get": reverse("webui:htmx_modal_user_prefs"), + "data_hx_target": "#modals-container", + "data_hx_swap": "outerHTML", + } + + return zc_header_icon_button( + icon=ICON_SVG_COG, + title="Edit preferences", + id_="user-prefs-button", + htmx_attributes=htmx_attributes, + ) + + +def user_prefs_modal(*, user_prefs: UserPrefs) -> dom_tag: return modal_container( header=h3( "Preferences ", @@ -37,9 +54,9 @@ def user_prefs_modal(*, user_prefs: "UserPrefs") -> "dom_tag": ) -def _user_prefs_form(user_prefs: "UserPrefs") -> "dom_tag": +def _user_prefs_form(user_prefs: UserPrefs) -> dom_tag: form_htmx_attributes = { - "data_hx_post": reverse("daily_challenge:htmx_daily_challenge_user_prefs_save"), + "data_hx_post": reverse("webui:htmx_modal_user_prefs"), "data_hx_target": "#modals-container", "data_hx_swap": "innerHTML", } @@ -65,11 +82,10 @@ def _user_prefs_form(user_prefs: "UserPrefs") -> "dom_tag": ) submit_button = ( - button( + zc_button( "Save preferences", - " ", - ICON_SVG_CONFIRM, - cls=BUTTON_CONFIRM_CLASSES, + svg_icon=ICON_SVG_CONFIRM, + button_type="confirm", ), ) @@ -85,9 +101,9 @@ def _form_fieldset( *, fieldset_legend: str, input_name: str, - choices: "type[Choices]", + choices: type[Choices], # choices_icons: dict, - current_value: "Any", + current_value: Any, ) -> fieldset: return fieldset( legend( diff --git a/src/apps/webui/components/molecules/__init__.py b/src/apps/webui/components/molecules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/webui/components/molecules/chess_arena_companion_bars.py b/src/apps/webui/components/molecules/chess_arena_companion_bars.py new file mode 100644 index 0000000..22bffe2 --- /dev/null +++ b/src/apps/webui/components/molecules/chess_arena_companion_bars.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +from dominate.tags import div + +from apps.chess.components.chess_board import INFO_BARS_COMMON_CLASSES +from apps.chess.components.svg_icons import ICON_SVG_CANCEL, ICON_SVG_CONFIRM +from apps.webui.components.atoms.buttons import zc_button + +if TYPE_CHECKING: + from collections.abc import Mapping + + from dominate.dom_tag import dom_tag + +CompanionBarPosition = Literal["top", "bottom"] + + +def companion_bar( + inner_content: dom_tag, + *, + position: CompanionBarPosition, + extra_attrs: Mapping[str, str | bool] | None = None, + htmx_attrs: Mapping[str, str | bool] | None = None, + id_: str | None = None, +) -> dom_tag: + attributes = { + **(extra_attrs or {}), + **(htmx_attrs or {}), + } + if id_: + attributes["id"] = id_ + + classes = ( + f"min-h-[4rem] flex items-center justify-center {INFO_BARS_COMMON_CLASSES}" + ) + match position: + case "top": + classes += " border-t-0 xl:border-2 xl:rounded-t-md" + case "bottom": + classes += " border-t-0 rounded-b-md" + + return div( + inner_content, + cls=classes, + **attributes, + ) + + +def confirmation_dialog_bar( + *, + question: dom_tag, + htmx_attrs_confirm: Mapping[str, str | bool], + htmx_attrs_cancel: Mapping[str, str | bool], + htmx_attrs: Mapping[str, str | bool] | None = None, + id_: str | None = None, +) -> dom_tag: + inner_content = div( + question, + div( + zc_button( + "Confirm", + button_type="confirm", + svg_icon=ICON_SVG_CONFIRM, + htmx_attrs=htmx_attrs_confirm, + ), + zc_button( + "Cancel", + svg_icon=ICON_SVG_CANCEL, + button_type="cancel", + htmx_attrs=htmx_attrs_cancel, + ), + cls="text-center", + ), + ) + + return companion_bar( + inner_content=inner_content, position="top", id_=id_, htmx_attrs=htmx_attrs + ) diff --git a/src/apps/webui/cookie_helpers.py b/src/apps/webui/cookie_helpers.py new file mode 100644 index 0000000..96c91b7 --- /dev/null +++ b/src/apps/webui/cookie_helpers.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import datetime as dt +import logging +from typing import TYPE_CHECKING + +from msgspec import MsgspecError + +from apps.chess.models import UserPrefs +from lib.http_cookies_helpers import ( + HttpCookieAttributes, + set_http_cookie_on_django_response, +) + +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse + + +_USER_PREFS_COOKIE_ATTRS = HttpCookieAttributes( + name="uprefs", + max_age=dt.timedelta(days=30 * 6), # approximately 6 months + http_only=True, + same_site="Lax", +) + +_logger = logging.getLogger(__name__) + + +def get_user_prefs_from_request(request: HttpRequest) -> UserPrefs: + def new_content(): + return UserPrefs() + + cookie_content: str | None = request.COOKIES.get(_USER_PREFS_COOKIE_ATTRS.name) + if cookie_content is None or len(cookie_content) < 5: + return new_content() + + try: + user_prefs = UserPrefs.from_cookie_content(cookie_content) + return user_prefs + except MsgspecError: + _logger.exception( + "Could not decode cookie content; restarting with a blank one." + ) + return new_content() + + +def save_user_prefs(*, user_prefs: UserPrefs, response: HttpResponse) -> None: + set_http_cookie_on_django_response( + response=response, + attributes=_USER_PREFS_COOKIE_ATTRS, + value=user_prefs.to_cookie_content(), + ) diff --git a/src/apps/daily_challenge/forms.py b/src/apps/webui/forms.py similarity index 95% rename from src/apps/daily_challenge/forms.py rename to src/apps/webui/forms.py index c642482..b4cfeb3 100644 --- a/src/apps/daily_challenge/forms.py +++ b/src/apps/webui/forms.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from django import forms from apps.chess.models import ( diff --git a/src/apps/webui/static/.gitignore b/src/apps/webui/static/.gitignore new file mode 100644 index 0000000..8119d0a --- /dev/null +++ b/src/apps/webui/static/.gitignore @@ -0,0 +1 @@ +/fonts/ diff --git a/src/apps/webui/urls.py b/src/apps/webui/urls.py new file mode 100644 index 0000000..7e368f1 --- /dev/null +++ b/src/apps/webui/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from . import views + +app_name = "webui" + +urlpatterns = [ + # User prefs + path( + "htmx/modals/user-prefs/", + views.htmx_user_prefs_modal, + name="htmx_modal_user_prefs", + ), +] diff --git a/src/apps/webui/views.py b/src/apps/webui/views.py new file mode 100644 index 0000000..9d90606 --- /dev/null +++ b/src/apps/webui/views.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.http import HttpResponse +from django.views.decorators.http import require_http_methods +from django_htmx.http import HttpResponseClientRedirect + +from .components.misc_ui.user_prefs_modal import user_prefs_modal +from .cookie_helpers import get_user_prefs_from_request, save_user_prefs +from .forms import UserPrefsForm + +if TYPE_CHECKING: + from django.http import HttpRequest + + +@require_http_methods(["HEAD", "GET", "POST"]) +def htmx_user_prefs_modal(request: HttpRequest) -> HttpResponse: + if request.method == "POST": + # As user preferences updates can have an impact on any part of the UI + # (changing the way the chess board is displayed, for example), we'd better + # reload the whole page after having saved preferences. + response = HttpResponseClientRedirect("/") + + form = UserPrefsForm(request.POST) + if user_prefs := form.to_user_prefs(): + save_user_prefs(user_prefs=user_prefs, response=response) + + return response + + user_prefs = get_user_prefs_from_request(request) + modal_content = user_prefs_modal(user_prefs=user_prefs) + + return HttpResponse(str(modal_content)) diff --git a/src/lib/chess_engines/andoma/evaluate.py b/src/lib/chess_engines/andoma/evaluate.py deleted file mode 100644 index 75fd006..0000000 --- a/src/lib/chess_engines/andoma/evaluate.py +++ /dev/null @@ -1,218 +0,0 @@ -# N.B. Copy-pasted from https://github.com/healeycodes/andoma -import chess - -# this module implement's Tomasz Michniewski's Simplified Evaluation Function -# https://www.chessprogramming.org/Simplified_Evaluation_Function -# note that the board layouts have been flipped and the top left square is A1 - -# fmt: off -piece_value = { - chess.PAWN: 100, - chess.ROOK: 500, - chess.KNIGHT: 320, - chess.BISHOP: 330, - chess.QUEEN: 900, - chess.KING: 20000 -} - -pawnEvalWhite = [ - 0, 0, 0, 0, 0, 0, 0, 0, - 5, 10, 10, -20, -20, 10, 10, 5, - 5, -5, -10, 0, 0, -10, -5, 5, - 0, 0, 0, 20, 20, 0, 0, 0, - 5, 5, 10, 25, 25, 10, 5, 5, - 10, 10, 20, 30, 30, 20, 10, 10, - 50, 50, 50, 50, 50, 50, 50, 50, - 0, 0, 0, 0, 0, 0, 0, 0 -] -pawnEvalBlack = list(reversed(pawnEvalWhite)) - -knightEval = [ - -50, -40, -30, -30, -30, -30, -40, -50, - -40, -20, 0, 0, 0, 0, -20, -40, - -30, 0, 10, 15, 15, 10, 0, -30, - -30, 5, 15, 20, 20, 15, 5, -30, - -30, 0, 15, 20, 20, 15, 0, -30, - -30, 5, 10, 15, 15, 10, 5, -30, - -40, -20, 0, 5, 5, 0, -20, -40, - -50, -40, -30, -30, -30, -30, -40, -50 -] - -bishopEvalWhite = [ - -20, -10, -10, -10, -10, -10, -10, -20, - -10, 5, 0, 0, 0, 0, 5, -10, - -10, 10, 10, 10, 10, 10, 10, -10, - -10, 0, 10, 10, 10, 10, 0, -10, - -10, 5, 5, 10, 10, 5, 5, -10, - -10, 0, 5, 10, 10, 5, 0, -10, - -10, 0, 0, 0, 0, 0, 0, -10, - -20, -10, -10, -10, -10, -10, -10, -20 -] -bishopEvalBlack = list(reversed(bishopEvalWhite)) - -rookEvalWhite = [ - 0, 0, 0, 5, 5, 0, 0, 0, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - 5, 10, 10, 10, 10, 10, 10, 5, - 0, 0, 0, 0, 0, 0, 0, 0 -] -rookEvalBlack = list(reversed(rookEvalWhite)) - -queenEval = [ - -20, -10, -10, -5, -5, -10, -10, -20, - -10, 0, 0, 0, 0, 0, 0, -10, - -10, 0, 5, 5, 5, 5, 0, -10, - -5, 0, 5, 5, 5, 5, 0, -5, - 0, 0, 5, 5, 5, 5, 0, -5, - -10, 5, 5, 5, 5, 5, 0, -10, - -10, 0, 5, 0, 0, 0, 0, -10, - -20, -10, -10, -5, -5, -10, -10, -20 -] - -kingEvalWhite = [ - 20, 30, 10, 0, 0, 10, 30, 20, - 20, 20, 0, 0, 0, 0, 20, 20, - -10, -20, -20, -20, -20, -20, -20, -10, - 20, -30, -30, -40, -40, -30, -30, -20, - -30, -40, -40, -50, -50, -40, -40, -30, - -30, -40, -40, -50, -50, -40, -40, -30, - -30, -40, -40, -50, -50, -40, -40, -30, - -30, -40, -40, -50, -50, -40, -40, -30 -] -kingEvalBlack = list(reversed(kingEvalWhite)) - -kingEvalEndGameWhite = [ - 50, -30, -30, -30, -30, -30, -30, -50, - -30, -30, 0, 0, 0, 0, -30, -30, - -30, -10, 20, 30, 30, 20, -10, -30, - -30, -10, 30, 40, 40, 30, -10, -30, - -30, -10, 30, 40, 40, 30, -10, -30, - -30, -10, 20, 30, 30, 20, -10, -30, - -30, -20, -10, 0, 0, -10, -20, -30, - -50, -40, -30, -20, -20, -30, -40, -50 -] -kingEvalEndGameBlack = list(reversed(kingEvalEndGameWhite)) -# fmt: on - - -def move_value(board: chess.Board, move: chess.Move, endgame: bool) -> float: - """ - How good is a move? - A promotion is great. - A weaker piece taking a stronger piece is good. - A stronger piece taking a weaker piece is bad. - Also consider the position change via piece-square table. - """ - if move.promotion is not None: - return -float("inf") if board.turn == chess.BLACK else float("inf") - - _piece = board.piece_at(move.from_square) - if _piece: - _from_value = evaluate_piece(_piece, move.from_square, endgame) - _to_value = evaluate_piece(_piece, move.to_square, endgame) - position_change = _to_value - _from_value - else: - raise Exception(f"A piece was expected at {move.from_square}") - - capture_value = 0.0 - if board.is_capture(move): - capture_value = evaluate_capture(board, move) - - current_move_value = capture_value + position_change - if board.turn == chess.BLACK: - current_move_value = -current_move_value - - return current_move_value - - -def evaluate_capture(board: chess.Board, move: chess.Move) -> float: - """ - Given a capturing move, weight the trade being made. - """ - if board.is_en_passant(move): - return piece_value[chess.PAWN] - _to = board.piece_at(move.to_square) - _from = board.piece_at(move.from_square) - if _to is None or _from is None: - raise Exception( - f"Pieces were expected at _both_ {move.to_square} and {move.from_square}" - ) - return piece_value[_to.piece_type] - piece_value[_from.piece_type] - - -def evaluate_piece(piece: chess.Piece, square: chess.Square, end_game: bool) -> int: - piece_type = piece.piece_type - mapping = [] - if piece_type == chess.PAWN: - mapping = pawnEvalWhite if piece.color == chess.WHITE else pawnEvalBlack - if piece_type == chess.KNIGHT: - mapping = knightEval - if piece_type == chess.BISHOP: - mapping = bishopEvalWhite if piece.color == chess.WHITE else bishopEvalBlack - if piece_type == chess.ROOK: - mapping = rookEvalWhite if piece.color == chess.WHITE else rookEvalBlack - if piece_type == chess.QUEEN: - mapping = queenEval - if piece_type == chess.KING: - # use end game piece-square tables if neither side has a queen - if end_game: - mapping = ( - kingEvalEndGameWhite - if piece.color == chess.WHITE - else kingEvalEndGameBlack - ) - else: - mapping = kingEvalWhite if piece.color == chess.WHITE else kingEvalBlack - - return mapping[square] - - -def evaluate_board(board: chess.Board) -> float: - """ - Evaluates the full board and determines which player is in a most favorable position. - The sign indicates the side: - (+) for white - (-) for black - The magnitude, how big of an advantage that player has - """ - total = 0 - end_game = check_end_game(board) - - for square in chess.SQUARES: - piece = board.piece_at(square) - if not piece: - continue - - value = piece_value[piece.piece_type] + evaluate_piece(piece, square, end_game) - total += value if piece.color == chess.WHITE else -value - - return total - - -def check_end_game(board: chess.Board) -> bool: - """ - Are we in the end game? - Per Michniewski: - - Both sides have no queens or - - Every side which has a queen has additionally no other pieces or one minorpiece maximum. - """ - queens = 0 - minors = 0 - - for square in chess.SQUARES: - piece = board.piece_at(square) - if piece and piece.piece_type == chess.QUEEN: - queens += 1 - if piece and ( - piece.piece_type == chess.BISHOP or piece.piece_type == chess.KNIGHT - ): - minors += 1 - - if queens == 0 or (queens == 2 and minors <= 1): - return True - - return False diff --git a/src/lib/chess_engines/andoma/movegeneration.py b/src/lib/chess_engines/andoma/movegeneration.py deleted file mode 100644 index edda3ce..0000000 --- a/src/lib/chess_engines/andoma/movegeneration.py +++ /dev/null @@ -1,147 +0,0 @@ -# N.B. Copy-pasted from https://github.com/healeycodes/andoma - -import time -from typing import Any, Dict, List - -import chess - -from .evaluate import check_end_game, evaluate_board, move_value - -debug_info: Dict[str, Any] = {} - - -MATE_SCORE = 1000000000 -MATE_THRESHOLD = 999000000 - - -def next_move(depth: int, board: chess.Board, debug=True) -> chess.Move: - """ - What is the next best move? - """ - debug_info.clear() - debug_info["nodes"] = 0 - t0 = time.time() - - move = minimax_root(depth, board) - - debug_info["time"] = time.time() - t0 - if debug: - print(f"info {debug_info}") - return move - - -def get_ordered_moves(board: chess.Board) -> List[chess.Move]: - """ - Get legal moves. - Attempt to sort moves by best to worst. - Use piece values (and positional gains/losses) to weight captures. - """ - end_game = check_end_game(board) - - def orderer(move): - return move_value(board, move, end_game) - - in_order = sorted( - board.legal_moves, key=orderer, reverse=(board.turn == chess.WHITE) - ) - return list(in_order) - - -def minimax_root(depth: int, board: chess.Board) -> chess.Move: - """ - What is the highest value move per our evaluation function? - """ - # White always wants to maximize (and black to minimize) - # the board score according to evaluate_board() - maximize = board.turn == chess.WHITE - best_move = -float("inf") - if not maximize: - best_move = float("inf") - - moves = get_ordered_moves(board) - best_move_found = moves[0] - - for move in moves: - board.push(move) - # Checking if draw can be claimed at this level, because the threefold repetition check - # can be expensive. This should help the bot avoid a draw if it's not favorable - # https://python-chess.readthedocs.io/en/latest/core.html#chess.Board.can_claim_draw - if board.can_claim_draw(): - value = 0.0 - else: - value = minimax(depth - 1, board, -float("inf"), float("inf"), not maximize) - board.pop() - if maximize and value >= best_move: - best_move = value - best_move_found = move - elif not maximize and value <= best_move: - best_move = value - best_move_found = move - - return best_move_found - - -def minimax( - depth: int, - board: chess.Board, - alpha: float, - beta: float, - is_maximising_player: bool, -) -> float: - """ - Core minimax logic. - https://en.wikipedia.org/wiki/Minimax - """ - debug_info["nodes"] += 1 - - if board.is_checkmate(): - # The previous move resulted in checkmate - return -MATE_SCORE if is_maximising_player else MATE_SCORE - # When the game is over and it's not a checkmate it's a draw - # In this case, don't evaluate. Just return a neutral result: zero - elif board.is_game_over(): - return 0 - - if depth == 0: - return evaluate_board(board) - - if is_maximising_player: - best_move = -float("inf") - moves = get_ordered_moves(board) - for move in moves: - board.push(move) - curr_move = minimax(depth - 1, board, alpha, beta, not is_maximising_player) - # Each ply after a checkmate is slower, so they get ranked slightly less - # We want the fastest mate! - if curr_move > MATE_THRESHOLD: - curr_move -= 1 - elif curr_move < -MATE_THRESHOLD: - curr_move += 1 - best_move = max( - best_move, - curr_move, - ) - board.pop() - alpha = max(alpha, best_move) - if beta <= alpha: - return best_move - return best_move - else: - best_move = float("inf") - moves = get_ordered_moves(board) - for move in moves: - board.push(move) - curr_move = minimax(depth - 1, board, alpha, beta, not is_maximising_player) - if curr_move > MATE_THRESHOLD: - curr_move -= 1 - elif curr_move < -MATE_THRESHOLD: - curr_move += 1 - best_move = min( - best_move, - curr_move, - ) - board.pop() - beta = min(beta, best_move) - if beta <= alpha: - return best_move - return best_move diff --git a/src/lib/chess_engines/sunfish/sunfish.py b/src/lib/chess_engines/sunfish/sunfish.py deleted file mode 100644 index f34ee42..0000000 --- a/src/lib/chess_engines/sunfish/sunfish.py +++ /dev/null @@ -1,877 +0,0 @@ -#!/usr/bin/env pypy -# -*- coding: utf-8 -*- - -# N.B. Copy-pasted from https://github.com/thomasahle/sunfish -# type: ignore - -from __future__ import print_function - -import re -import sys -import time -from collections import namedtuple -from itertools import count - -############################################################################### -# Piece-Square tables. Tune these to change sunfish's behaviour -############################################################################### - -piece = {"P": 100, "N": 280, "B": 320, "R": 479, "Q": 929, "K": 60000} -pst = { - "P": ( - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 78, - 83, - 86, - 73, - 102, - 82, - 85, - 90, - 7, - 29, - 21, - 44, - 40, - 31, - 44, - 7, - -17, - 16, - -2, - 15, - 14, - 0, - 15, - -13, - -26, - 3, - 10, - 9, - 6, - 1, - 0, - -23, - -22, - 9, - 5, - -11, - -10, - -2, - 3, - -19, - -31, - 8, - -7, - -37, - -36, - -14, - 3, - -31, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - ), - "N": ( - -66, - -53, - -75, - -75, - -10, - -55, - -58, - -70, - -3, - -6, - 100, - -36, - 4, - 62, - -4, - -14, - 10, - 67, - 1, - 74, - 73, - 27, - 62, - -2, - 24, - 24, - 45, - 37, - 33, - 41, - 25, - 17, - -1, - 5, - 31, - 21, - 22, - 35, - 2, - 0, - -18, - 10, - 13, - 22, - 18, - 15, - 11, - -14, - -23, - -15, - 2, - 0, - 2, - 0, - -23, - -20, - -74, - -23, - -26, - -24, - -19, - -35, - -22, - -69, - ), - "B": ( - -59, - -78, - -82, - -76, - -23, - -107, - -37, - -50, - -11, - 20, - 35, - -42, - -39, - 31, - 2, - -22, - -9, - 39, - -32, - 41, - 52, - -10, - 28, - -14, - 25, - 17, - 20, - 34, - 26, - 25, - 15, - 10, - 13, - 10, - 17, - 23, - 17, - 16, - 0, - 7, - 14, - 25, - 24, - 15, - 8, - 25, - 20, - 15, - 19, - 20, - 11, - 6, - 7, - 6, - 20, - 16, - -7, - 2, - -15, - -12, - -14, - -15, - -10, - -10, - ), - "R": ( - 35, - 29, - 33, - 4, - 37, - 33, - 56, - 50, - 55, - 29, - 56, - 67, - 55, - 62, - 34, - 60, - 19, - 35, - 28, - 33, - 45, - 27, - 25, - 15, - 0, - 5, - 16, - 13, - 18, - -4, - -9, - -6, - -28, - -35, - -16, - -21, - -13, - -29, - -46, - -30, - -42, - -28, - -42, - -25, - -25, - -35, - -26, - -46, - -53, - -38, - -31, - -26, - -29, - -43, - -44, - -53, - -30, - -24, - -18, - 5, - -2, - -18, - -31, - -32, - ), - "Q": ( - 6, - 1, - -8, - -104, - 69, - 24, - 88, - 26, - 14, - 32, - 60, - -10, - 20, - 76, - 57, - 24, - -2, - 43, - 32, - 60, - 72, - 63, - 43, - 2, - 1, - -16, - 22, - 17, - 25, - 20, - -13, - -6, - -14, - -15, - -2, - -5, - -1, - -10, - -20, - -22, - -30, - -6, - -13, - -11, - -16, - -11, - -16, - -27, - -36, - -18, - 0, - -19, - -15, - -15, - -21, - -38, - -39, - -30, - -31, - -13, - -31, - -36, - -34, - -42, - ), - "K": ( - 4, - 54, - 47, - -99, - -99, - 60, - 83, - -62, - -32, - 10, - 55, - 56, - 56, - 55, - 10, - 3, - -62, - 12, - -57, - 44, - -67, - 28, - 37, - -31, - -55, - 50, - 11, - -4, - -19, - 13, - 0, - -49, - -55, - -43, - -52, - -28, - -51, - -47, - -8, - -50, - -47, - -42, - -43, - -79, - -64, - -32, - -29, - -32, - -4, - 3, - -14, - -50, - -57, - -18, - 13, - 4, - 17, - 30, - -3, - -14, - 6, - -1, - 40, - 18, - ), -} -# Pad tables and join piece and pst dictionaries -for k, table in pst.items(): - - def padrow(row): - return (0,) + tuple(x + piece[k] for x in row) + (0,) - - pst[k] = sum((padrow(table[i * 8 : i * 8 + 8]) for i in range(8)), ()) - pst[k] = (0,) * 20 + pst[k] + (0,) * 20 - -############################################################################### -# Global constants -############################################################################### - -# Our board is represented as a 120 character string. The padding allows for -# fast detection of moves that don't stay within the board. -A1, H1, A8, H8 = 91, 98, 21, 28 -initial = ( - " \n" # 0 - 9 - " \n" # 10 - 19 - " rnbqkbnr\n" # 20 - 29 - " pppppppp\n" # 30 - 39 - " ........\n" # 40 - 49 - " ........\n" # 50 - 59 - " ........\n" # 60 - 69 - " ........\n" # 70 - 79 - " PPPPPPPP\n" # 80 - 89 - " RNBQKBNR\n" # 90 - 99 - " \n" # 100 -109 - " \n" # 110 -119 -) - -# Lists of possible moves for each piece type. -N, E, S, W = -10, 1, 10, -1 -directions = { - "P": (N, N + N, N + W, N + E), - "N": ( - N + N + E, - E + N + E, - E + S + E, - S + S + E, - S + S + W, - W + S + W, - W + N + W, - N + N + W, - ), - "B": (N + E, S + E, S + W, N + W), - "R": (N, E, S, W), - "Q": (N, E, S, W, N + E, S + E, S + W, N + W), - "K": (N, E, S, W, N + E, S + E, S + W, N + W), -} - -# Mate value must be greater than 8*queen + 2*(rook+knight+bishop) -# King value is set to twice this value such that if the opponent is -# 8 queens up, but we got the king, we still exceed MATE_VALUE. -# When a MATE is detected, we'll set the score to MATE_UPPER - plies to get there -# E.g. Mate in 3 will be MATE_UPPER - 6 -MATE_LOWER = piece["K"] - 10 * piece["Q"] -MATE_UPPER = piece["K"] + 10 * piece["Q"] - -# The table size is the maximum number of elements in the transposition table. -TABLE_SIZE = 1e7 - -# Constants for tuning search -QS_LIMIT = 219 -EVAL_ROUGHNESS = 13 -DRAW_TEST = True - - -############################################################################### -# Chess logic -############################################################################### - - -class Position(namedtuple("Position", "board score wc bc ep kp")): - """A state of a chess game - board -- a 120 char representation of the board - score -- the board evaluation - wc -- the castling rights, [west/queen side, east/king side] - bc -- the opponent castling rights, [west/king side, east/queen side] - ep - the en passant square - kp - the king passant square - """ - - def gen_moves(self): - # For each of our pieces, iterate through each possible 'ray' of moves, - # as defined in the 'directions' map. The rays are broken e.g. by - # captures or immediately in case of pieces such as knights. - for i, p in enumerate(self.board): - if not p.isupper(): - continue - for d in directions[p]: - for j in count(i + d, d): - q = self.board[j] - # Stay inside the board, and off friendly pieces - if q.isspace() or q.isupper(): - break - # Pawn move, double move and capture - if p == "P" and d in (N, N + N) and q != ".": - break - if ( - p == "P" - and d == N + N - and (i < A1 + N or self.board[i + N] != ".") - ): - break - if ( - p == "P" - and d in (N + W, N + E) - and q == "." - and j not in (self.ep, self.kp, self.kp - 1, self.kp + 1) - ): - break - # Move it - yield (i, j) - # Stop crawlers from sliding, and sliding after captures - if p in "PNK" or q.islower(): - break - # Castling, by sliding the rook next to the king - if i == A1 and self.board[j + E] == "K" and self.wc[0]: - yield (j + E, j + W) - if i == H1 and self.board[j + W] == "K" and self.wc[1]: - yield (j + W, j + E) - - def rotate(self): - """Rotates the board, preserving enpassant""" - return Position( - self.board[::-1].swapcase(), - -self.score, - self.bc, - self.wc, - 119 - self.ep if self.ep else 0, - 119 - self.kp if self.kp else 0, - ) - - def nullmove(self): - """Like rotate, but clears ep and kp""" - return Position( - self.board[::-1].swapcase(), -self.score, self.bc, self.wc, 0, 0 - ) - - def move(self, move): - i, j = move - p, q = self.board[i], self.board[j] - - def put(board, i, p): - return board[:i] + p + board[i + 1 :] - - # Copy variables and reset ep and kp - board = self.board - wc, bc, ep, kp = self.wc, self.bc, 0, 0 - score = self.score + self.value(move) - # Actual move - board = put(board, j, board[i]) - board = put(board, i, ".") - # Castling rights, we move the rook or capture the opponent's - if i == A1: - wc = (False, wc[1]) - if i == H1: - wc = (wc[0], False) - if j == A8: - bc = (bc[0], False) - if j == H8: - bc = (False, bc[1]) - # Castling - if p == "K": - wc = (False, False) - if abs(j - i) == 2: - kp = (i + j) // 2 - board = put(board, A1 if j < i else H1, ".") - board = put(board, kp, "R") - # Pawn promotion, double move and en passant capture - if p == "P": - if A8 <= j <= H8: - board = put(board, j, "Q") - if j - i == 2 * N: - ep = i + N - if j == self.ep: - board = put(board, j + S, ".") - # We rotate the returned position, so it's ready for the next player - return Position(board, score, wc, bc, ep, kp).rotate() - - def value(self, move): - i, j = move - p, q = self.board[i], self.board[j] - # Actual move - score = pst[p][j] - pst[p][i] - # Capture - if q.islower(): - score += pst[q.upper()][119 - j] - # Castling check detection - if abs(j - self.kp) < 2: - score += pst["K"][119 - j] - # Castling - if p == "K" and abs(i - j) == 2: - score += pst["R"][(i + j) // 2] - score -= pst["R"][A1 if j < i else H1] - # Special pawn stuff - if p == "P": - if A8 <= j <= H8: - score += pst["Q"][j] - pst["P"][j] - if j == self.ep: - score += pst["P"][119 - (j + S)] - return score - - -############################################################################### -# Search logic -############################################################################### - -# lower <= s(pos) <= upper -Entry = namedtuple("Entry", "lower upper") - - -class Searcher: - def __init__(self): - self.tp_score = {} - self.tp_move = {} - self.history = set() - self.nodes = 0 - - def bound(self, pos, gamma, depth, root=True): - """returns r where - s(pos) <= r < gamma if gamma > s(pos) - gamma <= r <= s(pos) if gamma <= s(pos)""" - self.nodes += 1 - - # Depth <= 0 is QSearch. Here any position is searched as deeply as is needed for - # calmness, and from this point on there is no difference in behaviour depending on - # depth, so so there is no reason to keep different depths in the transposition table. - depth = max(depth, 0) - - # Sunfish is a king-capture engine, so we should always check if we - # still have a king. Notice since this is the only termination check, - # the remaining code has to be comfortable with being mated, stalemated - # or able to capture the opponent king. - if pos.score <= -MATE_LOWER: - return -MATE_UPPER - - # We detect 3-fold captures by comparing against previously - # _actually played_ positions. - # Note that we need to do this before we look in the table, as the - # position may have been previously reached with a different score. - # This is what prevents a search instability. - # FIXME: This is not true, since other positions will be affected by - # the new values for all the drawn positions. - if DRAW_TEST: - if not root and pos in self.history: - return 0 - - # Look in the table if we have already searched this position before. - # We also need to be sure, that the stored search was over the same - # nodes as the current search. - entry = self.tp_score.get((pos, depth, root), Entry(-MATE_UPPER, MATE_UPPER)) - if entry.lower >= gamma and (not root or self.tp_move.get(pos) is not None): - return entry.lower - if entry.upper < gamma: - return entry.upper - - # Here extensions may be added - # Such as 'if in_check: depth += 1' - - # Generator of moves to search in order. - # This allows us to define the moves, but only calculate them if needed. - def moves(): - # First try not moving at all. We only do this if there is at least one major - # piece left on the board, since otherwise zugzwangs are too dangerous. - if depth > 0 and not root and any(c in pos.board for c in "RBNQ"): - yield ( - None, - -self.bound(pos.nullmove(), 1 - gamma, depth - 3, root=False), - ) - # For QSearch we have a different kind of null-move, namely we can just stop - # and not capture anything else. - if depth == 0: - yield None, pos.score - # Then killer move. We search it twice, but the tp will fix things for us. - # Note, we don't have to check for legality, since we've already done it - # before. Also note that in QS the killer must be a capture, otherwise we - # will be non deterministic. - killer = self.tp_move.get(pos) - if killer and (depth > 0 or pos.value(killer) >= QS_LIMIT): - yield ( - killer, - -self.bound(pos.move(killer), 1 - gamma, depth - 1, root=False), - ) - # Then all the other moves - for move in sorted(pos.gen_moves(), key=pos.value, reverse=True): - # for val, move in sorted(((pos.value(move), move) for move in pos.gen_moves()), reverse=True): - # If depth == 0 we only try moves with high intrinsic score (captures and - # promotions). Otherwise we do all moves. - if depth > 0 or pos.value(move) >= QS_LIMIT: - yield ( - move, - -self.bound(pos.move(move), 1 - gamma, depth - 1, root=False), - ) - - # Run through the moves, shortcutting when possible - best = -MATE_UPPER - for move, score in moves(): - best = max(best, score) - if best >= gamma: - # Clear before setting, so we always have a value - if len(self.tp_move) > TABLE_SIZE: - self.tp_move.clear() - # Save the move for pv construction and killer heuristic - self.tp_move[pos] = move - break - - # Stalemate checking is a bit tricky: Say we failed low, because - # we can't (legally) move and so the (real) score is -infty. - # At the next depth we are allowed to just return r, -infty <= r < gamma, - # which is normally fine. - # However, what if gamma = -10 and we don't have any legal moves? - # Then the score is actaully a draw and we should fail high! - # Thus, if best < gamma and best < 0 we need to double check what we are doing. - # This doesn't prevent sunfish from making a move that results in stalemate, - # but only if depth == 1, so that's probably fair enough. - # (Btw, at depth 1 we can also mate without realizing.) - if best < gamma and best < 0 and depth > 0: - - def is_dead(pos): - return any(pos.value(m) >= MATE_LOWER for m in pos.gen_moves()) - - if all(is_dead(pos.move(m)) for m in pos.gen_moves()): - in_check = is_dead(pos.nullmove()) - best = -MATE_UPPER if in_check else 0 - - # Clear before setting, so we always have a value - if len(self.tp_score) > TABLE_SIZE: - self.tp_score.clear() - # Table part 2 - if best >= gamma: - self.tp_score[pos, depth, root] = Entry(best, entry.upper) - if best < gamma: - self.tp_score[pos, depth, root] = Entry(entry.lower, best) - - return best - - def search(self, pos, history=()): - """Iterative deepening MTD-bi search""" - self.nodes = 0 - if DRAW_TEST: - self.history = set(history) - # print('# Clearing table due to new history') - self.tp_score.clear() - - # In finished games, we could potentially go far enough to cause a recursion - # limit exception. Hence we bound the ply. - for depth in range(1, 1000): - # The inner loop is a binary search on the score of the position. - # Inv: lower <= score <= upper - # 'while lower != upper' would work, but play tests show a margin of 20 plays - # better. - lower, upper = -MATE_UPPER, MATE_UPPER - while lower < upper - EVAL_ROUGHNESS: - gamma = (lower + upper + 1) // 2 - score = self.bound(pos, gamma, depth) - if score >= gamma: - lower = score - if score < gamma: - upper = score - # We want to make sure the move to play hasn't been kicked out of the table, - # So we make another call that must always fail high and thus produce a move. - self.bound(pos, lower, depth) - # If the game hasn't finished we can retrieve our move from the - # transposition table. - yield ( - depth, - self.tp_move.get(pos), - self.tp_score.get((pos, depth, True)).lower, - ) - - -############################################################################### -# User interface -############################################################################### - -# Python 2 compatability -if sys.version_info[0] == 2: - input = raw_input - - -def parse(c): - fil, rank = ord(c[0]) - ord("a"), int(c[1]) - 1 - return A1 + fil - 10 * rank - - -def render(i): - rank, fil = divmod(i - A1, 10) - return chr(fil + ord("a")) + str(-rank + 1) - - -def print_pos(pos): - print() - uni_pieces = { - "R": "♜", - "N": "♞", - "B": "♝", - "Q": "♛", - "K": "♚", - "P": "♟", - "r": "♖", - "n": "♘", - "b": "♗", - "q": "♕", - "k": "♔", - "p": "♙", - ".": "·", - } - for i, row in enumerate(pos.board.split()): - print(" ", 8 - i, " ".join(uni_pieces.get(p, p) for p in row)) - print(" a b c d e f g h \n\n") - - -def main(): - hist = [Position(initial, 0, (True, True), (True, True), 0, 0)] - searcher = Searcher() - while True: - print_pos(hist[-1]) - - if hist[-1].score <= -MATE_LOWER: - print("You lost") - break - - # We query the user until she enters a (pseudo) legal move. - move = None - while move not in hist[-1].gen_moves(): - match = re.match("([a-h][1-8])" * 2, input("Your move: ")) - if match: - move = parse(match.group(1)), parse(match.group(2)) - else: - # Inform the user when invalid input (e.g. "help") is entered - print("Please enter a move like g8f6") - hist.append(hist[-1].move(move)) - - # After our move we rotate the board and print it again. - # This allows us to see the effect of our move. - print_pos(hist[-1].rotate()) - - if hist[-1].score <= -MATE_LOWER: - print("You won") - break - - # Fire up the engine to look for a move. - start = time.time() - for _depth, move, score in searcher.search(hist[-1], hist): - if time.time() - start > 1: - break - - if score == MATE_UPPER: - print("Checkmate!") - - # The black player moves from a rotated position, so we have to - # 'back rotate' the move before printing it. - print("My move:", render(119 - move[0]) + render(119 - move[1])) - hist.append(hist[-1].move(move)) - - -if __name__ == "__main__": - main() diff --git a/src/lib/chess_engines/sunfish/tools.py b/src/lib/chess_engines/sunfish/tools.py deleted file mode 100644 index 67a2e3b..0000000 --- a/src/lib/chess_engines/sunfish/tools.py +++ /dev/null @@ -1,316 +0,0 @@ -# N.B. Copy-pasted from https://github.com/thomasahle/sunfish - -import itertools -import re -import sys -import time - -from . import sunfish - -################################################################################ -# This module contains functions used by test.py and xboard.py. -# Nothing from here is imported into sunfish.py which is entirely self-sufficient -################################################################################ - -# Sunfish doesn't have to know about colors, but for more advanced things, such -# as xboard support, we have to. -WHITE, BLACK = range(2) - -FEN_INITIAL = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" - - -def search(searcher, pos, secs, history=()): - """This used to be in the Searcher class""" - start = time.time() - for i, (depth, move, score) in enumerate(searcher.search(pos, history)): - if time.time() - start > secs: - break - return i, move, score, depth - - -################################################################################ -# Parse and Render moves -################################################################################ - - -def gen_legal_moves(pos): - """pos.gen_moves(), but without those that leaves us in check. - Also the position after moving is included.""" - for move in pos.gen_moves(): - pos1 = pos.move(move) - if not can_kill_king(pos1): - yield move, pos1 - - -def can_kill_king(pos): - # If we just checked for opponent moves capturing the king, we would miss - # captures in case of illegal castling. - return any(pos.value(m) >= sunfish.MATE_LOWER for m in pos.gen_moves()) - - -def mrender(pos, m): - # Sunfish always assumes promotion to queen - p = "q" if sunfish.A8 <= m[1] <= sunfish.H8 and pos.board[m[0]] == "P" else "" - m = m if get_color(pos) == WHITE else (119 - m[0], 119 - m[1]) - return sunfish.render(m[0]) + sunfish.render(m[1]) + p - - -def mparse(color, move): - m = (sunfish.parse(move[0:2]), sunfish.parse(move[2:4])) - return m if color == WHITE else (119 - m[0], 119 - m[1]) - - -def renderSAN(pos, move): - """Assumes board is rotated to position of current player""" - i, j = move - csrc, cdst = sunfish.render(i), sunfish.render(j) - # Rotate flor black - if get_color(pos) == BLACK: - csrc, cdst = sunfish.render(119 - i), sunfish.render(119 - j) - # Check - pos1 = pos.move(move) - - def cankill(p): - return any(p.board[b] == "k" for a, b in p.gen_moves()) - - check = "" - if cankill(pos1.rotate()): - check = "+" - if all(cankill(pos1.move(move1)) for move1 in pos1.gen_moves()): - check = "#" - # Castling - if pos.board[i] == "K" and abs(i - j) == 2: - if get_color(pos) == WHITE and j > i or get_color(pos) == BLACK and j < i: - return "O-O" + check - else: - return "O-O-O" + check - # Pawn moves - if pos.board[i] == "P": - pro = "=Q" if sunfish.A8 <= j <= sunfish.H8 else "" - cap = csrc[0] + "x" if pos.board[j] != "." or j == pos.ep else "" - return cap + cdst + pro + check - # Figure out what files and ranks we need to include - srcs = [ - a - for (a, b), _ in gen_legal_moves(pos) - if pos.board[a] == pos.board[i] and b == j - ] - srcs_file = [a for a in srcs if (a - sunfish.A1) % 10 == (i - sunfish.A1) % 10] - srcs_rank = [a for a in srcs if (a - sunfish.A1) // 10 == (i - sunfish.A1) // 10] - assert srcs, "No moves compatible with {}".format(move) - if len(srcs) == 1: - src = "" - elif len(srcs_file) == 1: - src = csrc[0] - elif len(srcs_rank) == 1: - src = csrc[1] - else: - src = csrc - # Normal moves - p = pos.board[i] - cap = "x" if pos.board[j] != "." else "" - return p + src + cap + cdst + check - - -def parseSAN(pos, msan): - """Assumes board is rotated to position of current player""" - # Normal moves - normal = re.match("([KQRBN])([a-h])?([1-8])?x?([a-h][1-8])", msan) - if normal: - p, fil, rank, dst = normal.groups() - src = (fil or "[a-h]") + (rank or "[1-8]") - # Pawn moves - pawn = re.match("([a-h])?x?([a-h][1-8])", msan) - if pawn: - assert not re.search( - "[RBN]$", msan - ), "Sunfish only supports queen promotion in {}".format(msan) - p, (fil, dst) = "P", pawn.groups() - src = (fil or "[a-h]") + "[1-8]" - # Castling - if re.match(msan, "O-O-O[+#]?"): - p, src, dst = "K", "e[18]", "c[18]" - if re.match(msan, "O-O[+#]?"): - p, src, dst = "K", "e[18]", "g[18]" - # Find possible match - assert "p" in vars(), "No piece to move with {}".format(msan) - for (i, j), _ in gen_legal_moves(pos): - if get_color(pos) == WHITE: - csrc, cdst = sunfish.render(i), sunfish.render(j) - else: - csrc, cdst = sunfish.render(119 - i), sunfish.render(119 - j) - if pos.board[i] == p and re.match(dst, cdst) and re.match(src, csrc): - return (i, j) - assert False, "Couldn't find legal move matching {}. Had {}".format( - msan, {"p": p, "src": src, "dst": dst, "mvs": list(gen_legal_moves(pos))} - ) - - -def readPGN(file): - """Yields a number of [(pos, move), ...] lists.""" - - def _parse_single_pgn(lines): - # Remove comments and numbers. - parts = re.sub("{.*?}", "", " ".join(lines)).split() - msans = [part for part in parts if not part[0].isdigit()] - pos = parseFEN(FEN_INITIAL) - for msan in msans: - try: - move = parseSAN(pos, msan) - except AssertionError: - print("PGN was:", " ".join(lines)) - raise - yield pos, move - pos = pos.move(move) - - # TODO: Currently assumes all games start at the initial position. - current_game = [] - for line in file: - if line.startswith("["): - if current_game: - yield " ".join(current_game), list(_parse_single_pgn(current_game)) - del current_game[:] - else: - current_game.append(line.strip()) - - -################################################################################ -# Parse and Render positions -################################################################################ - - -def get_color(pos): - """A slightly hacky way to to get the color from a sunfish position""" - return BLACK if pos.board.startswith("\n") else WHITE - - -def parseFEN(fen): - """Parses a string in Forsyth-Edwards Notation into a Position""" - board, color, castling, enpas, _hclock, _fclock = fen.split() - board = re.sub(r"\d", (lambda m: "." * int(m.group(0))), board) - board = list(21 * " " + " ".join(board.split("/")) + 21 * " ") - board[9::10] = ["\n"] * 12 - # if color == 'w': board[::10] = ['\n']*12 - # if color == 'b': board[9::10] = ['\n']*12 - board = "".join(board) - wc = ("Q" in castling, "K" in castling) - bc = ("k" in castling, "q" in castling) - ep = sunfish.parse(enpas) if enpas != "-" else 0 - score = sum(sunfish.pst[p][i] for i, p in enumerate(board) if p.isupper()) - score -= sum( - sunfish.pst[p.upper()][119 - i] for i, p in enumerate(board) if p.islower() - ) - pos = sunfish.Position(board, score, wc, bc, ep, 0) - return pos if color == "w" else pos.rotate() - - -def renderFEN(pos, half_move_clock=0, full_move_clock=1): - color = "wb"[get_color(pos)] - if get_color(pos) == BLACK: - pos = pos.rotate() - board = "/".join(pos.board.split()) - board = re.sub(r"\.+", (lambda m: str(len(m.group(0)))), board) - castling = "".join(itertools.compress("KQkq", pos.wc[::-1] + pos.bc)) or "-" - ep = sunfish.render(pos.ep) if not pos.board[pos.ep].isspace() else "-" - clock = "{} {}".format(half_move_clock, full_move_clock) - return " ".join((board, color, castling, ep, clock)) - - -def parseEPD(epd, opt_dict=False): - epd = epd.strip("\n ;").replace('"', "") - parts = epd.split(maxsplit=6) - opt_part = "" - if len(parts) >= 6 and parts[4].isdigit() and parts[5].isdigit(): - fen = " ".join(parts[:6]) - opt_part = " ".join(parts[6:]) - else: - # Sometimes fen doesn't include half move clocks - fen = " ".join(parts[:4]) + " 0 1" - opt_part = " ".join(parts[4:]) - # EPD operations may either be or ( ) - opts = opt_part.split(";") - if opt_dict: - opts = dict(p.split(maxsplit=1) for p in opts) - return fen, opts - - -################################################################################ -# Pretty print -################################################################################ - - -def pv(searcher, pos, include_scores=True, include_loop=False): - res = [] - seen_pos = set() - color = get_color(pos) - origc = color - if include_scores: - res.append(str(pos.score)) - while True: - move = searcher.tp_move.get(pos) - # The tp may have illegal moves, given lower depths don't detect king killing - if move is None or can_kill_king(pos.move(move)): - break - res.append(mrender(pos, move)) - pos, color = pos.move(move), 1 - color - if pos in seen_pos: - if include_loop: - res.append("loop") - break - seen_pos.add(pos) - if include_scores: - res.append(str(pos.score if color == origc else -pos.score)) - return " ".join(res) - - -################################################################################ -# Bulk move generation -################################################################################ - - -def expand_position(pos): - """Yields a tree of generators [p, [p, [...], ...], ...] rooted at pos""" - yield pos - for _, pos1 in gen_legal_moves(pos): - yield expand_position(pos1) - - -def collect_tree_depth(tree, depth): - """Yields positions exactly at depth""" - root = next(tree) - if depth == 0: - yield root - else: - for subtree in tree: - for pos in collect_tree_depth(subtree, depth - 1): - yield pos - - -def flatten_tree(tree, depth): - """Yields positions exactly at less than depth""" - if depth == 0: - return - yield next(tree) - for subtree in tree: - for pos in flatten_tree(subtree, depth - 1): - yield pos - - -################################################################################ -# Non chess related tools -################################################################################ - - -# Disable buffering -class Unbuffered(object): - def __init__(self, stream): - self.stream = stream - - def write(self, data): - self.stream.write(data) - self.stream.flush() - sys.stderr.write(data) - sys.stderr.flush() - - def __getattr__(self, attr): - return getattr(self.stream, attr) diff --git a/src/lib/django_helpers.py b/src/lib/django_choices_helpers.py similarity index 59% rename from src/lib/django_helpers.py rename to src/lib/django_choices_helpers.py index 9d095b4..4ecd4bd 100644 --- a/src/lib/django_helpers.py +++ b/src/lib/django_choices_helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Literal, TypeAlias if TYPE_CHECKING: @@ -7,9 +9,9 @@ DjangoChoice: TypeAlias = tuple[str | int | float, str] -def enum_to_django_choices(enum_class: "type[enum.Enum]") -> "Sequence[DjangoChoice]": +def enum_to_django_choices(enum_class: type[enum.Enum]) -> Sequence[DjangoChoice]: return [(enum_member.name, enum_member.value) for enum_member in enum_class] -def literal_to_django_choices(literal: "type[Literal]") -> "Sequence[DjangoChoice]": # type: ignore[valid-type] +def literal_to_django_choices(literal: type[Literal]) -> Sequence[DjangoChoice]: # type: ignore[valid-type] return [(value, value) for value in literal.__args__] diff --git a/src/lib/http_cookies_helpers.py b/src/lib/http_cookies_helpers.py new file mode 100644 index 0000000..d3abae5 --- /dev/null +++ b/src/lib/http_cookies_helpers.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, NamedTuple + +if TYPE_CHECKING: + import datetime as dt + + from django.http import HttpResponse + + +class HttpCookieAttributes(NamedTuple): + name: str + max_age: dt.timedelta | None + http_only: bool + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + same_site: Literal["Strict", "Lax", "None", None] = "Lax" + + +def set_http_cookie_on_django_response( + *, response: HttpResponse, attributes: HttpCookieAttributes, value: str +) -> None: + response.set_cookie( + attributes.name, + value, + max_age=attributes.max_age, + httponly=attributes.http_only, + samesite=attributes.same_site, + ) diff --git a/src/project/asgi.py b/src/project/asgi.py index 83ce383..267d813 100644 --- a/src/project/asgi.py +++ b/src/project/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings.production") application = get_asgi_application() diff --git a/src/project/settings/_base.py b/src/project/settings/_base.py index 3ff296c..71f18bf 100644 --- a/src/project/settings/_base.py +++ b/src/project/settings/_base.py @@ -9,6 +9,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/5.1/ref/settings/ """ +from __future__ import annotations from os import environ as env from pathlib import Path @@ -43,17 +44,22 @@ "django_htmx", "axes", # https://github.com/jazzband/django-axes "import_export", # https://django-import-export.readthedocs.io/ + "django_google_fonts", # https://github.com/andymckay/django-google-fonts ] + [ "apps.authentication", "apps.chess", "apps.daily_challenge", + "apps.lichess_bridge", "apps.webui", ] ) MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + # > The WhiteNoise middleware should be placed directly after the + # > Django SecurityMiddleware and before all other middleware + "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -103,9 +109,8 @@ CACHES = { "default": { - # Let's kiss things simple for now, and let each Django worker - # manage their own in-memory cache. - "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "BACKEND": "django.core.cache.backends.db.DatabaseCache", + "LOCATION": "django_cache", } } @@ -188,10 +193,27 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +# Google fonts to mirror locally: +# https://github.com/andymckay/django-google-fonts +GOOGLE_FONTS = ( + "Open Sans:ital,wght@0,300..800;1,300..800", # https://fonts.google.com/specimen/Open+Sans +) +GOOGLE_FONTS_DIR = BASE_DIR / "src" / "apps" / "webui" / "static" # Our custom settings: ZAKUCHESS_VERSION = env.get("ZAKUCHESS_VERSION", "dev") -JS_CHESS_ENGINE = env.get("JS_CHESS_ENGINE", "stockfish") MASTODON_PAGE = env.get("MASTODON_PAGE") CANONICAL_URL = env.get("CANONICAL_URL", "https://zakuchess.com/") + DEBUG_LAYOUT = env.get("DEBUG_LAYOUT", "") == "1" + +# Daily challenge app: +JS_CHESS_ENGINE = env.get("JS_CHESS_ENGINE", "stockfish") + +# Lichess bridge app: +# > Lichess supports unregistered and public clients +# > (no client authentication, choose any unique client id). +# So it's not a kind of API secret we would have created on Lichess' side, but just an +# arbitrary identifier. +LICHESS_CLIENT_ID = env.get("LICHESS_CLIENT_ID", "zakuchess.com") +LICHESS_HOST = env.get("LICHESS_HOST", "https://lichess.org") diff --git a/src/project/settings/development.py b/src/project/settings/development.py index dad00fc..26ec4ce 100644 --- a/src/project/settings/development.py +++ b/src/project/settings/development.py @@ -6,6 +6,14 @@ DEBUG = True +INSTALLED_APPS.insert( + # Make sure `runserver` doesn't try to serve static assets, + # even without the `--no-static` option: + # (https://whitenoise.readthedocs.io/en/stable/django.html#using-whitenoise-in-development) + INSTALLED_APPS.index("django.contrib.staticfiles"), + "whitenoise.runserver_nostatic", +) + INSTALLED_APPS += [ "django_extensions", ] @@ -13,9 +21,17 @@ LOGGING = { "version": 1, "disable_existing_loggers": False, + "formatters": { + "uvicorn": { + "()": "uvicorn.logging.DefaultFormatter", + "fmt": "%(levelprefix)s [%(name)s] %(message)s", + "use_colors": True, + }, + }, "handlers": { "console": { "class": "logging.StreamHandler", + "formatter": "uvicorn", }, }, "root": { diff --git a/src/project/settings/flyio.py b/src/project/settings/flyio.py index 66ada54..83cc8ac 100644 --- a/src/project/settings/flyio.py +++ b/src/project/settings/flyio.py @@ -1,6 +1,8 @@ from .production import * USE_X_FORWARDED_HOST = True # Fly.io always sends request through a proxy +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + # @link https://django-axes.readthedocs.io/en/latest/4_configuration.html#configuring-reverse-proxies AXES_IPWARE_PROXY_COUNT = 1 diff --git a/src/project/settings/production.py b/src/project/settings/production.py index 0ea6ef9..8cb7143 100644 --- a/src/project/settings/production.py +++ b/src/project/settings/production.py @@ -10,12 +10,6 @@ # Static assets served by Whitenoise on production # @link http://whitenoise.evans.io/en/stable/ -# > The WhiteNoise middleware should be placed directly after the -# > Django SecurityMiddleware and before all other middleware -MIDDLEWARE.insert( - MIDDLEWARE.index("django.middleware.security.SecurityMiddleware") + 1, - "whitenoise.middleware.WhiteNoiseMiddleware", -) STORAGES["staticfiles"] = { "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", } diff --git a/src/project/tests/test_alive_probe.py b/src/project/tests/test_alive_probe.py index 5185299..0d41282 100644 --- a/src/project/tests/test_alive_probe.py +++ b/src/project/tests/test_alive_probe.py @@ -1,10 +1,12 @@ +from __future__ import annotations + from typing import TYPE_CHECKING if TYPE_CHECKING: from django.test import Client as DjangoClient -def test_alive(client: "DjangoClient"): +def test_alive(client: DjangoClient): resp = client.get("/-/alive/") assert resp.status_code == 200 assert resp.content == b"ok" diff --git a/src/project/urls.py b/src/project/urls.py index eb1742f..fc867cf 100644 --- a/src/project/urls.py +++ b/src/project/urls.py @@ -1,10 +1,9 @@ """project URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/5.0/topics/http/urls/ + https://docs.djangoproject.com/en/5.1/topics/http/urls/ """ -from django.conf import settings from django.contrib import admin from django.urls import include, path, register_converter @@ -13,14 +12,9 @@ register_converter(ChessSquareConverter, "square") urlpatterns = [ + path("", include("apps.webui.urls")), path("", include("apps.daily_challenge.urls")), + path("lichess/", include("apps.lichess_bridge.urls")), path("-/", include("django_alive.urls")), path("admin/", admin.site.urls), ] - -if settings.DEBUG: - # @link https://docs.djangoproject.com/en/5.0/howto/static-files/ - - from django.conf.urls.static import static - - urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/tailwind.config.js b/tailwind.config.js index 66b8a71..f234d6b 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -8,22 +8,27 @@ const PIECE_NAMES = ["pawn", "knight", "bishop", "rook", "queen", "king"] const PLAYER_SIDES = ["w", "b"] const FACTIONS = ["humans", "undeads"] -const ACTIVE_PLAYER_SELECTION_COLOR = "#ffff00" -const OPPONENT_PLAYER_SELECTION_COLOR = "#ffd000" +const PLAYABLE_SELECTION_COLOR = "#ffff00" +const NON_PLAYABLE_SELECTION_COLOR = "#ffd000" const POTENTIAL_CAPTURE_COLOR = "#c00000" -const PIECE_SYMBOL_BORDER_OPACITY = Math.round(0.4 * 0xff).toString(16) // 40% of 255 +const PIECE_UNIT_BORDER_OPACITY = Math.round(0.5 * 0xff).toString(16) // 50% of 255 +const PIECE_SYMBOL_BORDER_OPACITY = Math.round(0.75 * 0xff).toString(16) // 75% of 255 +const PIECE_UNIT_W = `#065f46${PIECE_UNIT_BORDER_OPACITY}` // emerald-800 +const PIECE_UNIT_B = `#3730a3${PIECE_UNIT_BORDER_OPACITY}` // indigo-800 const PIECE_SYMBOL_W = `#065f46${PIECE_SYMBOL_BORDER_OPACITY}` // emerald-800 -const PIECE_SYMBOL_B = `#3730a3${PIECE_SYMBOL_BORDER_OPACITY}` // indigo-800 +const PIECE_SYMBOL_B = `#a855f7${PIECE_SYMBOL_BORDER_OPACITY}` // purple-500 const PIECES_DROP_SHADOW_OFFSET = 1 // px const SPEECH_BUBBLE_DROP_SHADOW_COLOR = "#fbbf24" // amber-400 +// https://github.com/tailwindlabs/tailwindcss/blob/main/stubs/config.full.js + /** @type {import('tailwindcss').Config} */ module.exports = { content: ["./src/apps/*/components/**/*.py"], safelist: chessRelatedClassesSafeList(), theme: { fontFamily: { - sans: ["OpenSans", "sans-serif"], + sans: ["Open Sans", "sans-serif"], pixel: ["PixelFont", "monospace"], mono: ["monospace"], }, @@ -35,8 +40,8 @@ module.exports = { "chess-square-dark": "#a57713", // "#881337", // Amber 700 // "#a57713", // "#9f1239", "chess-square-square-info": "#58400b", "body-background": "#120222", // @link https://www.tints.dev/purple/A855F7 - "active-chess-available-target-marker": ACTIVE_PLAYER_SELECTION_COLOR, - "opponent-chess-available-target-marker": OPPONENT_PLAYER_SELECTION_COLOR, + "playable-chess-available-target-marker": PLAYABLE_SELECTION_COLOR, + "non-playable-chess-available-target-marker": NON_PLAYABLE_SELECTION_COLOR, }, width: { "1/8": "12.5%", @@ -80,21 +85,21 @@ module.exports = { size: "width, height", }, dropShadow: { - // "piece-symbol-w": `0 0 0.1rem ${PIECE_SYMBOL_W}`, - // "piece-symbol-b": `0 0 0.1rem ${PIECE_SYMBOL_B}`, + "piece-unit-w": borderFromDropShadow(1, PIECE_UNIT_W), + "piece-unit-b": borderFromDropShadow(1, PIECE_UNIT_B), "piece-symbol-w": borderFromDropShadow(1, PIECE_SYMBOL_W), "piece-symbol-b": borderFromDropShadow(1, PIECE_SYMBOL_B), - "active-selected-piece": borderFromDropShadow( - PIECES_DROP_SHADOW_OFFSET, - ACTIVE_PLAYER_SELECTION_COLOR, - ), - "opponent-selected-piece": borderFromDropShadow( + "playable-selected-piece": borderFromDropShadow(PIECES_DROP_SHADOW_OFFSET, PLAYABLE_SELECTION_COLOR), + "non-playable-selected-piece": borderFromDropShadow( PIECES_DROP_SHADOW_OFFSET, - OPPONENT_PLAYER_SELECTION_COLOR, + NON_PLAYABLE_SELECTION_COLOR, ), "potential-capture": borderFromDropShadow(PIECES_DROP_SHADOW_OFFSET, POTENTIAL_CAPTURE_COLOR), "speech-bubble": `0 0 2px ${SPEECH_BUBBLE_DROP_SHADOW_COLOR}`, }, + brightness: { + 60: ".6", + }, }, }, plugins: [], diff --git a/uv.lock b/uv.lock index 695f977..6797eac 100644 --- a/uv.lock +++ b/uv.lock @@ -1,7 +1,7 @@ version = 1 requires-python = ">=3.11" resolution-markers = [ - "python_full_version == '3.11'", + "python_full_version <= '3.11'", "python_full_version > '3.11'", ] @@ -39,6 +39,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/86/4736ac618d82a20d87d2f92ae19441ebc7ac9e7a581d7e58bbe79233b24a/asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24", size = 27764 }, ] +[[package]] +name = "authlib" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/75/47dbab150ef6f9298e227a40c93c7fed5f3ffb67c9fb62cd49f66285e46e/authlib-1.3.2.tar.gz", hash = "sha256:4b16130117f9eb82aa6eec97f6dd4673c3f960ac0283ccdae2897ee4bc030ba2", size = 147313 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/4c/9aa0416a403d5cc80292cb030bcd2c918cce2755e314d8c1aa18656e1e12/Authlib-1.3.2-py2.py3-none-any.whl", hash = "sha256:ede026a95e9f5cdc2d4364a52103f5405e75aa156357e831ef2bfd0bc5094dfc", size = 225111 }, +] + [[package]] name = "blinker" version = "1.8.2" @@ -64,8 +76,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/d6/0bd38d758d1afa62a5524172f0b18626bb2392d717ff94806f741fcd5ee9/Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9", size = 2813051 }, { url = "https://files.pythonhosted.org/packages/14/56/48859dd5d129d7519e001f06dcfbb6e2cf6db92b2702c0c2ce7d97e086c1/Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265", size = 2938172 }, { url = "https://files.pythonhosted.org/packages/3d/77/a236d5f8cd9e9f4348da5acc75ab032ab1ab2c03cc8f430d24eea2672888/Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8", size = 2933023 }, + { url = "https://files.pythonhosted.org/packages/f1/87/3b283efc0f5cb35f7f84c0c240b1e1a1003a5e47141a4881bf87c86d0ce2/Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f", size = 2935871 }, + { url = "https://files.pythonhosted.org/packages/f3/eb/2be4cc3e2141dc1a43ad4ca1875a72088229de38c68e842746b342667b2a/Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757", size = 2847784 }, + { url = "https://files.pythonhosted.org/packages/66/13/b58ddebfd35edde572ccefe6890cf7c493f0c319aad2a5badee134b4d8ec/Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0", size = 3034905 }, + { url = "https://files.pythonhosted.org/packages/84/9c/bc96b6c7db824998a49ed3b38e441a2cae9234da6fa11f6ed17e8cf4f147/Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b", size = 2929467 }, { url = "https://files.pythonhosted.org/packages/e7/71/8f161dee223c7ff7fea9d44893fba953ce97cf2c3c33f78ba260a91bcff5/Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50", size = 333169 }, { url = "https://files.pythonhosted.org/packages/02/8a/fece0ee1057643cb2a5bbf59682de13f1725f8482b2c057d4e799d7ade75/Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1", size = 357253 }, + { url = "https://files.pythonhosted.org/packages/5c/d0/5373ae13b93fe00095a58efcbce837fd470ca39f703a235d2a999baadfbc/Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28", size = 815693 }, + { url = "https://files.pythonhosted.org/packages/8e/48/f6e1cdf86751300c288c1459724bfa6917a80e30dbfc326f92cea5d3683a/Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f", size = 422489 }, { url = "https://files.pythonhosted.org/packages/06/88/564958cedce636d0f1bed313381dfc4b4e3d3f6015a63dae6146e1b8c65c/Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409", size = 873081 }, { url = "https://files.pythonhosted.org/packages/58/79/b7026a8bb65da9a6bb7d14329fd2bd48d2b7f86d7329d5cc8ddc6a90526f/Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2", size = 446244 }, { url = "https://files.pythonhosted.org/packages/e5/18/c18c32ecea41b6c0004e15606e274006366fe19436b6adccc1ae7b2e50c2/Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451", size = 2906505 }, @@ -76,8 +94,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/58/5c391b41ecfc4527d2cc3350719b02e87cb424ef8ba2023fb662f9bf743c/Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180", size = 2814452 }, { url = "https://files.pythonhosted.org/packages/c7/4e/91b8256dfe99c407f174924b65a01f5305e303f486cc7a2e8a5d43c8bec3/Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248", size = 2938751 }, { url = "https://files.pythonhosted.org/packages/5a/a6/e2a39a5d3b412938362bbbeba5af904092bf3f95b867b4a3eb856104074e/Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966", size = 2933757 }, + { url = "https://files.pythonhosted.org/packages/13/f0/358354786280a509482e0e77c1a5459e439766597d280f28cb097642fc26/Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9", size = 2936146 }, + { url = "https://files.pythonhosted.org/packages/80/f7/daf538c1060d3a88266b80ecc1d1c98b79553b3f117a485653f17070ea2a/Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb", size = 2848055 }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0eaa0585c4077d3c2d1edf322d8e97aabf317941d3a72d7b3ad8bce004b0/Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111", size = 3035102 }, + { url = "https://files.pythonhosted.org/packages/d8/63/1c1585b2aa554fe6dbce30f0c18bdbc877fa9a1bf5ff17677d9cca0ac122/Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839", size = 2930029 }, { url = "https://files.pythonhosted.org/packages/5f/3b/4e3fd1893eb3bbfef8e5a80d4508bec17a57bb92d586c85c12d28666bb13/Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0", size = 333276 }, { url = "https://files.pythonhosted.org/packages/3d/d5/942051b45a9e883b5b6e98c041698b1eb2012d25e5948c58d6bf85b1bb43/Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951", size = 357255 }, + { url = "https://files.pythonhosted.org/packages/0a/9f/fb37bb8ffc52a8da37b1c03c459a8cd55df7a57bdccd8831d500e994a0ca/Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5", size = 815681 }, + { url = "https://files.pythonhosted.org/packages/06/b3/dbd332a988586fefb0aa49c779f59f47cae76855c2d00f450364bb574cac/Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8", size = 422475 }, + { url = "https://files.pythonhosted.org/packages/bb/80/6aaddc2f63dbcf2d93c2d204e49c11a9ec93a8c7c63261e2b4bd35198283/Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f", size = 2906173 }, + { url = "https://files.pythonhosted.org/packages/ea/1d/e6ca79c96ff5b641df6097d299347507d39a9604bde8915e76bf026d6c77/Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648", size = 2943803 }, + { url = "https://files.pythonhosted.org/packages/ac/a3/d98d2472e0130b7dd3acdbb7f390d478123dbf62b7d32bda5c830a96116d/Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0", size = 2918946 }, + { url = "https://files.pythonhosted.org/packages/c4/a5/c69e6d272aee3e1423ed005d8915a7eaa0384c7de503da987f2d224d0721/Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089", size = 2845707 }, + { url = "https://files.pythonhosted.org/packages/58/9f/4149d38b52725afa39067350696c09526de0125ebfbaab5acc5af28b42ea/Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368", size = 2936231 }, + { url = "https://files.pythonhosted.org/packages/5a/5a/145de884285611838a16bebfdb060c231c52b8f84dfbe52b852a15780386/Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c", size = 2848157 }, + { url = "https://files.pythonhosted.org/packages/50/ae/408b6bfb8525dadebd3b3dd5b19d631da4f7d46420321db44cd99dcf2f2c/Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284", size = 3035122 }, + { url = "https://files.pythonhosted.org/packages/af/85/a94e5cfaa0ca449d8f91c3d6f78313ebf919a0dbd55a100c711c6e9655bc/Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7", size = 2930206 }, + { url = "https://files.pythonhosted.org/packages/c2/f0/a61d9262cd01351df22e57ad7c34f66794709acab13f34be2675f45bf89d/Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0", size = 333804 }, + { url = "https://files.pythonhosted.org/packages/7e/c1/ec214e9c94000d1c1974ec67ced1c970c148aa6b8d8373066123fc3dbf06/Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b", size = 358517 }, ] [[package]] @@ -283,25 +317,45 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version == '3.11'" }, + { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] -name = "decorator" -version = "5.1.1" +name = "cryptography" +version = "43.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/0c/8d907af351aa16b42caae42f9d6aa37b900c67308052d10fdce809f8d952/decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330", size = 35016 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/50/83c593b07763e1161326b3b8c6686f0f4b0f24d5526546bee538c89837d6/decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186", size = 9073 }, +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ba/0664727028b37e249e73879348cc46d45c5c1a2a2e81e8166462953c5755/cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d", size = 686927 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/28/b92c98a04ba762f8cdeb54eba5c4c84e63cac037a7c5e70117d337b15ad6/cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d", size = 6223222 }, + { url = "https://files.pythonhosted.org/packages/33/13/1193774705783ba364121aa2a60132fa31a668b8ababd5edfa1662354ccd/cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062", size = 3794751 }, + { url = "https://files.pythonhosted.org/packages/5e/4b/39bb3c4c8cfb3e94e736b8d8859ce5c81536e91a1033b1d26770c4249000/cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962", size = 3981827 }, + { url = "https://files.pythonhosted.org/packages/ce/dc/1471d4d56608e1013237af334b8a4c35d53895694fbb73882d1c4fd3f55e/cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277", size = 3780034 }, + { url = "https://files.pythonhosted.org/packages/ad/43/7a9920135b0d5437cc2f8f529fa757431eb6a7736ddfadfdee1cc5890800/cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a", size = 3993407 }, + { url = "https://files.pythonhosted.org/packages/cc/42/9ab8467af6c0b76f3d9b8f01d1cf25b9c9f3f2151f4acfab888d21c55a72/cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042", size = 3886457 }, + { url = "https://files.pythonhosted.org/packages/a4/65/430509e31700286ec02868a2457d2111d03ccefc20349d24e58d171ae0a7/cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494", size = 4081499 }, + { url = "https://files.pythonhosted.org/packages/bb/18/a04b6467e6e09df8c73b91dcee8878f4a438a43a3603dc3cd6f8003b92d8/cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2", size = 2616504 }, + { url = "https://files.pythonhosted.org/packages/cc/73/0eacbdc437202edcbdc07f3576ed8fb8b0ab79d27bf2c5d822d758a72faa/cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d", size = 3067456 }, + { url = "https://files.pythonhosted.org/packages/8a/b6/bc54b371f02cffd35ff8dc6baba88304d7cf8e83632566b4b42e00383e03/cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d", size = 6225263 }, + { url = "https://files.pythonhosted.org/packages/00/0e/8217e348a1fa417ec4c78cd3cdf24154f5e76fd7597343a35bd403650dfd/cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806", size = 3794368 }, + { url = "https://files.pythonhosted.org/packages/3d/ed/38b6be7254d8f7251fde8054af597ee8afa14f911da67a9410a45f602fc3/cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85", size = 3981750 }, + { url = "https://files.pythonhosted.org/packages/64/f3/b7946c3887cf7436f002f4cbb1e6aec77b8d299b86be48eeadfefb937c4b/cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c", size = 3778925 }, + { url = "https://files.pythonhosted.org/packages/ac/7e/ebda4dd4ae098a0990753efbb4b50954f1d03003846b943ea85070782da7/cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1", size = 3993152 }, + { url = "https://files.pythonhosted.org/packages/43/f6/feebbd78a3e341e3913846a3bb2c29d0b09b1b3af1573c6baabc2533e147/cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa", size = 3886392 }, + { url = "https://files.pythonhosted.org/packages/bd/4c/ab0b9407d5247576290b4fd8abd06b7f51bd414f04eef0f2800675512d61/cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4", size = 4082606 }, + { url = "https://files.pythonhosted.org/packages/05/36/e532a671998d6fcfdb9122da16434347a58a6bae9465e527e450e0bc60a5/cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47", size = 2617948 }, + { url = "https://files.pythonhosted.org/packages/b3/c6/c09cee6968add5ff868525c3815e5dccc0e3c6e89eec58dc9135d3c40e88/cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb", size = 3070445 }, ] [[package]] -name = "defusedxml" -version = "0.7.1" +name = "decorator" +version = "5.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } +sdist = { url = "https://files.pythonhosted.org/packages/66/0c/8d907af351aa16b42caae42f9d6aa37b900c67308052d10fdce809f8d952/decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330", size = 35016 } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, + { url = "https://files.pythonhosted.org/packages/d5/50/83c593b07763e1161326b3b8c6686f0f4b0f24d5526546bee538c89837d6/decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186", size = 9073 }, ] [[package]] @@ -337,16 +391,16 @@ wheels = [ [[package]] name = "django" -version = "5.1.1" +version = "5.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/88/6f/8f57ed6dc88656edd4fcb35c50dd963f3cd79303bd711fb0160fc7fd6ab7/Django-5.1.1.tar.gz", hash = "sha256:021ffb7fdab3d2d388bc8c7c2434eb9c1f6f4d09e6119010bbb1694dda286bc2", size = 10675933 } +sdist = { url = "https://files.pythonhosted.org/packages/d3/e8/536555596dbb79f6e77418aeb40bdc1758c26725aba31919ba449e6d5e6a/Django-5.1.4.tar.gz", hash = "sha256:de450c09e91879fa5a307f696e57c851955c910a438a35e6b4c895e86bedc82a", size = 10716397 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/aa/b423e37e9ba5480d3fd1d187e3fdbd09f9f71b991468881a45413522ccd3/Django-5.1.1-py3-none-any.whl", hash = "sha256:71603f27dac22a6533fb38d83072eea9ddb4017fead6f67f2562a40402d61c3f", size = 8246418 }, + { url = "https://files.pythonhosted.org/packages/58/0b/8a4ab2c02982df4ed41e29f28f189459a7eba37899438e6bea7f39db793b/Django-5.1.4-py3-none-any.whl", hash = "sha256:236e023f021f5ce7dee5779de7b286565fdea5f4ab86bae5338e3f7b69896cf0", size = 8276471 }, ] [[package]] @@ -363,15 +417,15 @@ wheels = [ [[package]] name = "django-axes" -version = "6.1.1" +version = "6.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "asgiref" }, { name = "django" }, - { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/0c/5efb4ddf29afaf2353b6de1c4561d34bb462762af8a438c915d6d859facd/django-axes-6.1.1.tar.gz", hash = "sha256:cd1bc4f7becc8e9243eb4090dffa258d7d7125ca0ce3153b6ffc920bccbf2c3f", size = 244358 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/cc/383f9c247f65e5e2e90df4a2be0c982053684364349b51c1d1509d6e7a75/django_axes-6.5.2.tar.gz", hash = "sha256:c2c007d61a3de018ef97649350dc15e5663cd9def1a05de8eeb0fe6adc1ed2ab", size = 246681 } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/80/d2ac4181d65cc75199eed1a76a8526daaad4242707d5aaae7d220e5cc145/django_axes-6.1.1-py3-none-any.whl", hash = "sha256:29c48ff5f09046afd5e9a16e96d3bbb79f6c11c59f0a7bbd732559e60d0aa9fa", size = 64396 }, + { url = "https://files.pythonhosted.org/packages/da/89/340932f7ef3adeab4aa4a1824a7f094bd24be86025c62bd26c0183e0ff0c/django_axes-6.5.2-py3-none-any.whl", hash = "sha256:fab92b98032fff55d7d2e0fdfade2be189949b6590b9e0c90e6fd213dd70bb67", size = 68447 }, ] [package.optional-dependencies] @@ -391,6 +445,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/7e/ba12b9660642663f5273141018d2bec0a1cae1711f4f6d1093920e157946/django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401", size = 229868 }, ] +[[package]] +name = "django-google-fonts" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "tinycss2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/e9/a0d5a8988e0450444f69f99c94a896da5c4a0f8405ab8ab863cc377ece44/django_google_fonts-0.0.3.tar.gz", hash = "sha256:0439c4d89919970b141258bd3be0a085cc538f4e9c53e54f40eb2953ec2e5d30", size = 6878 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/35/7096ffbfadbd9acf332d99de078a56a48d7632f5b8c56714aa29f3333b36/django_google_fonts-0.0.3-py3-none-any.whl", hash = "sha256:f8f0b943107932d5fbb66da26f2088e0c6d89cde8b66ed66c3f58a6f40d54994", size = 7944 }, +] + [[package]] name = "django-htmx" version = "1.13.0" @@ -405,16 +472,16 @@ wheels = [ [[package]] name = "django-import-export" -version = "3.3.9" +version = "4.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "diff-match-patch" }, { name = "django" }, - { name = "tablib", extra = ["html", "ods", "xls", "xlsx", "yaml"] }, + { name = "tablib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/dc/915a0df2b9006cfd6870e04967964eacdfed0db3d5edf61a72b0d199407a/django_import_export-3.3.9.tar.gz", hash = "sha256:16797965e93a8001fe812c61e3b71fb858c57c1bd16da195fe276d6de685348e", size = 64625 } +sdist = { url = "https://files.pythonhosted.org/packages/29/77/b23eaeb57802999d1b4bcebeb6afb11ab9666879fc43547df725fc516016/django_import_export-4.1.1.tar.gz", hash = "sha256:16ecc5a9f0df46bde6eb278a3e65ebda0ee1db55656f36440e9fb83f40ab85a3", size = 2350049 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/06/2b474ddf52d5e2e46ea821789de6a76bc50996f0a592fdd29af1891d20af/django_import_export-3.3.9-py3-none-any.whl", hash = "sha256:dd6cabc08ed6d1bd37a392e7fb542bd7d196b615c800168f5c69f0f55f49b103", size = 112693 }, + { url = "https://files.pythonhosted.org/packages/02/49/9101262deb3c0c832071da8087abfdf160b52bf3a7b92d79898c39d038c2/django_import_export-4.1.1-py3-none-any.whl", hash = "sha256:730ae2443a02b1ba27d8dba078a27ae9123adfcabb78161b4f130843607b3df9", size = 134031 }, ] [[package]] @@ -438,15 +505,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/71/273f51cd3be1d3279ce3fc63bb46bdb4a82bb9445e9684100618124f9920/dominate-2.7.0-py2.py3-none-any.whl", hash = "sha256:5fe4258614687c6d3de67b0bbd881ed435a93a19742ae187344055db17052402", size = 29372 }, ] -[[package]] -name = "et-xmlfile" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3d/5d/0413a31d184a20c763ad741cc7852a659bf15094c24840c5bdd1754765cd/et_xmlfile-1.1.0.tar.gz", hash = "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c", size = 3218 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/c2/3dd434b0108730014f1b96fd286040dc3bcb70066346f7e01ec2ac95865f/et_xmlfile-1.1.0-py3-none-any.whl", hash = "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada", size = 4688 }, -] - [[package]] name = "executing" version = "2.1.0" @@ -465,6 +523,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/f0/48285f0262fe47103a4a45972ed2f9b93e4c80b8fd609fa98da78b2a5706/filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7", size = 16159 }, ] +[[package]] +name = "fix-future-annotations" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tokenize-rt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/56/d5ebafd3f1c46b43d5edd491359a870e224613ada3d1b6358a114d68d59e/fix-future-annotations-0.5.0.tar.gz", hash = "sha256:666bf5fd09068f3403b34b2dc69844955a4a7bac4df28dd345183bd18172cbfd", size = 11261 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/eb/7f2e259f542145d7bc4f4d664b7d9dedfa9da6ac564efeac3c5e6fa2cb33/fix_future_annotations-0.5.0-py3-none-any.whl", hash = "sha256:45b5e73b36c514a23c2fbb2b7ff26448496399bda3b98a7b78a14439639f2e59", size = 10149 }, +] + [[package]] name = "flask" version = "3.0.3" @@ -638,9 +708,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/d4/e5d7e4f2174f8a4d63c8897d79eb8fe2503f7ecc03282fee1fa2719c2704/httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5", size = 77926 }, ] +[[package]] +name = "httptools" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/d77686502fced061b3ead1c35a2d70f6b281b5f723c4eff7a2277c04e4a2/httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a", size = 191228 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/d1/53283b96ed823d5e4d89ee9aa0f29df5a1bdf67f148e061549a595d534e4/httptools-0.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7a7ea483c1a4485c71cb5f38be9db078f8b0e8b4c4dc0210f531cdd2ddac1ef1", size = 145855 }, + { url = "https://files.pythonhosted.org/packages/80/dd/cebc9d4b1d4b70e9f3d40d1db0829a28d57ca139d0b04197713816a11996/httptools-0.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85ed077c995e942b6f1b07583e4eb0a8d324d418954fc6af913d36db7c05a5a0", size = 75604 }, + { url = "https://files.pythonhosted.org/packages/76/7a/45c5a9a2e9d21f7381866eb7b6ead5a84d8fe7e54e35208eeb18320a29b4/httptools-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0bb634338334385351a1600a73e558ce619af390c2b38386206ac6a27fecfc", size = 324784 }, + { url = "https://files.pythonhosted.org/packages/59/23/047a89e66045232fb82c50ae57699e40f70e073ae5ccd53f54e532fbd2a2/httptools-0.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9ceb2c957320def533671fc9c715a80c47025139c8d1f3797477decbc6edd2", size = 318547 }, + { url = "https://files.pythonhosted.org/packages/82/f5/50708abc7965d7d93c0ee14a148ccc6d078a508f47fe9357c79d5360f252/httptools-0.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4f0f8271c0a4db459f9dc807acd0eadd4839934a4b9b892f6f160e94da309837", size = 330211 }, + { url = "https://files.pythonhosted.org/packages/e3/1e/9823ca7aab323c0e0e9dd82ce835a6e93b69f69aedffbc94d31e327f4283/httptools-0.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6a4f5ccead6d18ec072ac0b84420e95d27c1cdf5c9f1bc8fbd8daf86bd94f43d", size = 322174 }, + { url = "https://files.pythonhosted.org/packages/14/e4/20d28dfe7f5b5603b6b04c33bb88662ad749de51f0c539a561f235f42666/httptools-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:5cceac09f164bcba55c0500a18fe3c47df29b62353198e4f37bbcc5d591172c3", size = 55434 }, + { url = "https://files.pythonhosted.org/packages/60/13/b62e086b650752adf9094b7e62dab97f4cb7701005664544494b7956a51e/httptools-0.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:75c8022dca7935cba14741a42744eee13ba05db00b27a4b940f0d646bd4d56d0", size = 146354 }, + { url = "https://files.pythonhosted.org/packages/f8/5d/9ad32b79b6c24524087e78aa3f0a2dfcf58c11c90e090e4593b35def8a86/httptools-0.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:48ed8129cd9a0d62cf4d1575fcf90fb37e3ff7d5654d3a5814eb3d55f36478c2", size = 75785 }, + { url = "https://files.pythonhosted.org/packages/d0/a4/b503851c40f20bcbd453db24ed35d961f62abdae0dccc8f672cd5d350d87/httptools-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f58e335a1402fb5a650e271e8c2d03cfa7cea46ae124649346d17bd30d59c90", size = 345396 }, + { url = "https://files.pythonhosted.org/packages/a2/9a/aa406864f3108e06f7320425a528ff8267124dead1fd72a3e9da2067f893/httptools-0.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ad80d7176aa5788902f207a4e79885f0576134695dfb0fefc15b7a4648d503", size = 344741 }, + { url = "https://files.pythonhosted.org/packages/cf/3a/3fd8dfb987c4247651baf2ac6f28e8e9f889d484ca1a41a9ad0f04dfe300/httptools-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9bb68d3a085c2174c2477eb3ffe84ae9fb4fde8792edb7bcd09a1d8467e30a84", size = 345096 }, + { url = "https://files.pythonhosted.org/packages/80/01/379f6466d8e2edb861c1f44ccac255ed1f8a0d4c5c666a1ceb34caad7555/httptools-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b512aa728bc02354e5ac086ce76c3ce635b62f5fbc32ab7082b5e582d27867bb", size = 343535 }, + { url = "https://files.pythonhosted.org/packages/d3/97/60860e9ee87a7d4712b98f7e1411730520053b9d69e9e42b0b9751809c17/httptools-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:97662ce7fb196c785344d00d638fc9ad69e18ee4bfb4000b35a52efe5adcc949", size = 55660 }, +] + [[package]] name = "httpx" -version = "0.26.0" +version = "0.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -649,9 +741,9 @@ dependencies = [ { name = "idna" }, { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/26/2dc654950920f499bd062a211071925533f821ccdca04fa0c2fd914d5d06/httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf", size = 125671 } +sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/9b/4937d841aee9c2c8102d9a4eeb800c7dad25386caabb4a1bf5010df81a57/httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd", size = 75862 }, + { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, ] [[package]] @@ -758,12 +850,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/6b/3aa29e826ce02a3ca00b2e14f4955de0ef9e749badca04cac12c9eb562d4/locust-2.31.5-py3-none-any.whl", hash = "sha256:2904ff6307d54d3202c9ebd776f9170214f6dfbe4059504dad9e3ffaca03f600", size = 1204517 }, ] -[[package]] -name = "markuppy" -version = "1.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/ca/f43541b41bd17fc945cfae7ea44f1661dc21ea65ecc944a6fa138eead94c/MarkupPy-1.14.tar.gz", hash = "sha256:1adee2c0a542af378fe84548ff6f6b0168f3cb7f426b46961038a2bcfaad0d5f", size = 6815 } - [[package]] name = "markupsafe" version = "2.1.5" @@ -897,27 +983,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, ] -[[package]] -name = "odfpy" -version = "1.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "defusedxml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/97/73/8ade73f6749177003f7ce3304f524774adda96e6aaab30ea79fd8fda7934/odfpy-1.4.1.tar.gz", hash = "sha256:db766a6e59c5103212f3cc92ec8dd50a0f3a02790233ed0b52148b70d3c438ec", size = 717045 } - -[[package]] -name = "openpyxl" -version = "3.1.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "et-xmlfile" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910 }, -] - [[package]] name = "packaging" version = "24.1" @@ -1000,8 +1065,6 @@ version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/18/c7/8c6872f7372eb6a6b2e4708b88419fb46b857f7a2e1892966b851cc79fc9/psutil-6.0.0.tar.gz", hash = "sha256:8faae4f310b6d969fa26ca0545338b21f73c6b15db7c4a8d934a5482faa818f2", size = 508067 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/66/78c9c3020f573c58101dc43a44f6855d01bbbd747e24da2f0c4491200ea3/psutil-6.0.0-cp27-none-win32.whl", hash = "sha256:02b69001f44cc73c1c5279d02b30a817e339ceb258ad75997325e0e6169d8b35", size = 249766 }, - { url = "https://files.pythonhosted.org/packages/e1/3f/2403aa9558bea4d3854b0e5e567bc3dd8e9fbc1fc4453c0aa9aafeb75467/psutil-6.0.0-cp27-none-win_amd64.whl", hash = "sha256:21f1fb635deccd510f69f485b87433460a603919b45e2a324ad65b0cc74f8fb1", size = 253024 }, { url = "https://files.pythonhosted.org/packages/0b/37/f8da2fbd29690b3557cca414c1949f92162981920699cd62095a984983bf/psutil-6.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c588a7e9b1173b6e866756dde596fd4cad94f9399daf99ad8c3258b3cb2b47a0", size = 250961 }, { url = "https://files.pythonhosted.org/packages/35/56/72f86175e81c656a01c4401cd3b1c923f891b31fbcebe98985894176d7c9/psutil-6.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed2440ada7ef7d0d608f20ad89a04ec47d2d3ab7190896cd62ca5fc4fe08bf0", size = 287478 }, { url = "https://files.pythonhosted.org/packages/19/74/f59e7e0d392bc1070e9a70e2f9190d652487ac115bb16e2eff6b22ad1d24/psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd", size = 290455 }, @@ -1049,7 +1112,7 @@ wheels = [ [[package]] name = "pytest" -version = "7.4.4" +version = "8.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1057,9 +1120,33 @@ dependencies = [ { name = "packaging" }, { name = "pluggy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/1f/9d8e98e4133ffb16c90f3b405c43e38d3abb715bb5d7a63a5a684f7e46a3/pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", size = 1357116 } +sdist = { url = "https://files.pythonhosted.org/packages/b4/8c/9862305bdcd6020bc7b45b1b5e7397a6caf1a33d3025b9a003b39075ffb2/pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce", size = 1439314 } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8", size = 325287 }, + { url = "https://files.pythonhosted.org/packages/0f/f9/cf155cf32ca7d6fa3601bc4c5dd19086af4b320b706919d48a4c79081cf9/pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", size = 341802 }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 }, +] + +[[package]] +name = "pytest-blockage" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/21/10240e4d3d94b04e0caf66164ff22ae2060483bd1d6fb935bacb5ef49ec8/pytest-blockage-0.2.4.tar.gz", hash = "sha256:7127b9251242dfce7acce3f9f619727d06d22368249289c9cd7396134133c9a8", size = 3393 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/6b/8f30fd11bf57cff94079d00fb1c80661d539bfb3e5b3ffa2a8217fea69c5/pytest_blockage-0.2.4-py3-none-any.whl", hash = "sha256:b853f2259a290f079918cb886fb5f3e3fdd9e5e677d6aee60a580012daf9bf91", size = 3742 }, ] [[package]] @@ -1087,6 +1174,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/fe/54f387ee1b41c9ad59e48fb8368a361fad0600fe404315e31a12bacaea7d/pytest_django-4.9.0-py3-none-any.whl", hash = "sha256:1d83692cb39188682dbb419ff0393867e9904094a549a7d38a3154d5731b2b99", size = 23723 }, ] +[[package]] +name = "pytest-httpx-blockage" +version = "0.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/5c/330fa2d41d76f63b66a3f629da3aa04426953f988ccfbeb844f4d72fe836/pytest-httpx-blockage-0.0.8.tar.gz", hash = "sha256:c0eac96c806ab4c7842716bf13a15aa5846e4ddda87e810c8f0c7d91561e2c6d", size = 4590 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/13/49b0b11e681e10e23fa6ef6a3b872af7df7dc543f7dcd850b4dc37420819/pytest_httpx_blockage-0.0.8-py3-none-any.whl", hash = "sha256:7dbeea3005580a01e7a645784d6d12ba21c394c1f4307b7a6d2d382e693eaab7", size = 5504 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1346,24 +1446,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/02/404b9a79578e1a3512bf3ae5e1fb0766859ccf3b55a83ab1e7ac4aeb7bed/tablib-3.5.0-py3-none-any.whl", hash = "sha256:9821caa9eca6062ff7299fa645e737aecff982e6b2b42046928a6413c8dabfd9", size = 45479 }, ] -[package.optional-dependencies] -html = [ - { name = "markuppy" }, -] -ods = [ - { name = "odfpy" }, -] -xls = [ - { name = "xlrd" }, - { name = "xlwt" }, -] -xlsx = [ - { name = "openpyxl" }, -] -yaml = [ - { name = "pyyaml" }, -] - [[package]] name = "tabulate" version = "0.9.0" @@ -1418,6 +1500,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/c8/26367d0b8dfaf7445576fe0051bff61b8f5be752e7bf3e8807ed7fa3a343/time_machine-2.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:008bd668d933b1a029c81805bcdc0132390c2545b103cf8e6709e3adbc37989d", size = 18337 }, ] +[[package]] +name = "tinycss2" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/6f/38d2335a2b70b9982d112bb177e3dbe169746423e33f718bf5e9c7b3ddd3/tinycss2-1.3.0.tar.gz", hash = "sha256:152f9acabd296a8375fbca5b84c961ff95971fcfc32e79550c8df8e29118c54d", size = 67360 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/4d/0db5b8a613d2a59bbc29bc5bb44a2f8070eb9ceab11c50d477502a8a0092/tinycss2-1.3.0-py3-none-any.whl", hash = "sha256:54a8dbdffb334d536851be0226030e9505965bb2f30f21a4a82c55fb2a80fae7", size = 22532 }, +] + +[[package]] +name = "tokenize-rt" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/0a/5854d8ced8c1e00193d1353d13db82d7f813f99bd5dcb776ce3e2a4c0d19/tokenize_rt-6.1.0.tar.gz", hash = "sha256:e8ee836616c0877ab7c7b54776d2fefcc3bde714449a206762425ae114b53c86", size = 5506 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/ba/576aac29b10dfa49a6ce650001d1bb31f81e734660555eaf144bfe5b8995/tokenize_rt-6.1.0-py2.py3-none-any.whl", hash = "sha256:d706141cdec4aa5f358945abe36b911b8cbdc844545da99e811250c0cee9b6fc", size = 6015 }, +] + [[package]] name = "tomli" version = "2.0.1" @@ -1475,6 +1578,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/1c/89ffc63a9605b583d5df2be791a27bc1a42b7c32bab68d3c8f2f73a98cd4/urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", size = 121444 }, ] +[[package]] +name = "uvicorn" +version = "0.30.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/01/5e637e7aa9dd031be5376b9fb749ec20b86f5a5b6a49b87fabd374d5fa9f/uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788", size = 42825 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/8e/cdc7d6263db313030e4c257dd5ba3909ebc4e4fb53ad62d5f09b1a2f5458/uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5", size = 62835 }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvicorn-worker" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gunicorn" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/7a/a4b06ea7ece47f6b020671209912a505f8eef1812e02a68cb25d71ee0e8d/uvicorn_worker-0.2.0.tar.gz", hash = "sha256:f6894544391796be6eeed37d48cae9d7739e5a105f7e37061eccef2eac5a0295", size = 8959 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/9c/5ead3efe80abb7ba5e2764650a050e7c25d8a75228543a1e63ce321186c3/uvicorn_worker-0.2.0-py3-none-any.whl", hash = "sha256:65dcef25ab80a62e0919640f9582216ee05b3bb1dc2f0e58b354ca0511c398fb", size = 5282 }, +] + +[[package]] +name = "uvloop" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/f1/dc9577455e011ad43d9379e836ee73f40b4f99c02946849a44f7ae64835e/uvloop-0.20.0.tar.gz", hash = "sha256:4603ca714a754fc8d9b197e325db25b2ea045385e8a3ad05d3463de725fdf469", size = 2329938 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/bf/45828beccf685b7ed9638d9b77ef382b470c6ca3b5bff78067e02ffd5663/uvloop-0.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e50289c101495e0d1bb0bfcb4a60adde56e32f4449a67216a1ab2750aa84f037", size = 1320593 }, + { url = "https://files.pythonhosted.org/packages/27/c0/3c24e50bee7802a2add96ca9f0d5eb0ebab07e0a5615539d38aeb89499b9/uvloop-0.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e237f9c1e8a00e7d9ddaa288e535dc337a39bcbf679f290aee9d26df9e72bce9", size = 736676 }, + { url = "https://files.pythonhosted.org/packages/83/ce/ffa3c72954eae36825acfafd2b6a9221d79abd2670c0d25e04d6ef4a2007/uvloop-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:746242cd703dc2b37f9d8b9f173749c15e9a918ddb021575a0205ec29a38d31e", size = 3494573 }, + { url = "https://files.pythonhosted.org/packages/46/6d/4caab3a36199ba52b98d519feccfcf48921d7a6649daf14a93c7e77497e9/uvloop-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82edbfd3df39fb3d108fc079ebc461330f7c2e33dbd002d146bf7c445ba6e756", size = 3489932 }, + { url = "https://files.pythonhosted.org/packages/e4/4f/49c51595bd794945c88613df88922c38076eae2d7653f4624aa6f4980b07/uvloop-0.20.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:80dc1b139516be2077b3e57ce1cb65bfed09149e1d175e0478e7a987863b68f0", size = 4185596 }, + { url = "https://files.pythonhosted.org/packages/b8/94/7e256731260d313f5049717d1c4582d52a3b132424c95e16954a50ab95d3/uvloop-0.20.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4f44af67bf39af25db4c1ac27e82e9665717f9c26af2369c404be865c8818dcf", size = 4185746 }, + { url = "https://files.pythonhosted.org/packages/2d/64/31cbd379d6e260ac8de3f672f904e924f09715c3f192b09f26cc8e9f574c/uvloop-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4b75f2950ddb6feed85336412b9a0c310a2edbcf4cf931aa5cfe29034829676d", size = 1324302 }, + { url = "https://files.pythonhosted.org/packages/1e/6b/9207e7177ff30f78299401f2e1163ea41130d4fd29bcdc6d12572c06b728/uvloop-0.20.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:77fbc69c287596880ecec2d4c7a62346bef08b6209749bf6ce8c22bbaca0239e", size = 738105 }, + { url = "https://files.pythonhosted.org/packages/c1/ba/b64b10f577519d875992dc07e2365899a1a4c0d28327059ce1e1bdfb6854/uvloop-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6462c95f48e2d8d4c993a2950cd3d31ab061864d1c226bbf0ee2f1a8f36674b9", size = 4090658 }, + { url = "https://files.pythonhosted.org/packages/0a/f8/5ceea6876154d926604f10c1dd896adf9bce6d55a55911364337b8a5ed8d/uvloop-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:649c33034979273fa71aa25d0fe120ad1777c551d8c4cd2c0c9851d88fcb13ab", size = 4173357 }, + { url = "https://files.pythonhosted.org/packages/18/b2/117ab6bfb18274753fbc319607bf06e216bd7eea8be81d5bac22c912d6a7/uvloop-0.20.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a609780e942d43a275a617c0839d85f95c334bad29c4c0918252085113285b5", size = 4029868 }, + { url = "https://files.pythonhosted.org/packages/6f/52/deb4be09060637ef4752adaa0b75bf770c20c823e8108705792f99cd4a6f/uvloop-0.20.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aea15c78e0d9ad6555ed201344ae36db5c63d428818b4b2a42842b3870127c00", size = 4115980 }, +] + [[package]] name = "virtualenv" version = "20.26.3" @@ -1489,6 +1649,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/4d/410156100224c5e2f0011d435e477b57aed9576fc7fe137abcf14ec16e11/virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589", size = 5684792 }, ] +[[package]] +name = "watchfiles" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/27/2ba23c8cc85796e2d41976439b08d52f691655fdb9401362099502d1f0cf/watchfiles-0.24.0.tar.gz", hash = "sha256:afb72325b74fa7a428c009c1b8be4b4d7c2afedafb2982827ef2156646df2fe1", size = 37870 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/02/366ae902cd81ca5befcd1854b5c7477b378f68861597cef854bd6dc69fbe/watchfiles-0.24.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:bdcd5538e27f188dd3c804b4a8d5f52a7fc7f87e7fd6b374b8e36a4ca03db428", size = 375579 }, + { url = "https://files.pythonhosted.org/packages/bc/67/d8c9d256791fe312fea118a8a051411337c948101a24586e2df237507976/watchfiles-0.24.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2dadf8a8014fde6addfd3c379e6ed1a981c8f0a48292d662e27cabfe4239c83c", size = 367726 }, + { url = "https://files.pythonhosted.org/packages/b1/dc/a8427b21ef46386adf824a9fec4be9d16a475b850616cfd98cf09a97a2ef/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6509ed3f467b79d95fc62a98229f79b1a60d1b93f101e1c61d10c95a46a84f43", size = 437735 }, + { url = "https://files.pythonhosted.org/packages/3a/21/0b20bef581a9fbfef290a822c8be645432ceb05fb0741bf3c032e0d90d9a/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8360f7314a070c30e4c976b183d1d8d1585a4a50c5cb603f431cebcbb4f66327", size = 433644 }, + { url = "https://files.pythonhosted.org/packages/1c/e8/d5e5f71cc443c85a72e70b24269a30e529227986096abe091040d6358ea9/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:316449aefacf40147a9efaf3bd7c9bdd35aaba9ac5d708bd1eb5763c9a02bef5", size = 450928 }, + { url = "https://files.pythonhosted.org/packages/61/ee/bf17f5a370c2fcff49e1fec987a6a43fd798d8427ea754ce45b38f9e117a/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73bde715f940bea845a95247ea3e5eb17769ba1010efdc938ffcb967c634fa61", size = 469072 }, + { url = "https://files.pythonhosted.org/packages/a3/34/03b66d425986de3fc6077e74a74c78da298f8cb598887f664a4485e55543/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3770e260b18e7f4e576edca4c0a639f704088602e0bc921c5c2e721e3acb8d15", size = 475517 }, + { url = "https://files.pythonhosted.org/packages/70/eb/82f089c4f44b3171ad87a1b433abb4696f18eb67292909630d886e073abe/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa0fd7248cf533c259e59dc593a60973a73e881162b1a2f73360547132742823", size = 425480 }, + { url = "https://files.pythonhosted.org/packages/53/20/20509c8f5291e14e8a13104b1808cd7cf5c44acd5feaecb427a49d387774/watchfiles-0.24.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d7a2e3b7f5703ffbd500dabdefcbc9eafeff4b9444bbdd5d83d79eedf8428fab", size = 612322 }, + { url = "https://files.pythonhosted.org/packages/df/2b/5f65014a8cecc0a120f5587722068a975a692cadbe9fe4ea56b3d8e43f14/watchfiles-0.24.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d831ee0a50946d24a53821819b2327d5751b0c938b12c0653ea5be7dea9c82ec", size = 595094 }, + { url = "https://files.pythonhosted.org/packages/18/98/006d8043a82c0a09d282d669c88e587b3a05cabdd7f4900e402250a249ac/watchfiles-0.24.0-cp311-none-win32.whl", hash = "sha256:49d617df841a63b4445790a254013aea2120357ccacbed00253f9c2b5dc24e2d", size = 264191 }, + { url = "https://files.pythonhosted.org/packages/8a/8b/badd9247d6ec25f5f634a9b3d0d92e39c045824ec7e8afcedca8ee52c1e2/watchfiles-0.24.0-cp311-none-win_amd64.whl", hash = "sha256:d3dcb774e3568477275cc76554b5a565024b8ba3a0322f77c246bc7111c5bb9c", size = 277527 }, + { url = "https://files.pythonhosted.org/packages/af/19/35c957c84ee69d904299a38bae3614f7cede45f07f174f6d5a2f4dbd6033/watchfiles-0.24.0-cp311-none-win_arm64.whl", hash = "sha256:9301c689051a4857d5b10777da23fafb8e8e921bcf3abe6448a058d27fb67633", size = 266253 }, + { url = "https://files.pythonhosted.org/packages/35/82/92a7bb6dc82d183e304a5f84ae5437b59ee72d48cee805a9adda2488b237/watchfiles-0.24.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7211b463695d1e995ca3feb38b69227e46dbd03947172585ecb0588f19b0d87a", size = 374137 }, + { url = "https://files.pythonhosted.org/packages/87/91/49e9a497ddaf4da5e3802d51ed67ff33024597c28f652b8ab1e7c0f5718b/watchfiles-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b8693502d1967b00f2fb82fc1e744df128ba22f530e15b763c8d82baee15370", size = 367733 }, + { url = "https://files.pythonhosted.org/packages/0d/d8/90eb950ab4998effea2df4cf3a705dc594f6bc501c5a353073aa990be965/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdab9555053399318b953a1fe1f586e945bc8d635ce9d05e617fd9fe3a4687d6", size = 437322 }, + { url = "https://files.pythonhosted.org/packages/6c/a2/300b22e7bc2a222dd91fce121cefa7b49aa0d26a627b2777e7bdfcf1110b/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34e19e56d68b0dad5cff62273107cf5d9fbaf9d75c46277aa5d803b3ef8a9e9b", size = 433409 }, + { url = "https://files.pythonhosted.org/packages/99/44/27d7708a43538ed6c26708bcccdde757da8b7efb93f4871d4cc39cffa1cc/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:41face41f036fee09eba33a5b53a73e9a43d5cb2c53dad8e61fa6c9f91b5a51e", size = 452142 }, + { url = "https://files.pythonhosted.org/packages/b0/ec/c4e04f755be003129a2c5f3520d2c47026f00da5ecb9ef1e4f9449637571/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5148c2f1ea043db13ce9b0c28456e18ecc8f14f41325aa624314095b6aa2e9ea", size = 469414 }, + { url = "https://files.pythonhosted.org/packages/c5/4e/cdd7de3e7ac6432b0abf282ec4c1a1a2ec62dfe423cf269b86861667752d/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e4bd963a935aaf40b625c2499f3f4f6bbd0c3776f6d3bc7c853d04824ff1c9f", size = 472962 }, + { url = "https://files.pythonhosted.org/packages/27/69/e1da9d34da7fc59db358424f5d89a56aaafe09f6961b64e36457a80a7194/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c79d7719d027b7a42817c5d96461a99b6a49979c143839fc37aa5748c322f234", size = 425705 }, + { url = "https://files.pythonhosted.org/packages/e8/c1/24d0f7357be89be4a43e0a656259676ea3d7a074901f47022f32e2957798/watchfiles-0.24.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:32aa53a9a63b7f01ed32e316e354e81e9da0e6267435c7243bf8ae0f10b428ef", size = 612851 }, + { url = "https://files.pythonhosted.org/packages/c7/af/175ba9b268dec56f821639c9893b506c69fd999fe6a2e2c51de420eb2f01/watchfiles-0.24.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce72dba6a20e39a0c628258b5c308779b8697f7676c254a845715e2a1039b968", size = 594868 }, + { url = "https://files.pythonhosted.org/packages/44/81/1f701323a9f70805bc81c74c990137123344a80ea23ab9504a99492907f8/watchfiles-0.24.0-cp312-none-win32.whl", hash = "sha256:d9018153cf57fc302a2a34cb7564870b859ed9a732d16b41a9b5cb2ebed2d444", size = 264109 }, + { url = "https://files.pythonhosted.org/packages/b4/0b/32cde5bc2ebd9f351be326837c61bdeb05ad652b793f25c91cac0b48a60b/watchfiles-0.24.0-cp312-none-win_amd64.whl", hash = "sha256:551ec3ee2a3ac9cbcf48a4ec76e42c2ef938a7e905a35b42a1267fa4b1645896", size = 277055 }, + { url = "https://files.pythonhosted.org/packages/4b/81/daade76ce33d21dbec7a15afd7479de8db786e5f7b7d249263b4ea174e08/watchfiles-0.24.0-cp312-none-win_arm64.whl", hash = "sha256:b52a65e4ea43c6d149c5f8ddb0bef8d4a1e779b77591a458a893eb416624a418", size = 266169 }, + { url = "https://files.pythonhosted.org/packages/30/dc/6e9f5447ae14f645532468a84323a942996d74d5e817837a5c8ce9d16c69/watchfiles-0.24.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3d2e3ab79a1771c530233cadfd277fcc762656d50836c77abb2e5e72b88e3a48", size = 373764 }, + { url = "https://files.pythonhosted.org/packages/79/c0/c3a9929c372816c7fc87d8149bd722608ea58dc0986d3ef7564c79ad7112/watchfiles-0.24.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327763da824817b38ad125dcd97595f942d720d32d879f6c4ddf843e3da3fe90", size = 367873 }, + { url = "https://files.pythonhosted.org/packages/2e/11/ff9a4445a7cfc1c98caf99042df38964af12eed47d496dd5d0d90417349f/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd82010f8ab451dabe36054a1622870166a67cf3fce894f68895db6f74bbdc94", size = 438381 }, + { url = "https://files.pythonhosted.org/packages/48/a3/763ba18c98211d7bb6c0f417b2d7946d346cdc359d585cc28a17b48e964b/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d64ba08db72e5dfd5c33be1e1e687d5e4fcce09219e8aee893a4862034081d4e", size = 432809 }, + { url = "https://files.pythonhosted.org/packages/30/4c/616c111b9d40eea2547489abaf4ffc84511e86888a166d3a4522c2ba44b5/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1cf1f6dd7825053f3d98f6d33f6464ebdd9ee95acd74ba2c34e183086900a827", size = 451801 }, + { url = "https://files.pythonhosted.org/packages/b6/be/d7da83307863a422abbfeb12903a76e43200c90ebe5d6afd6a59d158edea/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43e3e37c15a8b6fe00c1bce2473cfa8eb3484bbeecf3aefbf259227e487a03df", size = 468886 }, + { url = "https://files.pythonhosted.org/packages/1d/d3/3dfe131ee59d5e90b932cf56aba5c996309d94dafe3d02d204364c23461c/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88bcd4d0fe1d8ff43675360a72def210ebad3f3f72cabfeac08d825d2639b4ab", size = 472973 }, + { url = "https://files.pythonhosted.org/packages/42/6c/279288cc5653a289290d183b60a6d80e05f439d5bfdfaf2d113738d0f932/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:999928c6434372fde16c8f27143d3e97201160b48a614071261701615a2a156f", size = 425282 }, + { url = "https://files.pythonhosted.org/packages/d6/d7/58afe5e85217e845edf26d8780c2d2d2ae77675eeb8d1b8b8121d799ce52/watchfiles-0.24.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:30bbd525c3262fd9f4b1865cb8d88e21161366561cd7c9e1194819e0a33ea86b", size = 612540 }, + { url = "https://files.pythonhosted.org/packages/6d/d5/b96eeb9fe3fda137200dd2f31553670cbc731b1e13164fd69b49870b76ec/watchfiles-0.24.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:edf71b01dec9f766fb285b73930f95f730bb0943500ba0566ae234b5c1618c18", size = 593625 }, + { url = "https://files.pythonhosted.org/packages/c1/e5/c326fe52ee0054107267608d8cea275e80be4455b6079491dfd9da29f46f/watchfiles-0.24.0-cp313-none-win32.whl", hash = "sha256:f4c96283fca3ee09fb044f02156d9570d156698bc3734252175a38f0e8975f07", size = 263899 }, + { url = "https://files.pythonhosted.org/packages/a6/8b/8a7755c5e7221bb35fe4af2dc44db9174f90ebf0344fd5e9b1e8b42d381e/watchfiles-0.24.0-cp313-none-win_amd64.whl", hash = "sha256:a974231b4fdd1bb7f62064a0565a6b107d27d21d9acb50c484d2cdba515b9366", size = 276622 }, +] + [[package]] name = "wcwidth" version = "0.2.13" @@ -1498,6 +1707,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, ] +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 }, +] + +[[package]] +name = "websockets" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/1c/78687e0267b09412409ac134f10fd14d14ac6475da892a8b09a02d0f6ae2/websockets-13.0.1.tar.gz", hash = "sha256:4d6ece65099411cfd9a48d13701d7438d9c34f479046b34c50ff60bb8834e43e", size = 149769 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/95/e002ec55688b751d3c9cc131c1960af7e440d95e1954c441535b9da2bf36/websockets-13.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:699ba9dd6a926f82a277063603fc8d586b89f4cb128efc353b749b641fcddda7", size = 150948 }, + { url = "https://files.pythonhosted.org/packages/62/6b/85fb8c13b278db7d45e27ff6ee0db3009b0fadef7c37c85e6cb4a0fbf08e/websockets-13.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf2fae6d85e5dc384bf846f8243ddaa9197f3a1a70044f59399af001fd1f51d4", size = 148599 }, + { url = "https://files.pythonhosted.org/packages/e8/2e/c80cafbab86f8c399ba8323efff298b7062055724146391443d266e9c49b/websockets-13.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:52aed6ef21a0f1a2a5e310fb5c42d7555e9c5855476bbd7173c3aa3d8a0302f2", size = 148851 }, + { url = "https://files.pythonhosted.org/packages/2e/67/631d4b1f28fef6f12730c0cbe982203a9d6814768c2ab1e0a352d9a07a97/websockets-13.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8eb2b9a318542153674c6e377eb8cb9ca0fc011c04475110d3477862f15d29f0", size = 158509 }, + { url = "https://files.pythonhosted.org/packages/9b/e8/ba740eab2a9c5b903ea94d9a2a448db63f0a296265aee976d17abf734758/websockets-13.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5df891c86fe68b2c38da55b7aea7095beca105933c697d719f3f45f4220a5e0e", size = 157507 }, + { url = "https://files.pythonhosted.org/packages/f8/4e/ffa2f1aad2da67e483fb7bad6c69f80c786f4e85d1942a39d7b275b084ed/websockets-13.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fac2d146ff30d9dd2fcf917e5d147db037a5c573f0446c564f16f1f94cf87462", size = 157881 }, + { url = "https://files.pythonhosted.org/packages/c0/85/0cbfe7b0e0dd3d885cd87b0523c6690ae7369feaf3aab5a23e95bdb4fefa/websockets-13.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b8ac5b46fd798bbbf2ac6620e0437c36a202b08e1f827832c4bf050da081b501", size = 158187 }, + { url = "https://files.pythonhosted.org/packages/39/29/d9df0a1daedebefaeea88fb8071539604df09fd0f1bfb73bf58333aa3eb6/websockets-13.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46af561eba6f9b0848b2c9d2427086cabadf14e0abdd9fde9d72d447df268418", size = 157626 }, + { url = "https://files.pythonhosted.org/packages/7d/9a/f88e186059f6b89f8bb08461d9fda7a26940b7b8897c7d7f02aead40b7e4/websockets-13.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b5a06d7f60bc2fc378a333978470dfc4e1415ee52f5f0fce4f7853eb10c1e9df", size = 157575 }, + { url = "https://files.pythonhosted.org/packages/cf/e4/ecdb8352ebab2e44c10b9d6f50008f95e30bb0a7ef0e6b66cb475d539d74/websockets-13.0.1-cp311-cp311-win32.whl", hash = "sha256:556e70e4f69be1082e6ef26dcb70efcd08d1850f5d6c5f4f2bcb4e397e68f01f", size = 151779 }, + { url = "https://files.pythonhosted.org/packages/12/40/46967d00640e6c3231b73d310617927a11c91bcc044dd5a0860a3c457c33/websockets-13.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:67494e95d6565bf395476e9d040037ff69c8b3fa356a886b21d8422ad86ae075", size = 152206 }, + { url = "https://files.pythonhosted.org/packages/4e/51/23ed2d239f1c3087c1431d41cfd159865df0bc35bb0c89973e3b6a0fff9b/websockets-13.0.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f9c9e258e3d5efe199ec23903f5da0eeaad58cf6fccb3547b74fd4750e5ac47a", size = 150953 }, + { url = "https://files.pythonhosted.org/packages/57/8d/814a7ef62b916b0f39108ad2e4d9b4cb0f8c640f8c30202fb63041598ada/websockets-13.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6b41a1b3b561f1cba8321fb32987552a024a8f67f0d05f06fcf29f0090a1b956", size = 148610 }, + { url = "https://files.pythonhosted.org/packages/ad/8b/a378d21124011737e0e490a8a6ef778914b03e50c8d938de2f2170a20dbd/websockets-13.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f73e676a46b0fe9426612ce8caeca54c9073191a77c3e9d5c94697aef99296af", size = 148849 }, + { url = "https://files.pythonhosted.org/packages/46/d2/814a61226af313c1bc289cfe3a10f87bf426b6f2d9df0f927c47afab7612/websockets-13.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f613289f4a94142f914aafad6c6c87903de78eae1e140fa769a7385fb232fdf", size = 158772 }, + { url = "https://files.pythonhosted.org/packages/a1/7e/5987299eb7e131216c9027b05a65f149cbc2bde7c582e694d9eed6ec3d40/websockets-13.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f52504023b1480d458adf496dc1c9e9811df4ba4752f0bc1f89ae92f4f07d0c", size = 157724 }, + { url = "https://files.pythonhosted.org/packages/94/6e/eaf95894042ba8a05a125fe8bcf9ee3572fef6edbcbf49478f4991c027cc/websockets-13.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:139add0f98206cb74109faf3611b7783ceafc928529c62b389917a037d4cfdf4", size = 158152 }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a1315d569cc2dadaafda74a9cea16ab5d68142525937f1994442d969b306/websockets-13.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:47236c13be337ef36546004ce8c5580f4b1150d9538b27bf8a5ad8edf23ccfab", size = 158442 }, + { url = "https://files.pythonhosted.org/packages/90/9b/59866695cfd05e785c90932fef3dae4682eb4e06e7076b7c53478f25faad/websockets-13.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c44ca9ade59b2e376612df34e837013e2b273e6c92d7ed6636d0556b6f4db93d", size = 157823 }, + { url = "https://files.pythonhosted.org/packages/9b/47/20af68a313b6453d2d094ccc497b7232e8475175d234e3e5bef5088521e5/websockets-13.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9bbc525f4be3e51b89b2a700f5746c2a6907d2e2ef4513a8daafc98198b92237", size = 157818 }, + { url = "https://files.pythonhosted.org/packages/f8/bb/60aaedc80e388e978617dda1ff38788780c6b0f6e462b85368cb934131a5/websockets-13.0.1-cp312-cp312-win32.whl", hash = "sha256:3624fd8664f2577cf8de996db3250662e259bfbc870dd8ebdcf5d7c6ac0b5185", size = 151785 }, + { url = "https://files.pythonhosted.org/packages/16/2e/e47692f569e1be2e66c1dbc5e85ea4d2cc93b80027fbafa28ae8b0dee52c/websockets-13.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0513c727fb8adffa6d9bf4a4463b2bade0186cbd8c3604ae5540fae18a90cb99", size = 152214 }, + { url = "https://files.pythonhosted.org/packages/46/37/d8ef4b68684d1fa368a5c64be466db07fc58b68163bc2496db2d4cc208ff/websockets-13.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1ee4cc030a4bdab482a37462dbf3ffb7e09334d01dd37d1063be1136a0d825fa", size = 150962 }, + { url = "https://files.pythonhosted.org/packages/95/49/78aeb3af08ec9887a9065e85cef9d7e199d6c6261fcd39eec087f3a62328/websockets-13.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbb0b697cc0655719522406c059eae233abaa3243821cfdfab1215d02ac10231", size = 148621 }, + { url = "https://files.pythonhosted.org/packages/31/0d/dc9b7cec8deaee452092a631ccda894bd7098859f71dd7639b4b5b9c615c/websockets-13.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:acbebec8cb3d4df6e2488fbf34702cbc37fc39ac7abf9449392cefb3305562e9", size = 148853 }, + { url = "https://files.pythonhosted.org/packages/16/bf/734cbd815d7bc94cffe35c934f4e08b619bf3b47df1c6c7af21c1d35bcfe/websockets-13.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63848cdb6fcc0bf09d4a155464c46c64ffdb5807ede4fb251da2c2692559ce75", size = 158741 }, + { url = "https://files.pythonhosted.org/packages/af/9b/756f89b12fee8931785531a314e6f087b21774a7f8c60878e597c684f91b/websockets-13.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:872afa52a9f4c414d6955c365b6588bc4401272c629ff8321a55f44e3f62b553", size = 157690 }, + { url = "https://files.pythonhosted.org/packages/d3/37/31f97132d2262e666b797e250879ca833eab55115f88043b3952a2840eb8/websockets-13.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05e70fec7c54aad4d71eae8e8cab50525e899791fc389ec6f77b95312e4e9920", size = 158132 }, + { url = "https://files.pythonhosted.org/packages/41/ce/59c8d44e148c002fec506a9527504fb4281676e2e75c2ee5a58180f1b99a/websockets-13.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e82db3756ccb66266504f5a3de05ac6b32f287faacff72462612120074103329", size = 158490 }, + { url = "https://files.pythonhosted.org/packages/1a/74/5b31ce0f318b902c0d70c031f8e1228ba1a4d95a46b2a24a2a5ac17f9cf0/websockets-13.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e85f46ce287f5c52438bb3703d86162263afccf034a5ef13dbe4318e98d86e7", size = 157879 }, + { url = "https://files.pythonhosted.org/packages/0d/a7/6eac4f04177644bbc98deb98d11770cc7fbc216f6f67ab187c150540fd52/websockets-13.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f3fea72e4e6edb983908f0db373ae0732b275628901d909c382aae3b592589f2", size = 157873 }, + { url = "https://files.pythonhosted.org/packages/72/f6/b8b30a3b134dfdb4ccd1694befa48fddd43783957c988a1dab175732af33/websockets-13.0.1-cp313-cp313-win32.whl", hash = "sha256:254ecf35572fca01a9f789a1d0f543898e222f7b69ecd7d5381d8d8047627bdb", size = 151782 }, + { url = "https://files.pythonhosted.org/packages/3e/88/d94ccc006c69583168aa9dd73b3f1885c8931f2c676f4bdd8cbfae91c7b6/websockets-13.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:ca48914cdd9f2ccd94deab5bcb5ac98025a5ddce98881e5cce762854a5de330b", size = 152212 }, + { url = "https://files.pythonhosted.org/packages/fd/bd/d34c4b7918453506d2149208b175368738148ffc4ba256d7fd8708956732/websockets-13.0.1-py3-none-any.whl", hash = "sha256:b80f0c51681c517604152eb6a572f5a9378f877763231fddb883ba2f968e8817", size = 145262 }, +] + [[package]] name = "werkzeug" version = "3.0.4" @@ -1519,47 +1779,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/d1/ab1d78bcf3e78517f4c57d34c3b349f1289afb5b2dbf46e5bf5c96932be5/whitenoise-6.5.0-py3-none-any.whl", hash = "sha256:16468e9ad2189f09f4a8c635a9031cc9bb2cdbc8e5e53365407acf99f7ade9ec", size = 19842 }, ] -[[package]] -name = "xlrd" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/b3/19a2540d21dea5f908304375bd43f5ed7a4c28a370dc9122c565423e6b44/xlrd-2.0.1.tar.gz", hash = "sha256:f72f148f54442c6b056bf931dbc34f986fd0c3b0b6b5a58d013c9aef274d0c88", size = 100259 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/0c/c2a72d51fe56e08a08acc85d13013558a2d793028ae7385448a6ccdfae64/xlrd-2.0.1-py2.py3-none-any.whl", hash = "sha256:6a33ee89877bd9abc1158129f6e94be74e2679636b8a205b43b85206c3f0bbdd", size = 96531 }, -] - -[[package]] -name = "xlwt" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/97/56a6f56ce44578a69343449aa5a0d98eefe04085d69da539f3034e2cd5c1/xlwt-1.3.0.tar.gz", hash = "sha256:c59912717a9b28f1a3c2a98fd60741014b06b043936dcecbc113eaaada156c88", size = 153929 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/48/def306413b25c3d01753603b1a222a011b8621aed27cd7f89cbc27e6b0f4/xlwt-1.3.0-py2.py3-none-any.whl", hash = "sha256:a082260524678ba48a297d922cc385f58278b8aa68741596a87de01a9c628b2e", size = 99981 }, -] - [[package]] name = "zakuchess" version = "0.1.0" -source = { editable = "." } +source = { virtual = "." } dependencies = [ + { name = "authlib" }, { name = "chess" }, { name = "dj-database-url" }, { name = "django" }, { name = "django-alive" }, { name = "django-axes", extra = ["ipware"] }, + { name = "django-google-fonts" }, { name = "django-htmx" }, { name = "django-import-export" }, { name = "dominate" }, { name = "gunicorn" }, + { name = "httpx" }, { name = "msgspec" }, { name = "requests" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "uvicorn-worker" }, { name = "whitenoise" }, ] [package.optional-dependencies] dev = [ { name = "django-extensions" }, - { name = "httpx" }, + { name = "fix-future-annotations" }, { name = "ipython" }, { name = "mypy" }, { name = "pre-commit" }, @@ -1573,38 +1820,49 @@ load-testing = [ ] test = [ { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-blockage" }, { name = "pytest-cov" }, { name = "pytest-django" }, + { name = "pytest-httpx-blockage" }, { name = "time-machine" }, ] [package.metadata] requires-dist = [ + { name = "authlib", specifier = "==1.*" }, { name = "chess", specifier = "==1.*" }, { name = "dj-database-url", specifier = "==2.*" }, { name = "django", specifier = "==5.1.*" }, { name = "django-alive", specifier = "==1.*" }, - { name = "django-axes", extras = ["ipware"], specifier = "==6.*" }, + { name = "django-axes", extras = ["ipware"], specifier = "==6.5.*" }, { name = "django-extensions", marker = "extra == 'dev'", specifier = "==3.*" }, + { name = "django-google-fonts", specifier = "==0.0.3" }, { name = "django-htmx", specifier = "==1.*" }, - { name = "django-import-export", specifier = "==3.*" }, + { name = "django-import-export", specifier = "==4.*" }, { name = "dominate", specifier = "==2.*" }, + { name = "fix-future-annotations", marker = "extra == 'dev'", specifier = ">=0.5.0" }, { name = "gunicorn", specifier = "==22.*" }, - { name = "httpx", marker = "extra == 'dev'", specifier = "==0.26.*" }, + { name = "httpx", specifier = "==0.27.*" }, { name = "ipython", marker = "extra == 'dev'", specifier = "==8.*" }, { name = "locust", marker = "extra == 'load-testing'", specifier = "==2.*" }, { name = "msgspec", specifier = "==0.18.*" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.*" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = "==3.*" }, - { name = "pytest", marker = "extra == 'test'", specifier = "==7.*" }, + { name = "pytest", marker = "extra == 'test'", specifier = "==8.3.*" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = "==0.24.*" }, + { name = "pytest-blockage", marker = "extra == 'test'", specifier = "==0.2.*" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = "==4.*" }, { name = "pytest-django", marker = "extra == 'test'", specifier = "==4.*" }, + { name = "pytest-httpx-blockage", marker = "extra == 'test'", specifier = "==0.0.8" }, { name = "python-dotenv", marker = "extra == 'dev'", specifier = "==1.*" }, { name = "requests", specifier = "==2.*" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.6.*" }, { name = "sqlite-utils", marker = "extra == 'dev'", specifier = "==3.*" }, { name = "time-machine", marker = "extra == 'test'", specifier = "==2.*" }, { name = "types-requests", marker = "extra == 'dev'", specifier = "==2.*" }, + { name = "uvicorn", extras = ["standard"], specifier = "==0.30.*" }, + { name = "uvicorn-worker", specifier = "==0.2.*" }, { name = "whitenoise", specifier = "==6.*" }, { name = "zakuchess" }, ]