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"""