Spaces:
Running
Running
Commit ·
e16348e
0
Parent(s):
Deploy ChessTB Explorer on Hugging Face Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +15 -0
- .gitattributes +53 -0
- .gitignore +21 -0
- Dockerfile +41 -0
- LICENSE +674 -0
- README.md +937 -0
- THIRD_PARTY_LICENSES.md +133 -0
- app.py +2048 -0
- config.py +313 -0
- dtz_anchor.py +72 -0
- frontend/.gitignore +1 -0
- frontend/admin.html +7 -0
- frontend/index.html +7 -0
- frontend/package-lock.json +1698 -0
- frontend/package.json +19 -0
- frontend/src/App.jsx +43 -0
- frontend/src/assets/fonts/Inter-Medium.woff2 +0 -0
- frontend/src/assets/fonts/Inter-Regular.woff2 +0 -0
- frontend/src/assets/fonts/Inter-SemiBold.woff2 +0 -0
- frontend/src/assets/fonts/JetBrainsMono-Medium.woff2 +0 -0
- frontend/src/assets/fonts/JetBrainsMono-Regular.woff2 +0 -0
- frontend/src/components/BoardArea.jsx +41 -0
- frontend/src/components/FenBar.jsx +60 -0
- frontend/src/components/Header.jsx +51 -0
- frontend/src/components/PgnDialog.jsx +46 -0
- frontend/src/components/PgnPanel.jsx +92 -0
- frontend/src/components/PromotionDialog.jsx +23 -0
- frontend/src/components/ResultsPanel.jsx +183 -0
- frontend/src/components/ScreenNav.jsx +33 -0
- frontend/src/components/SettingsPanel.jsx +91 -0
- frontend/src/components/Toasts.jsx +9 -0
- frontend/src/components/ui/Button.jsx +18 -0
- frontend/src/components/ui/Icon.jsx +43 -0
- frontend/src/components/ui/Panel.jsx +14 -0
- frontend/src/components/ui/Segmented.jsx +38 -0
- frontend/src/engine/board.js +1282 -0
- frontend/src/engine/gameState.js +76 -0
- frontend/src/engine/moveLine.js +103 -0
- frontend/src/engine/pgnFormat.js +198 -0
- frontend/src/engine/scroll.js +49 -0
- frontend/src/engine/storage.js +36 -0
- frontend/src/engine/tablebase.js +735 -0
- frontend/src/engine/theme.js +158 -0
- frontend/src/engine/utils.js +22 -0
- frontend/src/hooks/useExplorer.js +491 -0
- frontend/src/main.jsx +8 -0
- frontend/src/styles/admin-page.css +158 -0
- frontend/src/styles/admin.css +8 -0
- frontend/src/styles/base.css +44 -0
- frontend/src/styles/chess/board.css +245 -0
.dockerignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
.github
|
| 4 |
+
.venv
|
| 5 |
+
venv
|
| 6 |
+
__pycache__
|
| 7 |
+
*.pyc
|
| 8 |
+
.pytest_cache
|
| 9 |
+
.coverage
|
| 10 |
+
htmlcov
|
| 11 |
+
.vscode
|
| 12 |
+
.idea
|
| 13 |
+
.DS_Store
|
| 14 |
+
Thumbs.db
|
| 15 |
+
frontend/node_modules
|
.gitattributes
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ChessTB Explorer: line-ending / diff attributes
|
| 2 |
+
#
|
| 3 |
+
# Every text file in this repo is normalized to LF, regardless of the
|
| 4 |
+
# committer's or cloner's platform or local `core.autocrlf` setting - the
|
| 5 |
+
# app is meant to run identically on Windows/Linux/macOS, and mixed line
|
| 6 |
+
# endings across files (or within one file after a Windows edit) cause
|
| 7 |
+
# noisy whole-file diffs and spurious merge conflicts for no functional
|
| 8 |
+
# reason. Binary assets are marked explicitly so Git never attempts text
|
| 9 |
+
# normalization or diffing on them.
|
| 10 |
+
|
| 11 |
+
# Fallback: let Git auto-detect text vs binary for anything not matched
|
| 12 |
+
# below, and normalize any text file's line endings to LF in the
|
| 13 |
+
# repository (working-tree endings still follow core.autocrlf locally).
|
| 14 |
+
* text=auto eol=lf
|
| 15 |
+
|
| 16 |
+
# --- Source / config / docs - force LF explicitly -------------------------
|
| 17 |
+
*.py text eol=lf
|
| 18 |
+
*.js text eol=lf
|
| 19 |
+
*.mjs text eol=lf
|
| 20 |
+
*.css text eol=lf
|
| 21 |
+
*.html text eol=lf
|
| 22 |
+
*.md text eol=lf
|
| 23 |
+
*.yml text eol=lf
|
| 24 |
+
*.yaml text eol=lf
|
| 25 |
+
*.txt text eol=lf
|
| 26 |
+
*.json text eol=lf
|
| 27 |
+
*.svg text eol=lf
|
| 28 |
+
.gitignore text eol=lf
|
| 29 |
+
.gitattributes text eol=lf
|
| 30 |
+
LICENSE text eol=lf
|
| 31 |
+
|
| 32 |
+
# --- Binary assets - never touch, never diff as text -----------------------
|
| 33 |
+
*.png binary
|
| 34 |
+
*.jpg binary
|
| 35 |
+
*.jpeg binary
|
| 36 |
+
*.woff2 binary
|
| 37 |
+
*.woff binary
|
| 38 |
+
*.ttf binary
|
| 39 |
+
*.ico binary
|
| 40 |
+
|
| 41 |
+
# --- Vendored third-party code - exclude from GitHub's language stats and
|
| 42 |
+
# from generated-diff/review noise; these files are never hand-edited here,
|
| 43 |
+
# treated as vendored files and excluded from generated-diff noise.
|
| 44 |
+
# See THIRD_PARTY_LICENSES.md for what's vendored and why.
|
| 45 |
+
frontend/src/vendor/** linguist-vendored
|
| 46 |
+
frontend/src/assets/fonts/** linguist-vendored
|
| 47 |
+
|
| 48 |
+
# --- Generated build output - not hand-edited; excluded from language
|
| 49 |
+
# stats and review diffs. static/dist/ is rebuilt from frontend/src/
|
| 50 |
+
# by `vite build` (see README.md's "Frontend build"); package-lock.json is
|
| 51 |
+
# written by npm.
|
| 52 |
+
static/dist/** linguist-generated
|
| 53 |
+
frontend/package-lock.json linguist-generated
|
.gitignore
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local environment files: this project doesn't use one (config.py reads
|
| 2 |
+
# its two secrets, ADMIN_TOKEN and FLASK_SECRET_KEY, directly from the
|
| 3 |
+
# process environment rather than a .env file - see config.py's "Admin"
|
| 4 |
+
# section), but this stays as a no-cost safety net in case one is ever
|
| 5 |
+
# added locally.
|
| 6 |
+
.env
|
| 7 |
+
|
| 8 |
+
# Python
|
| 9 |
+
__pycache__/
|
| 10 |
+
*.pyc
|
| 11 |
+
.venv/
|
| 12 |
+
venv/
|
| 13 |
+
.pytest_cache/
|
| 14 |
+
.coverage
|
| 15 |
+
htmlcov/
|
| 16 |
+
|
| 17 |
+
# Editors / OS
|
| 18 |
+
.vscode/
|
| 19 |
+
.idea/
|
| 20 |
+
.DS_Store
|
| 21 |
+
Thumbs.db
|
Dockerfile
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Git is required because requirements.txt installs
|
| 4 |
+
# noobpwnftw/python-chess directly from GitHub.
|
| 5 |
+
RUN apt-get update \
|
| 6 |
+
&& apt-get install -y --no-install-recommends git \
|
| 7 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
# Run as a non-root user rather than the image's default root. UID 1000 has
|
| 10 |
+
# no special meaning to Render (it just needs to own /app below); it
|
| 11 |
+
# matches Hugging Face Docker Spaces' convention, so the same image also
|
| 12 |
+
# runs there.
|
| 13 |
+
RUN useradd -m -u 1000 user
|
| 14 |
+
|
| 15 |
+
WORKDIR /app
|
| 16 |
+
|
| 17 |
+
# Install Python dependencies first so Docker can cache this layer.
|
| 18 |
+
COPY --chown=user requirements.txt /app/requirements.txt
|
| 19 |
+
|
| 20 |
+
RUN pip install --no-cache-dir --upgrade pip \
|
| 21 |
+
&& pip install --no-cache-dir -r /app/requirements.txt
|
| 22 |
+
|
| 23 |
+
# Copy the actual application.
|
| 24 |
+
COPY --chown=user . /app
|
| 25 |
+
|
| 26 |
+
USER user
|
| 27 |
+
|
| 28 |
+
ENV HOME=/home/user
|
| 29 |
+
ENV PATH=/home/user/.local/bin:$PATH
|
| 30 |
+
ENV PYTHONUNBUFFERED=1
|
| 31 |
+
|
| 32 |
+
# Purely informational -- config.py's PORT reads the platform's own PORT
|
| 33 |
+
# environment variable at runtime (falling back to 7860 only when it's
|
| 34 |
+
# unset) and that's the port actually bound, regardless of this EXPOSE
|
| 35 |
+
# value. Render assigns and injects PORT itself (see render.yaml /
|
| 36 |
+
# README.md "Deploying to Render"), so nothing needs to change here to
|
| 37 |
+
# match it; 7860 is the default for a local `docker run` with no PORT set,
|
| 38 |
+
# and for Hugging Face Docker Spaces, which expects 7860 specifically.
|
| 39 |
+
EXPOSE 7860
|
| 40 |
+
|
| 41 |
+
CMD ["python", "app.py"]
|
LICENSE
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
GNU GENERAL PUBLIC LICENSE
|
| 2 |
+
Version 3, 29 June 2007
|
| 3 |
+
|
| 4 |
+
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
| 5 |
+
Everyone is permitted to copy and distribute verbatim copies
|
| 6 |
+
of this license document, but changing it is not allowed.
|
| 7 |
+
|
| 8 |
+
Preamble
|
| 9 |
+
|
| 10 |
+
The GNU General Public License is a free, copyleft license for
|
| 11 |
+
software and other kinds of works.
|
| 12 |
+
|
| 13 |
+
The licenses for most software and other practical works are designed
|
| 14 |
+
to take away your freedom to share and change the works. By contrast,
|
| 15 |
+
the GNU General Public License is intended to guarantee your freedom to
|
| 16 |
+
share and change all versions of a program--to make sure it remains free
|
| 17 |
+
software for all its users. We, the Free Software Foundation, use the
|
| 18 |
+
GNU General Public License for most of our software; it applies also to
|
| 19 |
+
any other work released this way by its authors. You can apply it to
|
| 20 |
+
your programs, too.
|
| 21 |
+
|
| 22 |
+
When we speak of free software, we are referring to freedom, not
|
| 23 |
+
price. Our General Public Licenses are designed to make sure that you
|
| 24 |
+
have the freedom to distribute copies of free software (and charge for
|
| 25 |
+
them if you wish), that you receive source code or can get it if you
|
| 26 |
+
want it, that you can change the software or use pieces of it in new
|
| 27 |
+
free programs, and that you know you can do these things.
|
| 28 |
+
|
| 29 |
+
To protect your rights, we need to prevent others from denying you
|
| 30 |
+
these rights or asking you to surrender the rights. Therefore, you have
|
| 31 |
+
certain responsibilities if you distribute copies of the software, or if
|
| 32 |
+
you modify it: responsibilities to respect the freedom of others.
|
| 33 |
+
|
| 34 |
+
For example, if you distribute copies of such a program, whether
|
| 35 |
+
gratis or for a fee, you must pass on to the recipients the same
|
| 36 |
+
freedoms that you received. You must make sure that they, too, receive
|
| 37 |
+
or can get the source code. And you must show them these terms so they
|
| 38 |
+
know their rights.
|
| 39 |
+
|
| 40 |
+
Developers that use the GNU GPL protect your rights with two steps:
|
| 41 |
+
(1) assert copyright on the software, and (2) offer you this License
|
| 42 |
+
giving you legal permission to copy, distribute and/or modify it.
|
| 43 |
+
|
| 44 |
+
For the developers' and authors' protection, the GPL clearly explains
|
| 45 |
+
that there is no warranty for this free software. For both users' and
|
| 46 |
+
authors' sake, the GPL requires that modified versions be marked as
|
| 47 |
+
changed, so that their problems will not be attributed erroneously to
|
| 48 |
+
authors of previous versions.
|
| 49 |
+
|
| 50 |
+
Some devices are designed to deny users access to install or run
|
| 51 |
+
modified versions of the software inside them, although the manufacturer
|
| 52 |
+
can do so. This is fundamentally incompatible with the aim of
|
| 53 |
+
protecting users' freedom to change the software. The systematic
|
| 54 |
+
pattern of such abuse occurs in the area of products for individuals to
|
| 55 |
+
use, which is precisely where it is most unacceptable. Therefore, we
|
| 56 |
+
have designed this version of the GPL to prohibit the practice for those
|
| 57 |
+
products. If such problems arise substantially in other domains, we
|
| 58 |
+
stand ready to extend this provision to those domains in future versions
|
| 59 |
+
of the GPL, as needed to protect the freedom of users.
|
| 60 |
+
|
| 61 |
+
Finally, every program is threatened constantly by software patents.
|
| 62 |
+
States should not allow patents to restrict development and use of
|
| 63 |
+
software on general-purpose computers, but in those that do, we wish to
|
| 64 |
+
avoid the special danger that patents applied to a free program could
|
| 65 |
+
make it effectively proprietary. To prevent this, the GPL assures that
|
| 66 |
+
patents cannot be used to render the program non-free.
|
| 67 |
+
|
| 68 |
+
The precise terms and conditions for copying, distribution and
|
| 69 |
+
modification follow.
|
| 70 |
+
|
| 71 |
+
TERMS AND CONDITIONS
|
| 72 |
+
|
| 73 |
+
0. Definitions.
|
| 74 |
+
|
| 75 |
+
"This License" refers to version 3 of the GNU General Public License.
|
| 76 |
+
|
| 77 |
+
"Copyright" also means copyright-like laws that apply to other kinds of
|
| 78 |
+
works, such as semiconductor masks.
|
| 79 |
+
|
| 80 |
+
"The Program" refers to any copyrightable work licensed under this
|
| 81 |
+
License. Each licensee is addressed as "you". "Licensees" and
|
| 82 |
+
"recipients" may be individuals or organizations.
|
| 83 |
+
|
| 84 |
+
To "modify" a work means to copy from or adapt all or part of the work
|
| 85 |
+
in a fashion requiring copyright permission, other than the making of an
|
| 86 |
+
exact copy. The resulting work is called a "modified version" of the
|
| 87 |
+
earlier work or a work "based on" the earlier work.
|
| 88 |
+
|
| 89 |
+
A "covered work" means either the unmodified Program or a work based
|
| 90 |
+
on the Program.
|
| 91 |
+
|
| 92 |
+
To "propagate" a work means to do anything with it that, without
|
| 93 |
+
permission, would make you directly or secondarily liable for
|
| 94 |
+
infringement under applicable copyright law, except executing it on a
|
| 95 |
+
computer or modifying a private copy. Propagation includes copying,
|
| 96 |
+
distribution (with or without modification), making available to the
|
| 97 |
+
public, and in some countries other activities as well.
|
| 98 |
+
|
| 99 |
+
To "convey" a work means any kind of propagation that enables other
|
| 100 |
+
parties to make or receive copies. Mere interaction with a user through
|
| 101 |
+
a computer network, with no transfer of a copy, is not conveying.
|
| 102 |
+
|
| 103 |
+
An interactive user interface displays "Appropriate Legal Notices"
|
| 104 |
+
to the extent that it includes a convenient and prominently visible
|
| 105 |
+
feature that (1) displays an appropriate copyright notice, and (2)
|
| 106 |
+
tells the user that there is no warranty for the work (except to the
|
| 107 |
+
extent that warranties are provided), that licensees may convey the
|
| 108 |
+
work under this License, and how to view a copy of this License. If
|
| 109 |
+
the interface presents a list of user commands or options, such as a
|
| 110 |
+
menu, a prominent item in the list meets this criterion.
|
| 111 |
+
|
| 112 |
+
1. Source Code.
|
| 113 |
+
|
| 114 |
+
The "source code" for a work means the preferred form of the work
|
| 115 |
+
for making modifications to it. "Object code" means any non-source
|
| 116 |
+
form of a work.
|
| 117 |
+
|
| 118 |
+
A "Standard Interface" means an interface that either is an official
|
| 119 |
+
standard defined by a recognized standards body, or, in the case of
|
| 120 |
+
interfaces specified for a particular programming language, one that
|
| 121 |
+
is widely used among developers working in that language.
|
| 122 |
+
|
| 123 |
+
The "System Libraries" of an executable work include anything, other
|
| 124 |
+
than the work as a whole, that (a) is included in the normal form of
|
| 125 |
+
packaging a Major Component, but which is not part of that Major
|
| 126 |
+
Component, and (b) serves only to enable use of the work with that
|
| 127 |
+
Major Component, or to implement a Standard Interface for which an
|
| 128 |
+
implementation is available to the public in source code form. A
|
| 129 |
+
"Major Component", in this context, means a major essential component
|
| 130 |
+
(kernel, window system, and so on) of the specific operating system
|
| 131 |
+
(if any) on which the executable work runs, or a compiler used to
|
| 132 |
+
produce the work, or an object code interpreter used to run it.
|
| 133 |
+
|
| 134 |
+
The "Corresponding Source" for a work in object code form means all
|
| 135 |
+
the source code needed to generate, install, and (for an executable
|
| 136 |
+
work) run the object code and to modify the work, including scripts to
|
| 137 |
+
control those activities. However, it does not include the work's
|
| 138 |
+
System Libraries, or general-purpose tools or generally available free
|
| 139 |
+
programs which are used unmodified in performing those activities but
|
| 140 |
+
which are not part of the work. For example, Corresponding Source
|
| 141 |
+
includes interface definition files associated with source files for
|
| 142 |
+
the work, and the source code for shared libraries and dynamically
|
| 143 |
+
linked subprograms that the work is specifically designed to require,
|
| 144 |
+
such as by intimate data communication or control flow between those
|
| 145 |
+
subprograms and other parts of the work.
|
| 146 |
+
|
| 147 |
+
The Corresponding Source need not include anything that users
|
| 148 |
+
can regenerate automatically from other parts of the Corresponding
|
| 149 |
+
Source.
|
| 150 |
+
|
| 151 |
+
The Corresponding Source for a work in source code form is that
|
| 152 |
+
same work.
|
| 153 |
+
|
| 154 |
+
2. Basic Permissions.
|
| 155 |
+
|
| 156 |
+
All rights granted under this License are granted for the term of
|
| 157 |
+
copyright on the Program, and are irrevocable provided the stated
|
| 158 |
+
conditions are met. This License explicitly affirms your unlimited
|
| 159 |
+
permission to run the unmodified Program. The output from running a
|
| 160 |
+
covered work is covered by this License only if the output, given its
|
| 161 |
+
content, constitutes a covered work. This License acknowledges your
|
| 162 |
+
rights of fair use or other equivalent, as provided by copyright law.
|
| 163 |
+
|
| 164 |
+
You may make, run and propagate covered works that you do not
|
| 165 |
+
convey, without conditions so long as your license otherwise remains
|
| 166 |
+
in force. You may convey covered works to others for the sole purpose
|
| 167 |
+
of having them make modifications exclusively for you, or provide you
|
| 168 |
+
with facilities for running those works, provided that you comply with
|
| 169 |
+
the terms of this License in conveying all material for which you do
|
| 170 |
+
not control copyright. Those thus making or running the covered works
|
| 171 |
+
for you must do so exclusively on your behalf, under your direction
|
| 172 |
+
and control, on terms that prohibit them from making any copies of
|
| 173 |
+
your copyrighted material outside their relationship with you.
|
| 174 |
+
|
| 175 |
+
Conveying under any other circumstances is permitted solely under
|
| 176 |
+
the conditions stated below. Sublicensing is not allowed; section 10
|
| 177 |
+
makes it unnecessary.
|
| 178 |
+
|
| 179 |
+
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
| 180 |
+
|
| 181 |
+
No covered work shall be deemed part of an effective technological
|
| 182 |
+
measure under any applicable law fulfilling obligations under article
|
| 183 |
+
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
| 184 |
+
similar laws prohibiting or restricting circumvention of such
|
| 185 |
+
measures.
|
| 186 |
+
|
| 187 |
+
When you convey a covered work, you waive any legal power to forbid
|
| 188 |
+
circumvention of technological measures to the extent such circumvention
|
| 189 |
+
is effected by exercising rights under this License with respect to
|
| 190 |
+
the covered work, and you disclaim any intention to limit operation or
|
| 191 |
+
modification of the work as a means of enforcing, against the work's
|
| 192 |
+
users, your or third parties' legal rights to forbid circumvention of
|
| 193 |
+
technological measures.
|
| 194 |
+
|
| 195 |
+
4. Conveying Verbatim Copies.
|
| 196 |
+
|
| 197 |
+
You may convey verbatim copies of the Program's source code as you
|
| 198 |
+
receive it, in any medium, provided that you conspicuously and
|
| 199 |
+
appropriately publish on each copy an appropriate copyright notice;
|
| 200 |
+
keep intact all notices stating that this License and any
|
| 201 |
+
non-permissive terms added in accord with section 7 apply to the code;
|
| 202 |
+
keep intact all notices of the absence of any warranty; and give all
|
| 203 |
+
recipients a copy of this License along with the Program.
|
| 204 |
+
|
| 205 |
+
You may charge any price or no price for each copy that you convey,
|
| 206 |
+
and you may offer support or warranty protection for a fee.
|
| 207 |
+
|
| 208 |
+
5. Conveying Modified Source Versions.
|
| 209 |
+
|
| 210 |
+
You may convey a work based on the Program, or the modifications to
|
| 211 |
+
produce it from the Program, in the form of source code under the
|
| 212 |
+
terms of section 4, provided that you also meet all of these conditions:
|
| 213 |
+
|
| 214 |
+
a) The work must carry prominent notices stating that you modified
|
| 215 |
+
it, and giving a relevant date.
|
| 216 |
+
|
| 217 |
+
b) The work must carry prominent notices stating that it is
|
| 218 |
+
released under this License and any conditions added under section
|
| 219 |
+
7. This requirement modifies the requirement in section 4 to
|
| 220 |
+
"keep intact all notices".
|
| 221 |
+
|
| 222 |
+
c) You must license the entire work, as a whole, under this
|
| 223 |
+
License to anyone who comes into possession of a copy. This
|
| 224 |
+
License will therefore apply, along with any applicable section 7
|
| 225 |
+
additional terms, to the whole of the work, and all its parts,
|
| 226 |
+
regardless of how they are packaged. This License gives no
|
| 227 |
+
permission to license the work in any other way, but it does not
|
| 228 |
+
invalidate such permission if you have separately received it.
|
| 229 |
+
|
| 230 |
+
d) If the work has interactive user interfaces, each must display
|
| 231 |
+
Appropriate Legal Notices; however, if the Program has interactive
|
| 232 |
+
interfaces that do not display Appropriate Legal Notices, your
|
| 233 |
+
work need not make them do so.
|
| 234 |
+
|
| 235 |
+
A compilation of a covered work with other separate and independent
|
| 236 |
+
works, which are not by their nature extensions of the covered work,
|
| 237 |
+
and which are not combined with it such as to form a larger program,
|
| 238 |
+
in or on a volume of a storage or distribution medium, is called an
|
| 239 |
+
"aggregate" if the compilation and its resulting copyright are not
|
| 240 |
+
used to limit the access or legal rights of the compilation's users
|
| 241 |
+
beyond what the individual works permit. Inclusion of a covered work
|
| 242 |
+
in an aggregate does not cause this License to apply to the other
|
| 243 |
+
parts of the aggregate.
|
| 244 |
+
|
| 245 |
+
6. Conveying Non-Source Forms.
|
| 246 |
+
|
| 247 |
+
You may convey a covered work in object code form under the terms
|
| 248 |
+
of sections 4 and 5, provided that you also convey the
|
| 249 |
+
machine-readable Corresponding Source under the terms of this License,
|
| 250 |
+
in one of these ways:
|
| 251 |
+
|
| 252 |
+
a) Convey the object code in, or embodied in, a physical product
|
| 253 |
+
(including a physical distribution medium), accompanied by the
|
| 254 |
+
Corresponding Source fixed on a durable physical medium
|
| 255 |
+
customarily used for software interchange.
|
| 256 |
+
|
| 257 |
+
b) Convey the object code in, or embodied in, a physical product
|
| 258 |
+
(including a physical distribution medium), accompanied by a
|
| 259 |
+
written offer, valid for at least three years and valid for as
|
| 260 |
+
long as you offer spare parts or customer support for that product
|
| 261 |
+
model, to give anyone who possesses the object code either (1) a
|
| 262 |
+
copy of the Corresponding Source for all the software in the
|
| 263 |
+
product that is covered by this License, on a durable physical
|
| 264 |
+
medium customarily used for software interchange, for a price no
|
| 265 |
+
more than your reasonable cost of physically performing this
|
| 266 |
+
conveying of source, or (2) access to copy the
|
| 267 |
+
Corresponding Source from a network server at no charge.
|
| 268 |
+
|
| 269 |
+
c) Convey individual copies of the object code with a copy of the
|
| 270 |
+
written offer to provide the Corresponding Source. This
|
| 271 |
+
alternative is allowed only occasionally and noncommercially, and
|
| 272 |
+
only if you received the object code with such an offer, in accord
|
| 273 |
+
with subsection 6b.
|
| 274 |
+
|
| 275 |
+
d) Convey the object code by offering access from a designated
|
| 276 |
+
place (gratis or for a charge), and offer equivalent access to the
|
| 277 |
+
Corresponding Source in the same way through the same place at no
|
| 278 |
+
further charge. You need not require recipients to copy the
|
| 279 |
+
Corresponding Source along with the object code. If the place to
|
| 280 |
+
copy the object code is a network server, the Corresponding Source
|
| 281 |
+
may be on a different server (operated by you or a third party)
|
| 282 |
+
that supports equivalent copying facilities, provided you maintain
|
| 283 |
+
clear directions next to the object code saying where to find the
|
| 284 |
+
Corresponding Source. Regardless of what server hosts the
|
| 285 |
+
Corresponding Source, you remain obligated to ensure that it is
|
| 286 |
+
available for as long as needed to satisfy these requirements.
|
| 287 |
+
|
| 288 |
+
e) Convey the object code using peer-to-peer transmission, provided
|
| 289 |
+
you inform other peers where the object code and Corresponding
|
| 290 |
+
Source of the work are being offered to the general public at no
|
| 291 |
+
charge under subsection 6d.
|
| 292 |
+
|
| 293 |
+
A separable portion of the object code, whose source code is excluded
|
| 294 |
+
from the Corresponding Source as a System Library, need not be
|
| 295 |
+
included in conveying the object code work.
|
| 296 |
+
|
| 297 |
+
A "User Product" is either (1) a "consumer product", which means any
|
| 298 |
+
tangible personal property which is normally used for personal, family,
|
| 299 |
+
or household purposes, or (2) anything designed or sold for incorporation
|
| 300 |
+
into a dwelling. In determining whether a product is a consumer product,
|
| 301 |
+
doubtful cases shall be resolved in favor of coverage. For a particular
|
| 302 |
+
product received by a particular user, "normally used" refers to a
|
| 303 |
+
typical or common use of that class of product, regardless of the status
|
| 304 |
+
of the particular user or of the way in which the particular user
|
| 305 |
+
actually uses, or expects or is expected to use, the product. A product
|
| 306 |
+
is a consumer product regardless of whether the product has substantial
|
| 307 |
+
commercial, industrial or non-consumer uses, unless such uses represent
|
| 308 |
+
the only significant mode of use of the product.
|
| 309 |
+
|
| 310 |
+
"Installation Information" for a User Product means any methods,
|
| 311 |
+
procedures, authorization keys, or other information required to install
|
| 312 |
+
and execute modified versions of a covered work in that User Product from
|
| 313 |
+
a modified version of its Corresponding Source. The information must
|
| 314 |
+
suffice to ensure that the continued functioning of the modified object
|
| 315 |
+
code is in no case prevented or interfered with solely because
|
| 316 |
+
modification has been made.
|
| 317 |
+
|
| 318 |
+
If you convey an object code work under this section in, or with, or
|
| 319 |
+
specifically for use in, a User Product, and the conveying occurs as
|
| 320 |
+
part of a transaction in which the right of possession and use of the
|
| 321 |
+
User Product is transferred to the recipient in perpetuity or for a
|
| 322 |
+
fixed term (regardless of how the transaction is characterized), the
|
| 323 |
+
Corresponding Source conveyed under this section must be accompanied
|
| 324 |
+
by the Installation Information. But this requirement does not apply
|
| 325 |
+
if neither you nor any third party retains the ability to install
|
| 326 |
+
modified object code on the User Product (for example, the work has
|
| 327 |
+
been installed in ROM).
|
| 328 |
+
|
| 329 |
+
The requirement to provide Installation Information does not include a
|
| 330 |
+
requirement to continue to provide support service, warranty, or updates
|
| 331 |
+
for a work that has been modified or installed by the recipient, or for
|
| 332 |
+
the User Product in which it has been modified or installed. Access to a
|
| 333 |
+
network may be denied when the modification itself materially and
|
| 334 |
+
adversely affects the operation of the network or violates the rules and
|
| 335 |
+
protocols for communication across the network.
|
| 336 |
+
|
| 337 |
+
Corresponding Source conveyed, and Installation Information provided,
|
| 338 |
+
in accord with this section must be in a format that is publicly
|
| 339 |
+
documented (and with an implementation available to the public in
|
| 340 |
+
source code form), and must require no special password or key for
|
| 341 |
+
unpacking, reading or copying.
|
| 342 |
+
|
| 343 |
+
7. Additional Terms.
|
| 344 |
+
|
| 345 |
+
"Additional permissions" are terms that supplement the terms of this
|
| 346 |
+
License by making exceptions from one or more of its conditions.
|
| 347 |
+
Additional permissions that are applicable to the entire Program shall
|
| 348 |
+
be treated as though they were included in this License, to the extent
|
| 349 |
+
that they are valid under applicable law. If additional permissions
|
| 350 |
+
apply only to part of the Program, that part may be used separately
|
| 351 |
+
under those permissions, but the entire Program remains governed by
|
| 352 |
+
this License without regard to the additional permissions.
|
| 353 |
+
|
| 354 |
+
When you convey a copy of a covered work, you may at your option
|
| 355 |
+
remove any additional permissions from that copy, or from any part of
|
| 356 |
+
it. (Additional permissions may be written to require their own
|
| 357 |
+
removal in certain cases when you modify the work.) You may place
|
| 358 |
+
additional permissions on material, added by you to a covered work,
|
| 359 |
+
for which you have or can give appropriate copyright permission.
|
| 360 |
+
|
| 361 |
+
Notwithstanding any other provision of this License, for material you
|
| 362 |
+
add to a covered work, you may (if authorized by the copyright holders of
|
| 363 |
+
that material) supplement the terms of this License with terms:
|
| 364 |
+
|
| 365 |
+
a) Disclaiming warranty or limiting liability differently from the
|
| 366 |
+
terms of sections 15 and 16 of this License; or
|
| 367 |
+
|
| 368 |
+
b) Requiring preservation of specified reasonable legal notices or
|
| 369 |
+
author attributions in that material or in the Appropriate Legal
|
| 370 |
+
Notices displayed by works containing it; or
|
| 371 |
+
|
| 372 |
+
c) Prohibiting misrepresentation of the origin of that material, or
|
| 373 |
+
requiring that modified versions of such material be marked in
|
| 374 |
+
reasonable ways as different from the original version; or
|
| 375 |
+
|
| 376 |
+
d) Limiting the use for publicity purposes of names of licensors or
|
| 377 |
+
authors of the material; or
|
| 378 |
+
|
| 379 |
+
e) Declining to grant rights under trademark law for use of some
|
| 380 |
+
trade names, trademarks, or service marks; or
|
| 381 |
+
|
| 382 |
+
f) Requiring indemnification of licensors and authors of that
|
| 383 |
+
material by anyone who conveys the material (or modified versions of
|
| 384 |
+
it) with contractual assumptions of liability to the recipient, for
|
| 385 |
+
any liability that these contractual assumptions directly impose on
|
| 386 |
+
those licensors and authors.
|
| 387 |
+
|
| 388 |
+
All other non-permissive additional terms are considered "further
|
| 389 |
+
restrictions" within the meaning of section 10. If the Program as you
|
| 390 |
+
received it, or any part of it, contains a notice stating that it is
|
| 391 |
+
governed by this License along with a term that is a further
|
| 392 |
+
restriction, you may remove that term. If a license document contains
|
| 393 |
+
a further restriction but permits relicensing or conveying under this
|
| 394 |
+
License, you may add to a covered work material governed by the terms
|
| 395 |
+
of that license document, provided that the further restriction does
|
| 396 |
+
not survive such relicensing or conveying.
|
| 397 |
+
|
| 398 |
+
If you add terms to a covered work in accord with this section, you
|
| 399 |
+
must place, in the relevant source files, a statement of the
|
| 400 |
+
additional terms that apply to those files, or a notice indicating
|
| 401 |
+
where to find the applicable terms.
|
| 402 |
+
|
| 403 |
+
Additional terms, permissive or non-permissive, may be stated in the
|
| 404 |
+
form of a separately written license, or stated as exceptions;
|
| 405 |
+
the above requirements apply either way.
|
| 406 |
+
|
| 407 |
+
8. Termination.
|
| 408 |
+
|
| 409 |
+
You may not propagate or modify a covered work except as expressly
|
| 410 |
+
provided under this License. Any attempt otherwise to propagate or
|
| 411 |
+
modify it is void, and will automatically terminate your rights under
|
| 412 |
+
this License (including any patent licenses granted under the third
|
| 413 |
+
paragraph of section 11).
|
| 414 |
+
|
| 415 |
+
However, if you cease all violation of this License, then your
|
| 416 |
+
license from a particular copyright holder is reinstated (a)
|
| 417 |
+
provisionally, unless and until the copyright holder explicitly and
|
| 418 |
+
finally terminates your license, and (b) permanently, if the copyright
|
| 419 |
+
holder fails to notify you of the violation by some reasonable means
|
| 420 |
+
prior to 60 days after the cessation.
|
| 421 |
+
|
| 422 |
+
Moreover, your license from a particular copyright holder is
|
| 423 |
+
reinstated permanently if the copyright holder notifies you of the
|
| 424 |
+
violation by some reasonable means, this is the first time you have
|
| 425 |
+
received notice of violation of this License (for any work) from that
|
| 426 |
+
copyright holder, and you cure the violation prior to 30 days after
|
| 427 |
+
your receipt of the notice.
|
| 428 |
+
|
| 429 |
+
Termination of your rights under this section does not terminate the
|
| 430 |
+
licenses of parties who have received copies or rights from you under
|
| 431 |
+
this License. If your rights have been terminated and not permanently
|
| 432 |
+
reinstated, you do not qualify to receive new licenses for the same
|
| 433 |
+
material under section 10.
|
| 434 |
+
|
| 435 |
+
9. Acceptance Not Required for Having Copies.
|
| 436 |
+
|
| 437 |
+
You are not required to accept this License in order to receive or
|
| 438 |
+
run a copy of the Program. Ancillary propagation of a covered work
|
| 439 |
+
occurring solely as a consequence of using peer-to-peer transmission
|
| 440 |
+
to receive a copy likewise does not require acceptance. However,
|
| 441 |
+
nothing other than this License grants you permission to propagate or
|
| 442 |
+
modify any covered work. These actions infringe copyright if you do
|
| 443 |
+
not accept this License. Therefore, by modifying or propagating a
|
| 444 |
+
covered work, you indicate your acceptance of this License to do so.
|
| 445 |
+
|
| 446 |
+
10. Automatic Licensing of Downstream Recipients.
|
| 447 |
+
|
| 448 |
+
Each time you convey a covered work, the recipient automatically
|
| 449 |
+
receives a license from the original licensors, to run, modify and
|
| 450 |
+
propagate that work, subject to this License. You are not responsible
|
| 451 |
+
for enforcing compliance by third parties with this License.
|
| 452 |
+
|
| 453 |
+
An "entity transaction" is a transaction transferring control of an
|
| 454 |
+
organization, or substantially all assets of one, or subdividing an
|
| 455 |
+
organization, or merging organizations. If propagation of a covered
|
| 456 |
+
work results from an entity transaction, each party to that
|
| 457 |
+
transaction who receives a copy of the work also receives whatever
|
| 458 |
+
licenses to the work the party's predecessor in interest had or could
|
| 459 |
+
give under the previous paragraph, plus a right to possession of the
|
| 460 |
+
Corresponding Source of the work from the predecessor in interest, if
|
| 461 |
+
the predecessor has it or can get it with reasonable efforts.
|
| 462 |
+
|
| 463 |
+
You may not impose any further restrictions on the exercise of the
|
| 464 |
+
rights granted or affirmed under this License. For example, you may
|
| 465 |
+
not impose a license fee, royalty, or other charge for exercise of
|
| 466 |
+
rights granted under this License, and you may not initiate litigation
|
| 467 |
+
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
| 468 |
+
any patent claim is infringed by making, using, selling, offering for
|
| 469 |
+
sale, or importing the Program or any portion of it.
|
| 470 |
+
|
| 471 |
+
11. Patents.
|
| 472 |
+
|
| 473 |
+
A "contributor" is a copyright holder who authorizes use under this
|
| 474 |
+
License of the Program or a work on which the Program is based. The
|
| 475 |
+
work thus licensed is called the contributor's "contributor version".
|
| 476 |
+
|
| 477 |
+
A contributor's "essential patent claims" are all patent claims
|
| 478 |
+
owned or controlled by the contributor, whether already acquired or
|
| 479 |
+
hereafter acquired, that would be infringed by some manner, permitted
|
| 480 |
+
by this License, of making, using, or selling its contributor version,
|
| 481 |
+
but do not include claims that would be infringed only as a
|
| 482 |
+
consequence of further modification of the contributor version. For
|
| 483 |
+
purposes of this definition, "control" includes the right to grant
|
| 484 |
+
patent sublicenses in a manner consistent with the requirements of
|
| 485 |
+
this License.
|
| 486 |
+
|
| 487 |
+
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
| 488 |
+
patent license under the contributor's essential patent claims, to
|
| 489 |
+
make, use, sell, offer for sale, import and otherwise run, modify and
|
| 490 |
+
propagate the contents of its contributor version.
|
| 491 |
+
|
| 492 |
+
In the following three paragraphs, a "patent license" is any express
|
| 493 |
+
agreement or commitment, however denominated, not to enforce a patent
|
| 494 |
+
(such as an express permission to practice a patent or covenant not to
|
| 495 |
+
sue for patent infringement). To "grant" such a patent license to a
|
| 496 |
+
party means to make such an agreement or commitment not to enforce a
|
| 497 |
+
patent against the party.
|
| 498 |
+
|
| 499 |
+
If you convey a covered work, knowingly relying on a patent license,
|
| 500 |
+
and the Corresponding Source of the work is not available for anyone
|
| 501 |
+
to copy, free of charge and under the terms of this License, through a
|
| 502 |
+
publicly available network server or other readily accessible means,
|
| 503 |
+
then you must either (1) cause the Corresponding Source to be so
|
| 504 |
+
available, or (2) arrange to deprive yourself of the benefit of the
|
| 505 |
+
patent license for this particular work, or (3) arrange, in a manner
|
| 506 |
+
consistent with the requirements of this License, to extend the patent
|
| 507 |
+
license to downstream recipients. "Knowingly relying" means you have
|
| 508 |
+
actual knowledge that, but for the patent license, your conveying the
|
| 509 |
+
covered work in a country, or your recipient's use of the covered work
|
| 510 |
+
in a country, would infringe one or more identifiable patents in that
|
| 511 |
+
country that you have reason to believe are valid.
|
| 512 |
+
|
| 513 |
+
If, pursuant to or in connection with a single transaction or
|
| 514 |
+
arrangement, you convey, or propagate by procuring conveyance of, a
|
| 515 |
+
covered work, and grant a patent license to some of the parties
|
| 516 |
+
receiving the covered work authorizing them to use, propagate, modify
|
| 517 |
+
or convey a specific copy of the covered work, then the patent license
|
| 518 |
+
you grant is automatically extended to all recipients of the covered
|
| 519 |
+
work and works based on it.
|
| 520 |
+
|
| 521 |
+
A patent license is "discriminatory" if it does not include within
|
| 522 |
+
the scope of its coverage, prohibits the exercise of, or is
|
| 523 |
+
conditioned on the non-exercise of one or more of the rights that are
|
| 524 |
+
specifically granted under this License. You may not convey a covered
|
| 525 |
+
work if you are a party to an arrangement with a third party that is
|
| 526 |
+
in the business of distributing software, under which you make payment
|
| 527 |
+
to the third party based on the extent of your activity of conveying
|
| 528 |
+
the work, and under which the third party grants, to any of the
|
| 529 |
+
parties who would receive the covered work from you, a discriminatory
|
| 530 |
+
patent license (a) in connection with copies of the covered work
|
| 531 |
+
conveyed by you (or copies made from those copies), or (b) primarily
|
| 532 |
+
for and in connection with specific products or compilations that
|
| 533 |
+
contain the covered work, unless you entered into that arrangement,
|
| 534 |
+
or that patent license was granted, prior to 28 March 2007.
|
| 535 |
+
|
| 536 |
+
Nothing in this License shall be construed as excluding or limiting
|
| 537 |
+
any implied license or other defenses to infringement that may
|
| 538 |
+
otherwise be available to you under applicable patent law.
|
| 539 |
+
|
| 540 |
+
12. No Surrender of Others' Freedom.
|
| 541 |
+
|
| 542 |
+
If conditions are imposed on you (whether by court order, agreement or
|
| 543 |
+
otherwise) that contradict the conditions of this License, they do not
|
| 544 |
+
excuse you from the conditions of this License. If you cannot convey a
|
| 545 |
+
covered work so as to satisfy simultaneously your obligations under this
|
| 546 |
+
License and any other pertinent obligations, then as a consequence you may
|
| 547 |
+
not convey it at all. For example, if you agree to terms that obligate you
|
| 548 |
+
to collect a royalty for further conveying from those to whom you convey
|
| 549 |
+
the Program, the only way you could satisfy both those terms and this
|
| 550 |
+
License would be to refrain entirely from conveying the Program.
|
| 551 |
+
|
| 552 |
+
13. Use with the GNU Affero General Public License.
|
| 553 |
+
|
| 554 |
+
Notwithstanding any other provision of this License, you have
|
| 555 |
+
permission to link or combine any covered work with a work licensed
|
| 556 |
+
under version 3 of the GNU Affero General Public License into a single
|
| 557 |
+
combined work, and to convey the resulting work. The terms of this
|
| 558 |
+
License will continue to apply to the part which is the covered work,
|
| 559 |
+
but the special requirements of the GNU Affero General Public License,
|
| 560 |
+
section 13, concerning interaction through a network will apply to the
|
| 561 |
+
combination as such.
|
| 562 |
+
|
| 563 |
+
14. Revised Versions of this License.
|
| 564 |
+
|
| 565 |
+
The Free Software Foundation may publish revised and/or new versions of
|
| 566 |
+
the GNU General Public License from time to time. Such new versions will
|
| 567 |
+
be similar in spirit to the present version, but may differ in detail to
|
| 568 |
+
address new problems or concerns.
|
| 569 |
+
|
| 570 |
+
Each version is given a distinguishing version number. If the
|
| 571 |
+
Program specifies that a certain numbered version of the GNU General
|
| 572 |
+
Public License "or any later version" applies to it, you have the
|
| 573 |
+
option of following the terms and conditions either of that numbered
|
| 574 |
+
version or of any later version published by the Free Software
|
| 575 |
+
Foundation. If the Program does not specify a version number of the
|
| 576 |
+
GNU General Public License, you may choose any version ever published
|
| 577 |
+
by the Free Software Foundation.
|
| 578 |
+
|
| 579 |
+
If the Program specifies that a proxy can decide which future
|
| 580 |
+
versions of the GNU General Public License can be used, that proxy's
|
| 581 |
+
public statement of acceptance of a version permanently authorizes you
|
| 582 |
+
to choose that version for the Program.
|
| 583 |
+
|
| 584 |
+
Later license versions may give you additional or different
|
| 585 |
+
permissions. However, no additional obligations are imposed on any
|
| 586 |
+
author or copyright holder as a result of your choosing to follow a
|
| 587 |
+
later version.
|
| 588 |
+
|
| 589 |
+
15. Disclaimer of Warranty.
|
| 590 |
+
|
| 591 |
+
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
| 592 |
+
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
| 593 |
+
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
| 594 |
+
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
| 595 |
+
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
| 596 |
+
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
| 597 |
+
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
| 598 |
+
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
| 599 |
+
|
| 600 |
+
16. Limitation of Liability.
|
| 601 |
+
|
| 602 |
+
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
| 603 |
+
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
| 604 |
+
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
| 605 |
+
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
| 606 |
+
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
| 607 |
+
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
| 608 |
+
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
| 609 |
+
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
| 610 |
+
SUCH DAMAGES.
|
| 611 |
+
|
| 612 |
+
17. Interpretation of Sections 15 and 16.
|
| 613 |
+
|
| 614 |
+
If the disclaimer of warranty and limitation of liability provided
|
| 615 |
+
above cannot be given local legal effect according to their terms,
|
| 616 |
+
reviewing courts shall apply local law that most closely approximates
|
| 617 |
+
an absolute waiver of all civil liability in connection with the
|
| 618 |
+
Program, unless a warranty or assumption of liability accompanies a
|
| 619 |
+
copy of the Program in return for a fee.
|
| 620 |
+
|
| 621 |
+
END OF TERMS AND CONDITIONS
|
| 622 |
+
|
| 623 |
+
How to Apply These Terms to Your New Programs
|
| 624 |
+
|
| 625 |
+
If you develop a new program, and you want it to be of the greatest
|
| 626 |
+
possible use to the public, the best way to achieve this is to make it
|
| 627 |
+
free software which everyone can redistribute and change under these terms.
|
| 628 |
+
|
| 629 |
+
To do so, attach the following notices to the program. It is safest
|
| 630 |
+
to attach them to the start of each source file to most effectively
|
| 631 |
+
state the exclusion of warranty; and each file should have at least
|
| 632 |
+
the "copyright" line and a pointer to where the full notice is found.
|
| 633 |
+
|
| 634 |
+
<one line to give the program's name and a brief idea of what it does.>
|
| 635 |
+
Copyright (C) <year> <name of author>
|
| 636 |
+
|
| 637 |
+
This program is free software: you can redistribute it and/or modify
|
| 638 |
+
it under the terms of the GNU General Public License as published by
|
| 639 |
+
the Free Software Foundation, either version 3 of the License, or
|
| 640 |
+
(at your option) any later version.
|
| 641 |
+
|
| 642 |
+
This program is distributed in the hope that it will be useful,
|
| 643 |
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 644 |
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 645 |
+
GNU General Public License for more details.
|
| 646 |
+
|
| 647 |
+
You should have received a copy of the GNU General Public License
|
| 648 |
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
| 649 |
+
|
| 650 |
+
Also add information on how to contact you by electronic and paper mail.
|
| 651 |
+
|
| 652 |
+
If the program does terminal interaction, make it output a short
|
| 653 |
+
notice like this when it starts in an interactive mode:
|
| 654 |
+
|
| 655 |
+
<program> Copyright (C) <year> <name of author>
|
| 656 |
+
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
| 657 |
+
This is free software, and you are welcome to redistribute it
|
| 658 |
+
under certain conditions; type `show c' for details.
|
| 659 |
+
|
| 660 |
+
The hypothetical commands `show w' and `show c' should show the appropriate
|
| 661 |
+
parts of the General Public License. Of course, your program's commands
|
| 662 |
+
might be different; for a GUI interface, you would use an "about box".
|
| 663 |
+
|
| 664 |
+
You should also get your employer (if you work as a programmer) or school,
|
| 665 |
+
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
| 666 |
+
For more information on this, and how to apply and follow the GNU GPL, see
|
| 667 |
+
<https://www.gnu.org/licenses/>.
|
| 668 |
+
|
| 669 |
+
The GNU General Public License does not permit incorporating your program
|
| 670 |
+
into proprietary programs. If your program is a subroutine library, you
|
| 671 |
+
may consider it more useful to permit linking proprietary applications with
|
| 672 |
+
the library. If this is what you want to do, use the GNU Lesser General
|
| 673 |
+
Public License instead of this License. But first, please read
|
| 674 |
+
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
README.md
ADDED
|
@@ -0,0 +1,937 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: ChessTB Explorer
|
| 3 |
+
emoji: ♟️
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: true
|
| 8 |
+
app_port: 7860
|
| 9 |
+
license: gpl-3.0
|
| 10 |
+
short_description: "Interactive tablebase probing: WDL/DTZ/DTC/DTM/DTM50"
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# ChessTB Explorer ♟️
|
| 14 |
+
|
| 15 |
+
Interactive tablebase probing interface with WDL/DTZ/DTC/DTM/DTM50 metrics.
|
| 16 |
+
|
| 17 |
+
This Hugging Face Space is deployed from
|
| 18 |
+
[PunisherChess/ChessTB-Explorer-Render](https://github.com/PunisherChess/ChessTB-Explorer-Render)
|
| 19 |
+
at commit `4969e401fa41ef1b86458cac4b6a31f9b11e08d0`.
|
| 20 |
+
The original authors' license and third-party notices are retained.
|
| 21 |
+
|
| 22 |
+
The application probes the public `chesstb/chesstb` bucket over HTTP byte
|
| 23 |
+
ranges, using the explorer's existing bounded page cache. Sources are
|
| 24 |
+
searched per table with raw generator output under `full/` preferred over
|
| 25 |
+
shipping tables at the bucket root. Castling materials use `full/castling/`
|
| 26 |
+
and `castling/`, respectively.
|
| 27 |
+
Availability of each metric depends on the published tables for that material.
|
| 28 |
+
|
| 29 |
+
`ADMIN_TOKEN` and `FLASK_SECRET_KEY` are configured as Space secrets.
|
| 30 |
+
The probing library follows the maintained `add-chesstb-tablebases` branch
|
| 31 |
+
of `noobpwnftw/python-chess`.
|
| 32 |
+
|
| 33 |
+
[](https://chesstb-explorer-render.onrender.com)
|
| 34 |
+
|
| 35 |
+
A single-page web application for exploring **ChessTB** chess endgame
|
| 36 |
+
tablebases. Paste or build any position on an interactive board and
|
| 37 |
+
instantly see every legal move ranked by **Distance to Zeroing (DTZ)**,
|
| 38 |
+
**Distance to Conversion (DTC)**, **Distance to Mate (DTM)**, and **DTM
|
| 39 |
+
under the 50-move rule (DTM50)** - straight from the tablebase, with no
|
| 40 |
+
chess engine involved.
|
| 41 |
+
|
| 42 |
+
The backend is a small Flask application that probes the tablebase through
|
| 43 |
+
a modified fork of [`python-chess`](https://github.com/noobpwnftw/python-chess/tree/add-chesstb-tablebases)
|
| 44 |
+
(the `chess.chesstb` module - see [Installation](#installation)). The frontend is
|
| 45 |
+
a React app built around
|
| 46 |
+
[Chessground](https://github.com/lichess-org/chessground) (lichess.org's
|
| 47 |
+
board component) and [chessops](https://github.com/niklasf/chessops) for
|
| 48 |
+
move generation/validation, supporting both Standard chess and Chess960
|
| 49 |
+
(Fischer Random).
|
| 50 |
+
|
| 51 |
+
> This tool only *displays* tablebase results - it does not generate,
|
| 52 |
+
> verify, or ship any tablebase data itself. See
|
| 53 |
+
> [Getting the tablebase files](#getting-the-tablebase-files) below.
|
| 54 |
+
|
| 55 |
+
> The probe endpoints are unauthenticated by design - evaluating a
|
| 56 |
+
> position is this app's public functionality. The `/admin` cache
|
| 57 |
+
> dashboard is gated behind `ADMIN_TOKEN`, a secret configured by the deployment
|
| 58 |
+
> (see [Security notes](#security-notes)).
|
| 59 |
+
|
| 60 |
+
---
|
| 61 |
+
|
| 62 |
+
## Features
|
| 63 |
+
|
| 64 |
+
- **Interactive board**: drag-and-drop moves, click-to-place pieces from
|
| 65 |
+
the spare-piece trays, pawn promotion dialog, board flip (orientation
|
| 66 |
+
persisted locally across visits), undo/redo.
|
| 67 |
+
- **Chess960 (Fischer Random) support**: a **Chess960 Mode** switch in
|
| 68 |
+
the settings panel changes castling interpretation without altering the
|
| 69 |
+
current board position: dragging a king onto its own rook castles the
|
| 70 |
+
Chess960 way, and X-FEN/Shredder-FEN rook-file castling-rights letters
|
| 71 |
+
(e.g. `HAha`) are accepted in the FEN box. Carried through the shared
|
| 72 |
+
URL (`&variant=chess960`), the saved in-progress game, and PGN
|
| 73 |
+
import/export (`[Variant "Chess960"]`). Off by default.
|
| 74 |
+
- **Lock**: a padlock button beside the FEN box that restricts board
|
| 75 |
+
interaction to legal moves only: dragging, click-to-move, and the
|
| 76 |
+
spare-piece trays are limited to the side to move's legal destinations,
|
| 77 |
+
and off-board drops are disabled. Unlocked by default; a manual toggle
|
| 78 |
+
is remembered locally across visits. Auto-play also engages Lock for
|
| 79 |
+
the duration of a run (unless already locked by hand), and disables the
|
| 80 |
+
padlock button until the run stops.
|
| 81 |
+
- **Four ranked move tables side by side**: DTZ, DTC, DTM, and DTM50
|
| 82 |
+
columns, each independently sorted best-move-first. Exact metric ties
|
| 83 |
+
prefer immediate checkmate, then use SAN order with promotions along
|
| 84 |
+
the same path ordered queen, rook, bishop, knight. A better-scoring
|
| 85 |
+
underpromotion still ranks first. Includes:
|
| 86 |
+
- score text colour-coded by outcome (win / cursed win / draw /
|
| 87 |
+
blessed loss / loss)
|
| 88 |
+
- a **warning dot** on any move flagged cursed win or blessed loss,
|
| 89 |
+
with a hover tooltip explaining the 50-move-rule nuance; a DTZ score
|
| 90 |
+
that may still be a ply off after the moves are cross-checked shows a
|
| 91 |
+
second tooltip line
|
| 92 |
+
- an **info dot** on a draw by insufficient material, keeping the score
|
| 93 |
+
text itself reading as a plain "Draw"
|
| 94 |
+
- dot messages on touch or pen input: tapping a dot shows its tooltip
|
| 95 |
+
text as a notice and does not play the move
|
| 96 |
+
- an outcome summary (wins / draws / losses / unknown) across legal moves,
|
| 97 |
+
respecting the halfmove clock and 50-move rule. It uses DTM50 outcomes,
|
| 98 |
+
or DTC when DTM50 is unavailable. Without either, clock-independent
|
| 99 |
+
draws, cursed wins and blessed losses count as draws; unresolved
|
| 100 |
+
wins/losses count as unknown.
|
| 101 |
+
- an optional pinned **Root Row** above rank 1, showing the current
|
| 102 |
+
position's own score for each metric as a reference point - toggled
|
| 103 |
+
from the settings panel (**Show Root Row**, off by default) and
|
| 104 |
+
persisted locally
|
| 105 |
+
- **Best-move arrows**: the top DTZ, DTC, DTM, and DTM50 moves are drawn
|
| 106 |
+
as colour-coded arrows directly on the board.
|
| 107 |
+
- **Auto-play**: automatically plays the best move for any of the four
|
| 108 |
+
metrics on a timer. The per-move delay
|
| 109 |
+
is set from the settings panel (**Autoplay Delay**, 0s–2.5s in 50ms
|
| 110 |
+
steps, 1.25s by default). The countdown starts after the current
|
| 111 |
+
position's tablebase results are displayed, giving you the full delay
|
| 112 |
+
to read them before the next move. A metric's button is disabled when its
|
| 113 |
+
table isn't present for the current material, or when the position is
|
| 114 |
+
already a draw.
|
| 115 |
+
- **PGN import/export**: paste a PGN to load its full move tree, including
|
| 116 |
+
variations, at the starting position. Step through it or click any move
|
| 117 |
+
in the PGN panel, or download the current game as a PGN file.
|
| 118 |
+
- **CSV export** of the current move table, including the Root Row line
|
| 119 |
+
ahead of rank 1 when that setting is on.
|
| 120 |
+
- **Move-list / PGN panel** with full undo/redo and click-to-jump - jumping
|
| 121 |
+
to an earlier point in the line re-uses the cached probe instead of
|
| 122 |
+
re-querying the tablebase.
|
| 123 |
+
- **Shareable positions**: the FEN (and, in Chess960 Mode, the active
|
| 124 |
+
variant) is written to the URL hash on every move, so a link to the
|
| 125 |
+
page reproduces the exact position and ruleset.
|
| 126 |
+
- **Session persistence**: the in-progress game (not just the position)
|
| 127 |
+
survives a trip to the admin dashboard and back.
|
| 128 |
+
- **Board and piece set**: Libre Brown board, CBurnett piece set.
|
| 129 |
+
- **Streaming probes**: `/probe/stream` reports progress via
|
| 130 |
+
Server-Sent Events while child positions are being probed, so the UI
|
| 131 |
+
shows a live progress bar instead of a blank pause on slower lookups.
|
| 132 |
+
- **Hover pre-fetch**: hovering a move in the table warms the cache for
|
| 133 |
+
the resulting position before selection.
|
| 134 |
+
- **Admin cache dashboard** (`/admin`, behind `ADMIN_TOKEN`) - live
|
| 135 |
+
hit-rate stats for both LRU caches, thread-pool configuration, and a
|
| 136 |
+
one-click cache-clear button.
|
| 137 |
+
- **Machine-readable API**: the full HTTP API is described by an OpenAPI
|
| 138 |
+
3.0 document served at `/openapi.yaml`.
|
| 139 |
+
- **Keyboard shortcuts**: `Enter` Apply, `F` Flip, `C` Clear, `←`/`→`
|
| 140 |
+
Back/Forward. The Board Settings panel lists them on devices with hover.
|
| 141 |
+
|
| 142 |
+
---
|
| 143 |
+
|
| 144 |
+
## Architecture at a glance
|
| 145 |
+
|
| 146 |
+
```
|
| 147 |
+
Browser (React app - see frontend/)
|
| 148 |
+
├─ engine/board.js - chessops-backed position/rules state, chessground
|
| 149 |
+
│ wrapper (drag/drop, history, arrows), active variant
|
| 150 |
+
├─ engine/tablebase.js - talks to /probe/stream, exposes results as state
|
| 151 |
+
├─ engine/theme.js - board/piece-set theming
|
| 152 |
+
├─ hooks/useExplorer.js - FEN box, PGN, auto-play, settings, keyboard shortcuts
|
| 153 |
+
└─ components/ - the rendered page (board, results table, PGN panel, dialogs)
|
| 154 |
+
│ HTTP (JSON) / SSE (text/event-stream) - {fen, variant}
|
| 155 |
+
▼
|
| 156 |
+
Flask app (app.py)
|
| 157 |
+
├─ /probe, /probe/stream - evaluate a FEN under a variant, rank every legal move
|
| 158 |
+
├─ /admin, /admin/cache/* - cache dashboard + stats API
|
| 159 |
+
└─ /openapi.yaml - API specification
|
| 160 |
+
│
|
| 161 |
+
▼
|
| 162 |
+
tablebase_router.py - searches TABLEBASE_PATH in order for every material,
|
| 163 |
+
including castling positions and recursive dependencies
|
| 164 |
+
│
|
| 165 |
+
▼
|
| 166 |
+
chesstb.open_tablebase(...) per source (noobpwnftw's modified python-chess fork)
|
| 167 |
+
│
|
| 168 |
+
▼
|
| 169 |
+
ChessTB tablebase files - local disk, or a remote http(s) URL (probed
|
| 170 |
+
in place over byte ranges by default, or downloaded and cached to local
|
| 171 |
+
disk per material - see remote/remote_direct.py, remote/remote_fallback.py,
|
| 172 |
+
and "Getting the tablebase files" below)
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
The React app is built with Vite into `static/dist/` (`app.js`,
|
| 176 |
+
`app.css`), which the Flask template loads as same-origin assets, as the
|
| 177 |
+
Content-Security-Policy (`script-src 'self'`) requires. See
|
| 178 |
+
[Frontend build](#frontend-build) below.
|
| 179 |
+
|
| 180 |
+
This covers the main explorer page's (`index.html`) module graph - see
|
| 181 |
+
[Project structure](#project-structure) below for the complete file set,
|
| 182 |
+
including `admin.js` (the `/admin` dashboard's client script, a plain
|
| 183 |
+
server-rendered page with no build step).
|
| 184 |
+
|
| 185 |
+
The Python modules and the frontend `engine/` modules carry a file-level
|
| 186 |
+
docstring/header comment describing their responsibilities, and the
|
| 187 |
+
move-ranking algorithm itself - including
|
| 188 |
+
its treatment of the 50-move rule - is documented inline in `app.py`
|
| 189 |
+
above `evaluate_all_moves()` and the `_effective_move_wdl`/
|
| 190 |
+
`_effective_distance`/`_order_rank` helpers it calls.
|
| 191 |
+
|
| 192 |
+
---
|
| 193 |
+
|
| 194 |
+
## Frontend build
|
| 195 |
+
|
| 196 |
+
The explorer page is a React app (`frontend/`) built with Vite into
|
| 197 |
+
`static/dist/`, which the Flask app serves alongside its own
|
| 198 |
+
static assets. The build output is checked in, so running the app needs
|
| 199 |
+
no frontend server or build step. Rebuild after changing anything under
|
| 200 |
+
`frontend/src/`:
|
| 201 |
+
|
| 202 |
+
```bash
|
| 203 |
+
cd frontend
|
| 204 |
+
npm ci
|
| 205 |
+
npm run build # writes static/dist/app.js, app.css, admin.css, fonts/
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
`frontend/src/engine/` and `frontend/src/vendor/` hold the position/rules
|
| 209 |
+
engine (chessops + Chessground) and the tablebase client, consumed by
|
| 210 |
+
the React components in `frontend/src/components/`. Shared UI primitives
|
| 211 |
+
(`Icon`, `Button`, `Segmented`, `Panel`) are in `frontend/src/components/ui/`.
|
| 212 |
+
Piece SVGs and the piece-set stylesheet are in `static/` and are linked
|
| 213 |
+
directly from the page template.
|
| 214 |
+
|
| 215 |
+
### Styling
|
| 216 |
+
|
| 217 |
+
Stylesheets are in `frontend/src/styles/` and are bundled by Vite into
|
| 218 |
+
`static/dist/app.css` (explorer page) and `static/dist/admin.css` (`/admin`).
|
| 219 |
+
`tokens.css` defines the colour, type, spacing, radius, elevation, motion,
|
| 220 |
+
and control-size custom properties; other files reference them instead of
|
| 221 |
+
literal values. Files are imported into the `reset`, `vendor`, and `app`
|
| 222 |
+
cascade layers by `index.css` and `admin.css`.
|
| 223 |
+
|
| 224 |
+
| Token group | Custom properties |
|
| 225 |
+
|---|---|
|
| 226 |
+
| Type scale | `--text-xs` 12 px, `--text-sm` 13 px, `--text-md` 14 px, `--text-lg` 16 px, `--text-xl` 20 px |
|
| 227 |
+
| Spacing | `--space-1` to `--space-6` (4 px to 32 px) |
|
| 228 |
+
| Controls | `--control-h`, `--control-h-sm` (44 px on touch-primary devices) |
|
| 229 |
+
| Board | `--board-light`, `--board-dark`, `--board-highlight`, read by `engine/theme.js` |
|
| 230 |
+
|
| 231 |
+
Sizes are authored in `rem`, so the large-screen root font size scaling
|
| 232 |
+
applies to all text and controls.
|
| 233 |
+
|
| 234 |
+
### Page layout
|
| 235 |
+
|
| 236 |
+
| Viewport | Layout |
|
| 237 |
+
|---|---|
|
| 238 |
+
| Above 1180 px | Three-column CSS grid (board, results, PGN). The results and PGN columns take the board column's height and scroll internally. |
|
| 239 |
+
| 768 to 1180 px | Board row above a results and PGN row; the page scrolls. |
|
| 240 |
+
| Below 768 px | Three horizontally swipeable screens (board, results, PGN) with a bottom navigation bar (`ScreenNav`). Header and navigation heights include the device safe-area insets, and screen heights use `dvh`. The header shows the subtitle below the title, and the board is sized to the visible height so the whole board screen fits. |
|
| 241 |
+
|
| 242 |
+
The side-to-move toggle and the board form one group (`.board-group`) as wide
|
| 243 |
+
as the board, with a fixed small gap between them at every screen size. Free
|
| 244 |
+
vertical space in the board column sits above the group.
|
| 245 |
+
|
| 246 |
+
The results panel is a `results` size container. At 49 rem wide and above it
|
| 247 |
+
shows the four metric columns; below that it shows one metric at a time,
|
| 248 |
+
selected with the metric tabs, regardless of viewport width. The desktop
|
| 249 |
+
layout reserves 50 rem for this panel before growing the board, so the
|
| 250 |
+
four-metric view can appear as soon as the three columns fit. The results
|
| 251 |
+
table is a fixed header table above a scrolling body table, so the scrollbar
|
| 252 |
+
starts at the first moves row. The body reserves no scrollbar gutter: while
|
| 253 |
+
its rows overflow, its table is widened by the scrollbar's measured width, so
|
| 254 |
+
columns keep the header's position and width whether or not a scrollbar shows.
|
| 255 |
+
The PGN move-number column width is set by `--pgn-rank-w` in `tokens.css`.
|
| 256 |
+
|
| 257 |
+
### Display scaling
|
| 258 |
+
|
| 259 |
+
Sizing is CSS-only and is defined in `frontend/src/styles/layout.css`.
|
| 260 |
+
|
| 261 |
+
- **Text:** above 1180 px, the root font size of the explorer page
|
| 262 |
+
(`html.explorer`) is 100% of the browser default up to a 1536 x 780 window
|
| 263 |
+
and rises to at most 125% (20 px), following the window's width and
|
| 264 |
+
height. Browser zoom and the browser default font size apply.
|
| 265 |
+
- **Board:** above 1180 px, `--board-size` fills the window height in
|
| 266 |
+
multiples of 8 px, from 20.5 rem up to `--board-size-max` (720 px, defined
|
| 267 |
+
in `tokens.css`), and leaves at least 40 rem for the results table. The
|
| 268 |
+
left column is as wide as the board, so it is capped at 720 px as well.
|
| 269 |
+
The width the board column does not use goes to the results column, so
|
| 270 |
+
the total content width stays constant.
|
| 271 |
+
- **Tablet layout:** at 1180 px and below, the root font size is the
|
| 272 |
+
browser default and `--board-size` is `--board-size-cap`, defined in
|
| 273 |
+
`tokens.css` as
|
| 274 |
+
`min(720px, clamp(18.75rem, min(23.4375vw, 41.6667vh), 31.5rem))`.
|
| 275 |
+
- **Phone layout:** below 768 px, `--board-size` is the smaller of
|
| 276 |
+
`--board-size-cap` (`min(360px, 88vw)` up to 480 px wide) and the height
|
| 277 |
+
that remains after the header, bottom navigation, and the controls
|
| 278 |
+
above the board, with a floor of 14 rem. The board screen therefore
|
| 279 |
+
fits the visible height regardless of browser toolbar size. Below the
|
| 280 |
+
floor, the screen scrolls from its top.
|
| 281 |
+
- `--board-fit-h` includes the fixed height above the board in the left
|
| 282 |
+
column and requires an update when that height changes. This applies to
|
| 283 |
+
both the desktop definition in `layout.css` and the phone definition in
|
| 284 |
+
`responsive.css`.
|
| 285 |
+
- `html.explorer` is set in `templates/index.html` and `frontend/index.html`.
|
| 286 |
+
`/admin` does not use it.
|
| 287 |
+
|
| 288 |
+
---
|
| 289 |
+
|
| 290 |
+
## Prerequisites
|
| 291 |
+
|
| 292 |
+
- **Python 3.10+**
|
| 293 |
+
- **Git**, available on `PATH` - `pip install -r requirements.txt` fetches the
|
| 294 |
+
`python-chess` fork directly from GitHub (`chess @ git+https://...`),
|
| 295 |
+
which requires `git` to be installed even though the package itself is
|
| 296 |
+
Python.
|
| 297 |
+
- **A ChessTB tablebase directory or URL**: either a local directory on
|
| 298 |
+
disk, or an `http(s)://` base URL serving the same layout remotely with
|
| 299 |
+
no local download (see [Getting the tablebase files](#getting-the-tablebase-files)).
|
| 300 |
+
The app starts without one, but every probe will fail until
|
| 301 |
+
`TABLEBASE_PATH` points at the required sources. See
|
| 302 |
+
[Configuration reference](#configuration-reference).
|
| 303 |
+
- A modern browser (the frontend uses `fetch`, `ReadableStream`, and CSS
|
| 304 |
+
container queries).
|
| 305 |
+
|
| 306 |
+
---
|
| 307 |
+
|
| 308 |
+
## Installation
|
| 309 |
+
|
| 310 |
+
1. **Create and activate a virtual environment:**
|
| 311 |
+
|
| 312 |
+
```bash
|
| 313 |
+
python -m venv .venv
|
| 314 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 315 |
+
```
|
| 316 |
+
|
| 317 |
+
2. **Install dependencies:**
|
| 318 |
+
|
| 319 |
+
```bash
|
| 320 |
+
pip install -r requirements.txt
|
| 321 |
+
```
|
| 322 |
+
|
| 323 |
+
This pulls in the **modified `python-chess` fork** (not the plain
|
| 324 |
+
PyPI `chess` package; see the comments in `requirements.txt` for why
|
| 325 |
+
that distinction matters).
|
| 326 |
+
|
| 327 |
+
3. **Get the tablebase files**: see the next section. When using the
|
| 328 |
+
remote (no-download) option requires no fetch; continue to step 4 and
|
| 329 |
+
point `TABLEBASE_PATH` at the URL.
|
| 330 |
+
|
| 331 |
+
4. **Configure `config.py`**: open it and set `TABLEBASE_PATH` to the
|
| 332 |
+
directory (or URL) from step 3:
|
| 333 |
+
|
| 334 |
+
```python
|
| 335 |
+
TABLEBASE_PATH = "/data/chesstb"
|
| 336 |
+
```
|
| 337 |
+
|
| 338 |
+
or, for the remote option:
|
| 339 |
+
|
| 340 |
+
```python
|
| 341 |
+
TABLEBASE_PATH = "https://huggingface.co/buckets/chesstb/chesstb/resolve"
|
| 342 |
+
```
|
| 343 |
+
|
| 344 |
+
This alone covers every piece count. See
|
| 345 |
+
[Getting the tablebase files](#getting-the-tablebase-files) for
|
| 346 |
+
the ordered source list, which includes shipping tables for both ordinary
|
| 347 |
+
and castling materials.
|
| 348 |
+
|
| 349 |
+
Every other setting in `config.py` has a default and a comment
|
| 350 |
+
explaining what it does - see
|
| 351 |
+
[Configuration reference](#configuration-reference) for the complete
|
| 352 |
+
list. Edit the values directly. To enable the header's source repository button,
|
| 353 |
+
set `SOURCE_REPOSITORY_URL` there too.
|
| 354 |
+
|
| 355 |
+
5. **Run the app:**
|
| 356 |
+
|
| 357 |
+
```bash
|
| 358 |
+
python app.py
|
| 359 |
+
```
|
| 360 |
+
|
| 361 |
+
By default (`DEBUG = False` in `config.py`) this serves the app via
|
| 362 |
+
**waitress**, a production-grade pure-Python WSGI server - see
|
| 363 |
+
[Running in production](#running-in-production) below. Set
|
| 364 |
+
`DEBUG = True` in `config.py` to use Flask's development server
|
| 365 |
+
(auto-reload, interactive debugger) for local development.
|
| 366 |
+
|
| 367 |
+
6. Open **http://127.0.0.1:7860** in a browser.
|
| 368 |
+
|
| 369 |
+
The built frontend (`static/dist/`) is checked in; see
|
| 370 |
+
[Frontend build](#frontend-build) when changing `frontend/src/`.
|
| 371 |
+
|
| 372 |
+
---
|
| 373 |
+
|
| 374 |
+
## Getting the tablebase files
|
| 375 |
+
|
| 376 |
+
Where the tablebase *data* lives is a separate question from where this
|
| 377 |
+
*app* runs - Option A below points `TABLEBASE_PATH` at a plain
|
| 378 |
+
`http(s)://` URL, so it works identically regardless of which platform
|
| 379 |
+
serves the app itself (Render, Hugging Face Spaces, a local machine,
|
| 380 |
+
etc.); `remote/remote_source.py` speaks plain HTTP byte-range requests,
|
| 381 |
+
nothing more, and has no dependency on any one host. See
|
| 382 |
+
[Deploying to Render](#deploying-to-render) for the app side of that
|
| 383 |
+
distinction.
|
| 384 |
+
|
| 385 |
+
ChessTB tablebase files are available two ways:
|
| 386 |
+
|
| 387 |
+
### Option A: Remote, no local download (recommended)
|
| 388 |
+
|
| 389 |
+
Tables are available in the [ChessTB bucket](https://huggingface.co/buckets/chesstb/chesstb).
|
| 390 |
+
Shipping tables are grouped by metric; raw generator output is under `full/`.
|
| 391 |
+
Both use `castling/` for tables with castling rights.
|
| 392 |
+
WDL/DTZ cover 3–8 pieces; DTC/DTM/DTM50 cover 3–6 pieces.
|
| 393 |
+
|
| 394 |
+
Set `TABLEBASE_PATH` to the bucket root:
|
| 395 |
+
|
| 396 |
+
```python
|
| 397 |
+
TABLEBASE_PATH = "https://huggingface.co/buckets/chesstb/chesstb/resolve"
|
| 398 |
+
```
|
| 399 |
+
|
| 400 |
+
The explorer prefers raw generator output under `full/`, falls back to
|
| 401 |
+
shipping tables, and selects `castling/` paths automatically, including
|
| 402 |
+
for recursive dependencies. This applies to Standard chess and Chess960.
|
| 403 |
+
Local roots use the same layout. An explicit list of source directories
|
| 404 |
+
is also supported.
|
| 405 |
+
|
| 406 |
+
`REMOTE_MODE` selects the remote access mode, which stays fixed for the
|
| 407 |
+
life of the process:
|
| 408 |
+
|
| 409 |
+
- **`"direct"` (default)**: `remote/remote_direct.py` probes the remote
|
| 410 |
+
tables **in place**, fetching only the `REMOTE_PAGE_SIZE_BYTES`-sized
|
| 411 |
+
byte ranges each probe reads and keeping them in an in-memory
|
| 412 |
+
LRU bounded by `REMOTE_PAGE_CACHE_BYTES`. Nothing is written to disk.
|
| 413 |
+
This uses `chess.chesstb`'s table-source seam
|
| 414 |
+
(`Tablebase.WDL_FILE` / `_TableFile._open_source`), which the pinned
|
| 415 |
+
fork (see `requirements.txt`) provides.
|
| 416 |
+
- **`"download"`**: `remote/remote_fallback.py` fetches each table
|
| 417 |
+
**in full** the first time a probe touches its material and caches it
|
| 418 |
+
in a temporary local directory (bounded by
|
| 419 |
+
`REMOTE_PAGE_CACHE_BYTES` as an on-disk budget, evicting
|
| 420 |
+
least-recently-used files, removed entirely when the app stops). Every
|
| 421 |
+
probe after the first is then a local mmap read.
|
| 422 |
+
|
| 423 |
+
`"direct"` is the better default for browsing across many materials, which
|
| 424 |
+
is what this app does. `"download"` is preferable when a long session
|
| 425 |
+
repeatedly probes a small set of materials, or on a high-latency link: a
|
| 426 |
+
ChessTB probe is not a narrow read - a dropped-frame table is reconstructed by walking
|
| 427 |
+
its children, and pawn positions reach promotion sub-tables - so one cold
|
| 428 |
+
probe can open several materials and issue several fetches against each.
|
| 429 |
+
That is also why `REMOTE_PAGE_SIZE_BYTES` should stay large. See each
|
| 430 |
+
module's docstring for the full design.
|
| 431 |
+
|
| 432 |
+
Both remote modes cache successful signed CDN redirects until 60 seconds
|
| 433 |
+
before expiry, allowing subsequent byte-range reads to bypass the resolver.
|
| 434 |
+
A failed cached URL is retried through the original URL to obtain a fresh
|
| 435 |
+
redirect. Resolver and CDN connection pools stay open for reuse.
|
| 436 |
+
Complete cached responses are returned immediately by `/probe/stream`,
|
| 437 |
+
without rebuilding the board or queuing child probes. Queued child work is
|
| 438 |
+
cancelled when a stream disconnects or its pre-warm deadline expires;
|
| 439 |
+
already running probes can finish and warm the shared cache.
|
| 440 |
+
Table contents and paths are assumed to remain fixed; existence
|
| 441 |
+
and size metadata stay cached for the process lifetime, including missing
|
| 442 |
+
files. Restart the app after adding or moving tables.
|
| 443 |
+
|
| 444 |
+
Remote access suits a machine without a couple of terabytes of local disk
|
| 445 |
+
for the full tablebase set. Its costs are probe latency on first touch of
|
| 446 |
+
any given material (depending on the connection to the CDN), temporary
|
| 447 |
+
local disk usage in `"download"` mode, and the requirement of a network
|
| 448 |
+
connection. Remote access uses the `requests` package (see
|
| 449 |
+
`requirements.txt`).
|
| 450 |
+
|
| 451 |
+
### Option B: Local directory, over FTP
|
| 452 |
+
|
| 453 |
+
The same tables are also distributed over FTP for a fully local, offline
|
| 454 |
+
setup:
|
| 455 |
+
|
| 456 |
+
```
|
| 457 |
+
ftp://chessdb:chessdb@ftp.chessdb.cn/pub/chesstb/
|
| 458 |
+
```
|
| 459 |
+
|
| 460 |
+
Any FTP client works, for example:
|
| 461 |
+
|
| 462 |
+
```bash
|
| 463 |
+
# lftp
|
| 464 |
+
lftp -e "mirror --parallel=4 /pub/chesstb/ /data/chesstb; quit" \
|
| 465 |
+
ftp://chessdb:chessdb@ftp.chessdb.cn
|
| 466 |
+
|
| 467 |
+
# curl (single file)
|
| 468 |
+
curl "ftp://chessdb:chessdb@ftp.chessdb.cn/pub/chesstb/<path-to-file>" -o <local-file>
|
| 469 |
+
```
|
| 470 |
+
|
| 471 |
+
Tablebase sets are large and grow quickly with piece count - check
|
| 472 |
+
available disk space before mirroring the full archive, and consider
|
| 473 |
+
mirroring only the required subsets (piece counts). Point
|
| 474 |
+
`TABLEBASE_PATH` at the selected directory.
|
| 475 |
+
|
| 476 |
+
---
|
| 477 |
+
|
| 478 |
+
Regardless of how it is configured, if `TABLEBASE_PATH` is unset, missing/unreachable, or
|
| 479 |
+
points at a location with no usable tables, the app still starts (with a
|
| 480 |
+
warning in the logs) - every `/probe` request will then return an error,
|
| 481 |
+
and `/health` reports `"degraded"`.
|
| 482 |
+
|
| 483 |
+
---
|
| 484 |
+
|
| 485 |
+
## Configuration reference
|
| 486 |
+
|
| 487 |
+
All configuration lives in **`config.py`**, as plain Python values. Open
|
| 488 |
+
it and edit the settings directly - each one has a comment above it
|
| 489 |
+
explaining what it does. The table below is the complete reference.
|
| 490 |
+
|
| 491 |
+
The defaults target a single process on Hugging Face CPU Basic
|
| 492 |
+
(2 vCPU, 16 GB RAM). The decoded-block cache has a shared 512 MiB
|
| 493 |
+
budget, and each of the four default remote sources has a 2 GiB page
|
| 494 |
+
cache budget, totaling 8.5 GiB of byte-cache budgets. Caches fill on
|
| 495 |
+
demand; this is not a cap on process memory. Root-response and child-probe
|
| 496 |
+
caches are limited by entry count, with memory use depending on response
|
| 497 |
+
size. The remaining RAM accommodates those results, table/index metadata,
|
| 498 |
+
Python object overhead, and active requests. Revisit the budgets if you
|
| 499 |
+
add remote sources or run multiple application processes.
|
| 500 |
+
|
| 501 |
+
| Setting | Default | Description |
|
| 502 |
+
|-----------------------------|-------------------|-------------|
|
| 503 |
+
| `TABLEBASE_PATH` | Public bucket root (also readable from a `TABLEBASE_PATH` env var) | Directory containing the ChessTB tablebase files, **or** an `http(s)://` base URL serving the same layout remotely (e.g. a Hugging Face storage bucket - see [Getting the tablebase files](#getting-the-tablebase-files)). Required for probing to work. A list of directories and URLs, tried left-to-right (a hybrid source), is accepted as a literal in `config.py` only. On Render, set this from the service's Environment tab (or `render.yaml`'s prompt) instead of editing the file. |
|
| 504 |
+
| `DEBUG` | `False` | `True` runs `app.py` via Flask's own dev server (auto-reload, detailed tracebacks) instead of waitress. Leave `False` - which serves via waitress - for anything reachable from another machine. See [Running in production](#running-in-production). |
|
| 505 |
+
| `HOST` | `"0.0.0.0"` | Interface the server (waitress, or the dev server if `DEBUG = True`) binds to. |
|
| 506 |
+
| `PORT` | `7860` (also readable from a `PORT` env var) | Port the server listens on. Render injects its own `PORT` at deploy time and this picks it up automatically - nothing to set for a Render deployment. |
|
| 507 |
+
| `WAITRESS_THREADS` | `4` | Worker threads in waitress's request-handling pool. Only relevant when `DEBUG = False`. Needs to be more than 1 so a long-lived `/probe/stream` connection can't block other requests. |
|
| 508 |
+
| `PROBE_THREADS` | `2` | Worker threads in the probe thread pool for evaluating a position's legal moves in parallel. Set an explicit integer to match the host's CPU count, or `None` to scale automatically (`min(16, cpu*2)`). |
|
| 509 |
+
| `PROBE_PARALLEL_THRESHOLD` | `4` | Minimum number of child positions before probing switches from sequential to the thread pool. |
|
| 510 |
+
| `PROBE_TIMEOUT_SECS` | `30` | Wall-clock timeout for each probe phase. `/probe` gives the root probe and the child-move batch independent budgets, so worst-case `/probe` latency can approach `2 × PROBE_TIMEOUT_SECS`. Also bounds how long a request waits on another thread's in-flight probe of the same FEN before giving up with a retryable `probe_timeout` error. |
|
| 511 |
+
| `EVALUATE_CACHE_SIZE` | `16384` | Max entries in the root-FEN result cache (full JSON responses). |
|
| 512 |
+
| `PROBE_CACHE_SIZE` | `262144` | Max entries in the child-position probe cache (raw `chess.chesstb.ProbeResult` objects - WDL/DTZ/DTC/DTM/DTM50 together). |
|
| 513 |
+
| `BLOCK_CACHE_BYTES` | `536870912` (512 MiB) | Size, in bytes, of `chesstb`'s own internal cache of decoded/decompressed tablebase blocks (shared across the WDL/DTZ/DTC/DTM/DTM50 tables). Raising this trades RAM for fewer repeated disk reads/HTTP fetches + decompressions across a session - most worthwhile when `PROBE_PARALLEL_THRESHOLD` is set high enough that probing runs mostly serially. |
|
| 514 |
+
| `REMOTE_MODE` | `"direct"` | **Remote tablebase entries only.** `"direct"` probes the remote tables in place over byte ranges (nothing written to disk); `"download"` fetches each table in full on first touch and caches it on local disk. See [Getting the tablebase files](#getting-the-tablebase-files). |
|
| 515 |
+
| `REMOTE_PAGE_CACHE_BYTES` | `2147483648` (2 GiB) | **Remote tablebase entries only.** Soft budget, in bytes, per remote source in `TABLEBASE_PATH`, shared across that source's tables: the in-memory page cache in `"direct"` mode, the on-disk cache of whole downloaded files in `"download"` mode. See [Getting the tablebase files](#getting-the-tablebase-files). |
|
| 516 |
+
| `REMOTE_PAGE_SIZE_BYTES` | `262144` (256 KiB) | **Remote tablebase entries only.** Size, in bytes, of one page. In `"direct"` mode this is the granularity of every fetch, so it trades over-fetching against round trips per probe; in `"download"` mode it is only the chunk size used while streaming a full file down. |
|
| 517 |
+
| `REMOTE_TIMEOUT_SECS` | `20` | **Remote tablebase entries only.** Per-HTTP-request timeout for existence/size checks and the download itself. |
|
| 518 |
+
| `REMOTE_MAX_RETRIES` | `3` | **Remote tablebase entries only.** Attempts for a single remote request before it's treated as failed. |
|
| 519 |
+
| `REMOTE_POOL_MAXSIZE` | `None` | **Remote tablebase entries only.** Size of each remote backend's HTTP connection pool. `None` computes `max(PROBE_THREADS * 2, 20)`. |
|
| 520 |
+
| `PROBE_RATE_LIMIT` | `"60 per minute"` | Per-client-IP request limit on `/probe` and `/probe/stream`, using [flask-limiter](https://flask-limiter.readthedocs.io/)'s string syntax. Left empty, rate limiting is disabled entirely. See [Security notes](#security-notes). |
|
| 521 |
+
| `ADMIN_LOGIN_RATE_LIMIT` | `"5 per minute"` | Per-client-IP request limit on `/admin/login`, same string syntax as `PROBE_RATE_LIMIT`, applied independently of it. Covers both failed and successful login attempts. Left empty, login attempts are not rate limited. See [Security notes](#security-notes). |
|
| 522 |
+
| `PREFETCH_RATE_LIMIT` | `"30 per minute"` | Per-client-IP request limit for hover prefetch requests marked with `X-Prefetch: 1`. Left empty, prefetch requests share `PROBE_RATE_LIMIT`. See [Security notes](#security-notes). |
|
| 523 |
+
| `TRUSTED_PROXY_COUNT` | `1` (also readable from a `TRUSTED_PROXY_COUNT` env var) | Number of trusted reverse-proxy hops in front of this app. In production this configures [waitress's own `trusted_proxy_count`](https://docs.pylonsproject.org/projects/waitress/en/stable/proxy-headers.html) (waitress parses `X-Forwarded-For`/`X-Forwarded-Proto` and corrects `REMOTE_ADDR`/`wsgi.url_scheme` itself, ahead of Flask); [Werkzeug's `ProxyFix`](https://werkzeug.palletsprojects.com/en/latest/middleware/proxy_fix/) makes the same correction for the `DEBUG = True` dev-server path, which doesn't run under waitress. Both rate limiters key on the corrected address, and `request.scheme`/`request.is_secure` read the corrected scheme. `1` matches this project's own `render.yaml` topology (Render's edge/load-balancer, once). Set it to match the deployment's reverse-proxy chain, or set it to `0` for a deployment with no reverse proxy in front of it at all. See [Security notes](#security-notes). |
|
| 524 |
+
| `ADMIN_TOKEN` | `""` (from `ADMIN_TOKEN` env var) | Shared secret required to reach `/admin` and `/admin/cache/*`. Read from the environment rather than hardcoded, so it can be set as a platform secret. Left unset, admin routes respond `503` rather than running unprotected. See [Security notes](#security-notes). |
|
| 525 |
+
| `FLASK_SECRET_KEY` | `""` (from `FLASK_SECRET_KEY` env var) | Key used to sign the admin session cookie. Read from the environment for the same reason as `ADMIN_TOKEN`. Left unset, a random key is generated at process startup instead, so every restart requires logging in again. |
|
| 526 |
+
| `SESSION_COOKIE_SECURE` | `False` (also readable from a `SESSION_COOKIE_SECURE` env var, `"true"`/anything else) | Whether the admin session cookie set by `/admin/login` requires HTTPS. Independent of `DEBUG`. Defaults `False` to match a plain-HTTP local installation; set to `"true"` for any deployment the browser reaches over HTTPS (including behind a TLS-terminating reverse proxy such as Render's - `render.yaml` already sets this explicitly for that deployment). See [Security notes](#security-notes). |
|
| 527 |
+
| `SOURCE_REPOSITORY_URL` | `""` (also readable from a `SOURCE_REPOSITORY_URL` env var) | Repository URL for the source repository button shown in the header of every page. Edited directly like any other non-secret value here, or set as an env var on a given deployment. Left empty, the button is omitted. |
|
| 528 |
+
|
| 529 |
+
Invalid values (wrong type, out of range) cause the app to log an error
|
| 530 |
+
and exit at startup rather than run with a silently-wrong configuration.
|
| 531 |
+
|
| 532 |
+
---
|
| 533 |
+
|
| 534 |
+
## Running in production
|
| 535 |
+
|
| 536 |
+
`python app.py` serves the app via **[waitress](https://docs.pylonsproject.org/projects/waitress/)**,
|
| 537 |
+
a production-grade, pure-Python WSGI server, whenever `DEBUG` in
|
| 538 |
+
`config.py` is `False` (the default) - no separate `gunicorn`/`waitress`
|
| 539 |
+
command or extra process is needed on top of the app itself. Setting
|
| 540 |
+
`DEBUG = True` in `config.py` switches `app.py` over to Flask's own dev
|
| 541 |
+
server instead (auto-reload + interactive debugger), which is meant for
|
| 542 |
+
local development only and should not be used for anything reachable
|
| 543 |
+
from another machine.
|
| 544 |
+
|
| 545 |
+
```bash
|
| 546 |
+
# Production (default): DEBUG = False in config.py: serves via waitress
|
| 547 |
+
python app.py
|
| 548 |
+
|
| 549 |
+
# Local development: set DEBUG = True in config.py first, then run the
|
| 550 |
+
# same command: Flask dev server, auto-reload + debugger
|
| 551 |
+
python app.py
|
| 552 |
+
```
|
| 553 |
+
|
| 554 |
+
- `HOST` / `PORT` control the interface and port waitress binds to, same
|
| 555 |
+
as for the dev server. `PORT` is also readable from a `PORT`
|
| 556 |
+
environment variable (see [Configuration reference](#configuration-reference)),
|
| 557 |
+
which is what lets the same image bind correctly on Render - or any
|
| 558 |
+
other platform that assigns the port at deploy time - with no edits.
|
| 559 |
+
- `WAITRESS_THREADS` (default `4`) sizes waitress's own pool of
|
| 560 |
+
request-handling threads. This needs to be more than 1 for the same
|
| 561 |
+
reason the dev server needs `threaded=True`: `/probe/stream` holds a
|
| 562 |
+
connection open via Server-Sent Events for the whole duration of a
|
| 563 |
+
probe, and a single-threaded server would let that one connection block
|
| 564 |
+
every other request - including the browser's own concurrent requests
|
| 565 |
+
for CSS/JS/piece images on first page load. It's independent of
|
| 566 |
+
`PROBE_THREADS`, which sizes the thread pool used internally to
|
| 567 |
+
parallelise tablebase probing rather than to serve HTTP requests.
|
| 568 |
+
- `TRUSTED_PROXY_COUNT` (default `1`) configures waitress's own
|
| 569 |
+
`trusted_proxy_count`, so `request.remote_addr` and `request.scheme`
|
| 570 |
+
reflect the real client IP and scheme behind Render's edge/load-balancer
|
| 571 |
+
rather than that proxy's own address/hop - see
|
| 572 |
+
[Security notes](#security-notes).
|
| 573 |
+
- Waitress handles `/probe/stream`'s streamed response natively; no
|
| 574 |
+
additional configuration is needed for SSE to work correctly.
|
| 575 |
+
- The probe endpoints are unauthenticated by design; only `/admin` and
|
| 576 |
+
`/admin/cache/*` require a credential (see
|
| 577 |
+
[Security notes](#security-notes)).
|
| 578 |
+
|
| 579 |
+
---
|
| 580 |
+
|
| 581 |
+
## Deploying to Render
|
| 582 |
+
|
| 583 |
+
This project deploys to [Render](https://render.com) as a single Docker
|
| 584 |
+
web service, built straight from this GitHub repository. Render watches
|
| 585 |
+
the connected branch and rebuilds/redeploys automatically on every push.
|
| 586 |
+
|
| 587 |
+
There are two ways to set it up:
|
| 588 |
+
|
| 589 |
+
### Option 1: Blueprint (`render.yaml`, recommended)
|
| 590 |
+
|
| 591 |
+
This repository ships a [`render.yaml`](render.yaml) at its root
|
| 592 |
+
describing the service (Docker runtime, health check, environment
|
| 593 |
+
variables). Render finds it automatically:
|
| 594 |
+
|
| 595 |
+
1. Push this repository to GitHub.
|
| 596 |
+
2. In the [Render Dashboard](https://dashboard.render.com), click
|
| 597 |
+
**New → Blueprint** and connect the repo.
|
| 598 |
+
3. Render reads `render.yaml` and requests the one setting required
|
| 599 |
+
before the first deploy can run: **`TABLEBASE_PATH`** - the
|
| 600 |
+
`http(s)://` base URL of the remote tablebase (see
|
| 601 |
+
[Getting the tablebase files](#getting-the-tablebase-files)).
|
| 602 |
+
`ADMIN_TOKEN` and `FLASK_SECRET_KEY` are generated automatically.
|
| 603 |
+
4. Click **Apply** / **Create**. Render builds the image from this
|
| 604 |
+
repo's `Dockerfile` and deploys it.
|
| 605 |
+
|
| 606 |
+
Every subsequent push to the connected branch redeploys automatically.
|
| 607 |
+
To change `TABLEBASE_PATH`, `ADMIN_TOKEN`, or any other setting later,
|
| 608 |
+
edit it from the service's **Environment** tab in the Render Dashboard;
|
| 609 |
+
see [Configuration reference](#configuration-reference) for which
|
| 610 |
+
settings are env-overridable this way versus which live only in
|
| 611 |
+
`config.py`.
|
| 612 |
+
|
| 613 |
+
### Option 2: Manual Web Service (no `render.yaml`)
|
| 614 |
+
|
| 615 |
+
The service can also be configured manually without the Blueprint file:
|
| 616 |
+
|
| 617 |
+
1. Push this repository to GitHub.
|
| 618 |
+
2. In the Render Dashboard, click **New → Web Service** and connect the
|
| 619 |
+
repo.
|
| 620 |
+
3. Set **Runtime** to **Docker** (Render should detect the `Dockerfile`
|
| 621 |
+
at the repo root automatically).
|
| 622 |
+
4. Under **Environment**, add:
|
| 623 |
+
- `TABLEBASE_PATH`: required; same as Option 1 above.
|
| 624 |
+
- `ADMIN_TOKEN`: optional but recommended; any secret string, or
|
| 625 |
+
use Render's "Generate" button. While it is unset, `/admin`
|
| 626 |
+
responds `503`.
|
| 627 |
+
- `FLASK_SECRET_KEY`: optional; when omitted, a random key is
|
| 628 |
+
generated at process startup and admin sessions don't survive a
|
| 629 |
+
restart.
|
| 630 |
+
- `SESSION_COOKIE_SECURE`: set to `true`. Render always terminates
|
| 631 |
+
TLS at its edge, so the browser reaches this deployment over HTTPS
|
| 632 |
+
even though the app itself just speaks plain HTTP to Render's
|
| 633 |
+
proxy; `config.py`'s own default (`false`) is tuned for a local
|
| 634 |
+
plain-HTTP installation instead. (Option 1's `render.yaml` sets
|
| 635 |
+
this automatically - it is a manual step here because Option 2
|
| 636 |
+
skips that file.)
|
| 637 |
+
- `SOURCE_REPOSITORY_URL`: optional; the repository URL, which enables the
|
| 638 |
+
header's source repository button.
|
| 639 |
+
- Leave `PORT` alone - Render sets it itself, and `config.py` already
|
| 640 |
+
reads it (see [Configuration reference](#configuration-reference)).
|
| 641 |
+
5. Under **Health Check Path**, set `/health` (matches the route in
|
| 642 |
+
`app.py`; Render won't mark a deploy healthy until this returns `200`,
|
| 643 |
+
which itself only happens once the sources in `TABLEBASE_PATH` have
|
| 644 |
+
opened successfully).
|
| 645 |
+
6. Click **Create Web Service**.
|
| 646 |
+
|
| 647 |
+
### Choosing a plan
|
| 648 |
+
|
| 649 |
+
Probing is CPU-bound (block decompression on every request), so a
|
| 650 |
+
dedicated vCPU - Render's **Starter** tier or above - gives lower probe
|
| 651 |
+
latency than the **Free** tier's shared/limited CPU. Free works
|
| 652 |
+
for casual or low-traffic use; the service spins down after inactivity
|
| 653 |
+
(a slow first request after waking up) and probes are slower under load. Either way, `REMOTE_MODE = "direct"` (the default)
|
| 654 |
+
means no persistent disk is needed regardless of plan - see
|
| 655 |
+
`config.py`'s "Remote (URL) tablebase entries only" section and
|
| 656 |
+
[Getting the tablebase files](#getting-the-tablebase-files).
|
| 657 |
+
|
| 658 |
+
---
|
| 659 |
+
|
| 660 |
+
## Usage guide
|
| 661 |
+
|
| 662 |
+
- **Set a position**: type or paste a FEN into the FEN box and press
|
| 663 |
+
`Enter` or **Apply**. The move rankings and best-move arrows update
|
| 664 |
+
automatically. The castling-availability field accepts `-` or any
|
| 665 |
+
combination of `K`, `Q`, `k`, `q` (each requiring a matching king and
|
| 666 |
+
rook on their home squares); in **Chess960 Mode** it additionally
|
| 667 |
+
accepts X-FEN/Shredder-FEN rook-file letters (`a`-`h` / `A`-`H`). A
|
| 668 |
+
position with a castling right is evaluated the same as any other,
|
| 669 |
+
reported as not covered if the loaded tablebase set has no table for it
|
| 670 |
+
(see `TABLEBASE_PATH` in
|
| 671 |
+
[Configuration reference](#configuration-reference)).
|
| 672 |
+
- **Chess960 (Fischer Random)**: toggle **Chess960 Mode** in the settings
|
| 673 |
+
panel to switch castling interpretation. This does not move any pieces
|
| 674 |
+
or randomize a new starting position - it only changes how castling
|
| 675 |
+
moves/rights on the *current* board are interpreted, and drops the
|
| 676 |
+
current move line the same way a board edit does (a line built under
|
| 677 |
+
one variant isn't reinterpreted as the other). With it on, drag a king
|
| 678 |
+
onto its own rook to castle; the destination square and resulting rook
|
| 679 |
+
position follow standard Chess960 rules regardless of where the king
|
| 680 |
+
and rook started. The setting is carried in the shared URL
|
| 681 |
+
(`&variant=chess960`), the saved in-progress game, and PGN
|
| 682 |
+
import/export (a `[Variant "Chess960"]` header).
|
| 683 |
+
- **Edit the board directly**: drag pieces around the board, drag a piece
|
| 684 |
+
off the board to remove it, or click a spare piece in either tray and
|
| 685 |
+
then click a square to place it (click again / press `Esc` to cancel).
|
| 686 |
+
Board edits reset the current move line the same way **Clear** does.
|
| 687 |
+
- **Lock the board**: click the padlock button beside the FEN box to
|
| 688 |
+
restrict drag-and-drop, click-to-move, and the spare-piece trays to
|
| 689 |
+
legal moves for the side to move; the button turns gold while active.
|
| 690 |
+
Click again to unlock. The choice is remembered locally across visits.
|
| 691 |
+
Starting auto-play locks the board automatically for the run (unless
|
| 692 |
+
already locked) and disables the padlock button until it stops.
|
| 693 |
+
- **Play moves**: click any move in one of the four ranked tables to
|
| 694 |
+
play it, or drag a piece on the board through a legal move.
|
| 695 |
+
- **Navigate history**: the Undo/Redo arrow buttons or `←`/`→`, or click
|
| 696 |
+
any move in the PGN panel to jump straight to that point in the line.
|
| 697 |
+
- **Auto-play**: click the ▶ button above the DTZ, DTC, DTM, or DTM50
|
| 698 |
+
columns
|
| 699 |
+
to have the app play that metric's best move on a timer (delay set by
|
| 700 |
+
**Autoplay Delay** in settings, 1.25s by default); click again (the
|
| 701 |
+
button shows ■ while running) to stop. Any manual navigation stops auto-play.
|
| 702 |
+
- **Import/export a game**: **Import** in the PGN panel opens a dialog to
|
| 703 |
+
paste PGN text. **Import Game** loads the full game and its variations
|
| 704 |
+
into the PGN panel, starting before the first move; **Export**
|
| 705 |
+
downloads the full game and its variations as `chesstb_game.pgn` with
|
| 706 |
+
standard headers.
|
| 707 |
+
- **Export the move table**: the **CSV** button downloads the current
|
| 708 |
+
DTZ/DTC/DTM/DTM50 rankings as a CSV file.
|
| 709 |
+
- **Share a position**: the URL updates live with `#fen=...`; sending
|
| 710 |
+
that link reproduces the exact position.
|
| 711 |
+
- **Change settings**: the ⚙ button in the header opens a **Show Root
|
| 712 |
+
Row** switch for the results table, a **Chess960 Mode** switch, and an
|
| 713 |
+
**Autoplay Delay** slider; the choices are persisted across visits.
|
| 714 |
+
- **Admin dashboard**: the gauge icon in the header opens `/admin`, prompting for the
|
| 715 |
+
`ADMIN_TOKEN` on first visit, then showing live hit-rate stats for both
|
| 716 |
+
caches and the thread-pool configuration, auto-refreshing every 5
|
| 717 |
+
seconds.
|
| 718 |
+
|
| 719 |
+
---
|
| 720 |
+
|
| 721 |
+
## API
|
| 722 |
+
|
| 723 |
+
The full HTTP API - `/probe`, `/probe/stream`, `/health`,
|
| 724 |
+
`/admin/cache/stats`, `/admin/cache/clear`, `/admin/login`,
|
| 725 |
+
`/admin/logout` - is documented as an OpenAPI 3.0 specification served
|
| 726 |
+
live by the running app at:
|
| 727 |
+
|
| 728 |
+
```
|
| 729 |
+
http://127.0.0.1:7860/openapi.yaml
|
| 730 |
+
```
|
| 731 |
+
|
| 732 |
+
`openapi.yaml` documents every endpoint's request/response shapes; the
|
| 733 |
+
ranking algorithm behind `moves_dtz` / `moves_dtc` / `moves_dtm` /
|
| 734 |
+
`moves_dtm50` is documented inline in `app.py`'s own module docstring and
|
| 735 |
+
its `evaluate_all_moves()` function. `/admin/cache/*` require an
|
| 736 |
+
`Authorization: Bearer <ADMIN_TOKEN>` header or an authenticated session
|
| 737 |
+
cookie from `/admin/login` - see [Security notes](#security-notes).
|
| 738 |
+
|
| 739 |
+
Quick example:
|
| 740 |
+
|
| 741 |
+
```bash
|
| 742 |
+
curl -X POST http://127.0.0.1:7860/probe \
|
| 743 |
+
-H "Content-Type: application/json" \
|
| 744 |
+
-d '{"fen": "4k3/8/8/8/8/8/8/4K2R w - - 0 1"}'
|
| 745 |
+
```
|
| 746 |
+
|
| 747 |
+
`variant` is optional and defaults to `"standard"` when omitted. It is
|
| 748 |
+
never inferred from the FEN itself - the orthodox starting array is
|
| 749 |
+
itself a legal Chess960 starting position, so the same FEN text is
|
| 750 |
+
ambiguous without it:
|
| 751 |
+
|
| 752 |
+
```bash
|
| 753 |
+
curl -X POST http://127.0.0.1:7860/probe \
|
| 754 |
+
-H "Content-Type: application/json" \
|
| 755 |
+
-d '{"fen": "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1", "variant": "chess960"}'
|
| 756 |
+
```
|
| 757 |
+
|
| 758 |
+
---
|
| 759 |
+
|
| 760 |
+
## Project structure
|
| 761 |
+
|
| 762 |
+
```
|
| 763 |
+
.
|
| 764 |
+
├── LICENSE # GPL-3.0-or-later license text for this project
|
| 765 |
+
├── THIRD_PARTY_LICENSES.md # Licenses and attributions for bundled third-party code, fonts, and the piece set
|
| 766 |
+
├── Dockerfile # Container build used for Render (and any other Docker host)
|
| 767 |
+
├── render.yaml # Render Blueprint: service definition, health check, env vars
|
| 768 |
+
├── app.py # Flask backend: routes, probing, caching
|
| 769 |
+
├── config.py # All configuration, as plain Python values - see "Configuration reference"
|
| 770 |
+
├── openapi.yaml # OpenAPI 3.0 specification, served at /openapi.yaml
|
| 771 |
+
├── requirements.txt
|
| 772 |
+
├── README.md
|
| 773 |
+
├── .gitattributes, .gitignore, .dockerignore
|
| 774 |
+
├── tablebase_router.py # Ordered local/remote source lookup for every material
|
| 775 |
+
├── tablebase_opening.py # Per-material table-opening lock policy, shared by tablebase_router.py and both remote backends
|
| 776 |
+
├── dtz_anchor.py # Resolves rounded DTZ values by cross-checking the moves of a position
|
| 777 |
+
├── remote/
|
| 778 |
+
│ ├── remote_source.py # Generic HTTP byte-range client shared by both remote backends - see its own module docstring
|
| 779 |
+
│ ├── remote_direct.py # REMOTE_MODE="direct": probe remote tables in place over byte ranges - see its own module docstring
|
| 780 |
+
│ └── remote_fallback.py # REMOTE_MODE="download": whole-file download, cached to local disk on first touch - see its own module docstring
|
| 781 |
+
├── templates/
|
| 782 |
+
│ ├── index.html # Main explorer UI - mounts the React app (see frontend/)
|
| 783 |
+
│ └── admin.html # Cache dashboard
|
| 784 |
+
├── frontend/ # React app for the explorer page - see "Frontend build"
|
| 785 |
+
│ ├── package.json, package-lock.json
|
| 786 |
+
│ ├── vite.config.js # Builds into ../static/dist with fixed filenames (no cache-busting hash)
|
| 787 |
+
│ ├── index.html, admin.html # Build entries (script and stylesheet)
|
| 788 |
+
│ └── src/
|
| 789 |
+
│ ├── main.jsx, App.jsx
|
| 790 |
+
│ ├── engine/ # Position/rules engine and tablebase client (chessops + Chessground)
|
| 791 |
+
│ ├── vendor/ # Vendored Chessground (script and base stylesheet) + chessops, bundled by the build
|
| 792 |
+
│ ├── hooks/useExplorer.js # FEN box, PGN, auto-play, settings, keyboard shortcuts
|
| 793 |
+
│ ├── components/ # Board, results table, PGN panel, dialogs, settings panel
|
| 794 |
+
│ │ └── ui/ # Icon, Button, Segmented, Panel
|
| 795 |
+
│ ├── styles/ # Tokens, base, layout, component and chess stylesheets - see "Styling"
|
| 796 |
+
│ └── assets/fonts/ # Inter and JetBrains Mono (woff2)
|
| 797 |
+
└── static/
|
| 798 |
+
├── dist/ # Built frontend (see "Frontend build"): app.js, app.css, admin.css, fonts/
|
| 799 |
+
├── css/
|
| 800 |
+
│ └── pieces-cburnett.css # Piece-set stylesheet linked by templates/index.html
|
| 801 |
+
├── js/
|
| 802 |
+
│ └── admin.js # Admin dashboard client
|
| 803 |
+
└── pieces/cburnett/ # Piece-set SVGs
|
| 804 |
+
```
|
| 805 |
+
|
| 806 |
+
---
|
| 807 |
+
|
| 808 |
+
## Security notes
|
| 809 |
+
|
| 810 |
+
- The `/probe` and `/probe/stream` endpoints are intentionally
|
| 811 |
+
unauthenticated - evaluating a chess position is this app's public,
|
| 812 |
+
core functionality. `/admin` and `/admin/cache/*` are gated behind
|
| 813 |
+
`ADMIN_TOKEN` (see below); every other route requires no credential.
|
| 814 |
+
- **Rate limiting** on `/probe` and `/probe/stream` is controlled by
|
| 815 |
+
`PROBE_RATE_LIMIT`, and on `/admin/login` separately by
|
| 816 |
+
`ADMIN_LOGIN_RATE_LIMIT` (both in `config.py`'s "Rate limiting"
|
| 817 |
+
section), each applied per client IP via
|
| 818 |
+
[flask-limiter](https://flask-limiter.readthedocs.io/). A client over
|
| 819 |
+
either limit gets a `429` with a JSON error body. `PROBE_RATE_LIMIT`'s
|
| 820 |
+
default, `"60 per minute"`, covers normal browsing and
|
| 821 |
+
autoplay at its default delay - raise it if legitimate autoplay at a
|
| 822 |
+
fast delay setting gets throttled, lower it if the deployment sees
|
| 823 |
+
abuse, or set either limit to `""` to disable it entirely.
|
| 824 |
+
`ADMIN_LOGIN_RATE_LIMIT` defaults to `"5 per minute"`, tight enough to
|
| 825 |
+
slow down repeated `ADMIN_TOKEN` guesses. Both limiters key on
|
| 826 |
+
`request.remote_addr` as corrected by `TRUSTED_PROXY_COUNT` (see
|
| 827 |
+
below) and their state lives in-process (`memory://`), which is
|
| 828 |
+
sufficient for a single-container deployment (e.g. one Render Web
|
| 829 |
+
Service instance) but isn't shared across multiple replicas.
|
| 830 |
+
- **`TRUSTED_PROXY_COUNT`** (`config.py`'s "Rate limiting" section) is
|
| 831 |
+
what `request.remote_addr`, and therefore both rate limiters above,
|
| 832 |
+
is corrected against, so it reads the real client IP from
|
| 833 |
+
`X-Forwarded-For` rather than the address of the reverse proxy
|
| 834 |
+
in front of this app. The same setting also governs whether
|
| 835 |
+
`X-Forwarded-Proto` is trusted, correcting `request.scheme` /
|
| 836 |
+
`request.is_secure` (and any externally-generated URL) when TLS
|
| 837 |
+
terminates at that reverse proxy rather than at this app itself. In
|
| 838 |
+
production (`DEBUG = False`) this configures [waitress's own `trusted_proxy_count`](https://docs.pylonsproject.org/projects/waitress/en/stable/proxy-headers.html):
|
| 839 |
+
waitress parses both headers and corrects `REMOTE_ADDR`/`wsgi.url_scheme`
|
| 840 |
+
itself, before Flask ever sees the request, so it takes priority here.
|
| 841 |
+
[Werkzeug's `ProxyFix`](https://werkzeug.palletsprojects.com/en/latest/middleware/proxy_fix/)
|
| 842 |
+
makes the equivalent correction for the `DEBUG = True` Flask-dev-server
|
| 843 |
+
path, which doesn't run under waitress at all. Defaults to `1`,
|
| 844 |
+
matching this project's own `render.yaml` topology; set it to match
|
| 845 |
+
the deployment if that differs, or to `0` for a deployment with no
|
| 846 |
+
reverse proxy in front of it at all.
|
| 847 |
+
- **Admin access** is controlled by `ADMIN_TOKEN` (`config.py`'s "Admin"
|
| 848 |
+
section), read from the environment rather than hardcoded - set it as
|
| 849 |
+
a platform secret (e.g. a Render environment variable, generated
|
| 850 |
+
automatically by `render.yaml`'s `generateValue: true`) rather than
|
| 851 |
+
committing it. Visiting `/admin` with no active session shows a login
|
| 852 |
+
form; a correct token there starts a signed, `HttpOnly`,
|
| 853 |
+
`SameSite=Lax` session cookie (`Secure` too, where configured - see
|
| 854 |
+
`SESSION_COOKIE_SECURE` below). API clients can instead send
|
| 855 |
+
`Authorization: Bearer <ADMIN_TOKEN>` directly, no session needed.
|
| 856 |
+
Token comparison uses `hmac.compare_digest` (constant-time, resistant
|
| 857 |
+
to timing attacks). Leaving `ADMIN_TOKEN` unset does **not** open the
|
| 858 |
+
admin panel to everyone - every admin route responds `503` instead.
|
| 859 |
+
- **`SESSION_COOKIE_SECURE`** (`config.py`'s "Admin" section) controls
|
| 860 |
+
whether the admin session cookie above requires HTTPS, independently
|
| 861 |
+
of `DEBUG` - `DEBUG` is a development/runtime behavior switch, not a
|
| 862 |
+
reliable indicator of the deployment's transport security, so
|
| 863 |
+
`DEBUG = False` alone does not imply HTTPS is in front of this
|
| 864 |
+
app. Read from a `SESSION_COOKIE_SECURE` env var (`"true"` or anything
|
| 865 |
+
else, case-insensitive), defaulting `False` to match this project's own
|
| 866 |
+
plain-HTTP local installation - a browser won't send a
|
| 867 |
+
`Secure` cookie back over a plain HTTP connection, so `/admin/login`
|
| 868 |
+
would otherwise appear to succeed while every subsequent authenticated
|
| 869 |
+
`/admin/*` request came back `401`. Set to `"true"` for any deployment
|
| 870 |
+
the browser reaches over HTTPS (including behind a TLS-terminating
|
| 871 |
+
reverse proxy - the browser's own connection is what the `Secure`
|
| 872 |
+
attribute governs, not the internal hop between the proxy and this
|
| 873 |
+
process); `render.yaml` already sets this explicitly for the Render
|
| 874 |
+
deployment it configures, since Render always terminates TLS at its
|
| 875 |
+
edge.
|
| 876 |
+
- The app serves via **waitress**, a production-grade WSGI server, by
|
| 877 |
+
default (`DEBUG = False` in `config.py`) - see [Running in production](#running-in-production).
|
| 878 |
+
Only set `DEBUG = True` (which switches to Flask's own dev server) for
|
| 879 |
+
local development, never for anything reachable from another machine.
|
| 880 |
+
- A `Content-Security-Policy` is set on every response: scripts and fonts
|
| 881 |
+
are restricted to `'self'` (no inline scripts, no third-party sources);
|
| 882 |
+
styles allow `'self'` plus `'unsafe-inline'` (needed for inline
|
| 883 |
+
`style` attributes set by the React app and in `admin.html`); images
|
| 884 |
+
allow `'self'` plus `data:`; `connect-src`/`default-src` are `'self'`
|
| 885 |
+
too, so `fetch`/SSE calls can only reach this same origin. `object-src`,
|
| 886 |
+
`base-uri`, and `form-action` are locked to `'none'`/`'self'`/`'self'`,
|
| 887 |
+
and `frame-ancestors 'self' https://huggingface.co` allows the Hugging
|
| 888 |
+
Face Space page to embed the app. `X-Content-Type-Options:
|
| 889 |
+
nosniff` and `Referrer-Policy: strict-origin-when-cross-origin` are set
|
| 890 |
+
too. Request bodies are capped at 4 KB.
|
| 891 |
+
- `config.py` itself holds no secrets - `ADMIN_TOKEN` and
|
| 892 |
+
`FLASK_SECRET_KEY` are the only two secrets, and they are read only from
|
| 893 |
+
the environment so the file stays safe to commit as-is. Every other
|
| 894 |
+
setting is a non-sensitive value.
|
| 895 |
+
|
| 896 |
+
---
|
| 897 |
+
|
| 898 |
+
## Troubleshooting
|
| 899 |
+
|
| 900 |
+
| Symptom | Likely cause |
|
| 901 |
+
|---|---|
|
| 902 |
+
| Every probe returns "Position not covered by the loaded tablebase." | Check `TABLEBASE_PATH` and the startup logs. Set the tablebase root; `full/` and `castling/` directories are resolved automatically. For an explicit source list, include every required directory. |
|
| 903 |
+
| A FEN is rejected with "Invalid castling availability" | The submitted castling-availability field is something other than `-` or a combination of `K`, `Q`, `k`, `q` (or, in Chess960 Mode, rook-file letters `a`-`h`/`A`-`H`), or names a castling right without a matching king and rook on the board. |
|
| 904 |
+
| A FEN with `a`-`h`/`A`-`H` castling-rights letters is rejected even though the position looks fine | Those letters are only accepted with **Chess960 Mode** on (settings panel, or `variant: "chess960"` on the API) - Standard mode only accepts `K`, `Q`, `k`, `q`, matching Standard chess's fixed castling-rook squares. |
|
| 905 |
+
| Dragging a king onto its own rook doesn't castle | **Chess960 Mode** is off - enable it in the settings panel (or send `variant: "chess960"` on the API) for Chess960-style king-onto-rook castling; in Standard mode a king can only reach its rook's square by regular king movement. |
|
| 906 |
+
| `/health` returns `503 degraded` | The tablebase failed to open - check `TABLEBASE_PATH` and file permissions. |
|
| 907 |
+
| `ModuleNotFoundError: No module named 'chess.chesstb'` | Plain PyPI `chess` was installed instead of the fork. Re-run `pip install -r requirements.txt` and confirm it pulled `chess` from `noobpwnftw/python-chess` (the `add-chesstb-tablebases` branch) rather than plain PyPI `chess` - see the note at the top of `requirements.txt`. |
|
| 908 |
+
| `ModuleNotFoundError: No module named 'config'` | `config.py` is missing from alongside `app.py`. It ships with the repository - if it was deleted or moved, restore it from the repo (or re-clone) and re-apply any edits, such as `TABLEBASE_PATH`. |
|
| 909 |
+
| App logs `Configuration error: ...` and exits immediately | A value in `config.py` is the wrong type or out of range for that setting (e.g. a string where an integer is expected, or a negative cache size) - the error message names which setting and why. Fix it in `config.py` and re-run `python app.py`. |
|
| 910 |
+
| Admin dashboard panels stuck on "Loading…" | Check the browser console for a CSP violation and verify that the app is running and reachable at the `/admin` URL. |
|
| 911 |
+
| A drag, drop, or spare-piece placement on the board is silently rejected | The board is Locked - the padlock button beside the FEN box is highlighted gold. Lock restricts board interaction to legal moves and disables the spare-piece trays and off-board drops; click the padlock again to unlock and edit freely. |
|
| 912 |
+
| Probing feels slow on positions with many legal moves, or doesn't scale with `PROBE_THREADS` | Confirm `lz4` is installed (`pip show lz4`, or `python -c "import lz4"`) - without it, `chess.chesstb`'s block decompression runs in pure Python and holds the GIL, so `PROBE_THREADS` can't achieve real parallelism. `pip install lz4` (already in `requirements.txt`) and restart. Otherwise, tune `PROBE_THREADS`, `PROBE_PARALLEL_THRESHOLD`, and `PROBE_TIMEOUT_SECS` in `config.py`. Note that `tablebase_router.py` guards each material's first open with a lock keyed by `(kind, material)`, so two threads racing to open the *same* never-before-seen material collapse onto one read, while distinct materials of the same kind open concurrently. If any configured tier uses `REMOTE_MODE="download"`, opens fall back to one lock shared per kind, so several distinct never-before-seen materials of that kind then open one at a time instead - either way, this only affects the very first probe against any given material. |
|
| 913 |
+
| `ModuleNotFoundError: No module named 'waitress'` | The active environment's dependencies are out of date - re-run `pip install -r requirements.txt` to pick up `waitress`. `app.py` imports it unconditionally at the top of the file, before `config.DEBUG` is even read, so setting `DEBUG = True` does **not** avoid this - waitress has to be installed either way. |
|
| 914 |
+
| Every probe on a remote `TABLEBASE_PATH` returns "Position not covered..." or `/health` is `degraded` | Confirm the URL is reachable and correct (try opening `TABLEBASE_PATH/wdl/KQK.lzw` - or any small material's `.lzw` - directly in a browser). Check the startup log's "TABLEBASE_PATH opened remotely at: ..." line (or a warning in its place) for the actual failure. |
|
| 915 |
+
| A probe occasionally returns `503` with `error_code: "probe_timeout"` | Another concurrent request was already probing the same position and didn't finish within `PROBE_TIMEOUT_SECS`. This is transient and retryable - the original probe has usually landed in the child-probe cache before a retry, so the retry is inexpensive. Frequent occurrences suggest raising `PROBE_TIMEOUT_SECS`, or that a slow remote `TABLEBASE_PATH` is the underlying bottleneck. |
|
| 916 |
+
| Remote probing feels slow, or repeated probes against the same material keep hitting the network | Check `REMOTE_TIMEOUT_SECS`/`REMOTE_MAX_RETRIES` aren't causing retries on a slow link, and consider raising `REMOTE_PAGE_CACHE_BYTES` - a too-small budget evicts cached pages (`"direct"`) or downloaded files (`"download"`) before later probes can reuse them. `GET /admin/cache/stats` surfaces this per tier as `tablebase_cache.<low|high|castling>.remote_page_cache` or `.remote_disk_cache` respectively. On a high-latency link also try raising `REMOTE_PAGE_SIZE_BYTES`, or `REMOTE_MODE = "download"`. See [Configuration reference](#configuration-reference). |
|
| 917 |
+
| On Render, the deploy never goes live / stays stuck "deploying" | Render's health check (`healthCheckPath: /health` in `render.yaml`, or the equivalent field on a manually-created service - see [Deploying to Render](#deploying-to-render)) only passes once `/health` returns `200`, which itself requires the sources in `TABLEBASE_PATH` to have opened successfully. Check the service's **Logs** tab for the same "opened remotely at: ..." / warning line described above. |
|
| 918 |
+
| On Render, the service responds but the page never loads / connection refused | Typically a `PORT` mismatch. Confirm `config.py` reads `PORT` from the environment (see [Configuration reference](#configuration-reference)) and that nothing else in a custom start command overrides `HOST`/`PORT`. |
|
| 919 |
+
|
| 920 |
+
---
|
| 921 |
+
|
| 922 |
+
## Credits
|
| 923 |
+
|
| 924 |
+
- Tablebase data & format: [ChessTB / chessdb.cn](https://www.chessdb.cn/)
|
| 925 |
+
- Tablebase probing support: [noobpwnftw/python-chess (`add-chesstb-tablebases` branch)](https://github.com/noobpwnftw/python-chess/tree/add-chesstb-tablebases), a fork of [niklasf/python-chess](https://github.com/niklasf/python-chess)
|
| 926 |
+
- Remote (URL) tablebase support for the fork's `chess.chesstb` module - see `remote/remote_source.py`, `remote/remote_direct.py` and `remote/remote_fallback.py`
|
| 927 |
+
- Board UI: [Chessground](https://github.com/lichess-org/chessground)
|
| 928 |
+
- Move generation/validation on the client, for both Standard chess and
|
| 929 |
+
Chess960: [chessops](https://github.com/niklasf/chessops)
|
| 930 |
+
- Production WSGI server: [waitress](https://docs.pylonsproject.org/projects/waitress/)
|
| 931 |
+
- Piece set: [cburnett](https://github.com/lichess-org/lila/blob/master/COPYING.md), a commonly-used community set found in most web chess UIs.
|
| 932 |
+
|
| 933 |
+
## License
|
| 934 |
+
|
| 935 |
+
ChessTB Explorer is licensed under `GPL-3.0-or-later`; see [`LICENSE`](LICENSE).
|
| 936 |
+
Bundled third-party components and their licenses are listed in
|
| 937 |
+
[`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
THIRD_PARTY_LICENSES.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Third-Party Notices
|
| 2 |
+
|
| 3 |
+
This project bundles or depends on the third-party components listed
|
| 4 |
+
below. Each entry states the component, version, SPDX license
|
| 5 |
+
identifier, and source. This document is provided for attribution and
|
| 6 |
+
license-compliance purposes only and does not constitute legal advice;
|
| 7 |
+
consult qualified counsel before distributing, selling, or publicly
|
| 8 |
+
deploying this software.
|
| 9 |
+
|
| 10 |
+
Every file in this repository not listed in the tables below - including
|
| 11 |
+
`app.py`, `config.py`, `dtz_anchor.py`, `tablebase_opening.py`,
|
| 12 |
+
`tablebase_router.py`, `static/js/admin.js`,
|
| 13 |
+
`static/css/*.css`, `templates/*.html`,
|
| 14 |
+
`frontend/src/engine/`, `frontend/src/hooks/`, `frontend/src/components/`,
|
| 15 |
+
`frontend/src/styles/`, `frontend/src/*.jsx`, and `remote/`, `Dockerfile`, `render.yaml`,
|
| 16 |
+
`.dockerignore`, `requirements.txt`, and `openapi.yaml` - is original and
|
| 17 |
+
licensed under the terms in [`LICENSE`](LICENSE) (`GPL-3.0-or-later`).
|
| 18 |
+
|
| 19 |
+
## Summary
|
| 20 |
+
|
| 21 |
+
| Component | Version | License (SPDX) | Category |
|
| 22 |
+
|---|---|---|---|
|
| 23 |
+
| [React](https://github.com/facebook/react) | 18.3.1 | `MIT` | Frontend |
|
| 24 |
+
| [React DOM](https://github.com/facebook/react) | 18.3.1 | `MIT` | Frontend |
|
| 25 |
+
| [Scheduler](https://github.com/facebook/react) | 0.23.2 | `MIT` | Frontend |
|
| 26 |
+
| [Chessground](https://github.com/lichess-org/chessground) | 10.1.1 | `GPL-3.0-or-later` | Frontend |
|
| 27 |
+
| [chessops](https://github.com/niklasf/chessops) | 0.15.1 | `GPL-3.0-or-later` | Frontend |
|
| 28 |
+
| [@badrap/result](https://github.com/badrap/result) | 0.3.1 | `MIT` | Frontend |
|
| 29 |
+
| [Inter](https://rsms.me/inter/) | — | `OFL-1.1` | Font |
|
| 30 |
+
| [JetBrains Mono](https://www.jetbrains.com/lp/mono/) | — | `OFL-1.1` | Font |
|
| 31 |
+
| cburnett piece set | — | `GPL-2.0-or-later` | Asset |
|
| 32 |
+
| [chess](https://github.com/noobpwnftw/python-chess) (`add-chesstb-tablebases` fork) | latest on branch | `GPL-3.0-or-later` | Python |
|
| 33 |
+
| [Flask](https://github.com/pallets/flask) | `>=3.0,<4.0` | `BSD-3-Clause` | Python |
|
| 34 |
+
| [lz4](https://github.com/python-lz4/python-lz4) | `>=4.0,<5.0` | `BSD-3-Clause` | Python |
|
| 35 |
+
| [waitress](https://github.com/Pylons/waitress) | `>=3.0,<4.0` | `ZPL-2.1` | Python |
|
| 36 |
+
| [requests](https://github.com/psf/requests) | `>=2.31,<3.0` | `Apache-2.0` | Python |
|
| 37 |
+
| [Flask-Limiter](https://github.com/alisaifee/flask-limiter) | `>=4.0,<5.0` | `MIT` | Python |
|
| 38 |
+
|
| 39 |
+
## Notices
|
| 40 |
+
|
| 41 |
+
This project is licensed as a whole under `GPL-3.0-or-later` - see
|
| 42 |
+
[`LICENSE`](LICENSE).
|
| 43 |
+
|
| 44 |
+
The following bundled components carry their own `GPL` license terms:
|
| 45 |
+
|
| 46 |
+
- **`chess` / `chess.chesstb`** (`GPL-3.0-or-later`): imported
|
| 47 |
+
directly by `app.py`.
|
| 48 |
+
- **Chessground** (`GPL-3.0-or-later`): vendored in
|
| 49 |
+
`frontend/src/vendor/`, bundled into `static/dist/app.js`, and served
|
| 50 |
+
to every client.
|
| 51 |
+
- **chessops** (`GPL-3.0-or-later`): vendored in
|
| 52 |
+
`frontend/src/vendor/chessops/`, bundled into `static/dist/app.js`,
|
| 53 |
+
and served to every client.
|
| 54 |
+
- **cburnett piece set** (`GPL-2.0-or-later`): bundled in
|
| 55 |
+
`static/pieces/cburnett/` and served to every client.
|
| 56 |
+
|
| 57 |
+
React, React DOM, Scheduler, and `@badrap/result` (`MIT`) are also
|
| 58 |
+
bundled into `static/dist/app.js` - see
|
| 59 |
+
[React / build dependencies](#react--build-dependencies-frontend) below.
|
| 60 |
+
|
| 61 |
+
## Frontend engine libraries: `frontend/src/vendor/`
|
| 62 |
+
|
| 63 |
+
| File | Component | Version | License | Source |
|
| 64 |
+
|---|---|---|---|---|
|
| 65 |
+
| `chessground.min.js` | Chessground | 10.1.1 | `GPL-3.0-or-later` | [lichess-org/chessground](https://github.com/lichess-org/chessground) |
|
| 66 |
+
| `chessops/*.js` (excl. `badrap-result.js`) | chessops | 0.15.1 | `GPL-3.0-or-later` | [niklasf/chessops](https://github.com/niklasf/chessops) |
|
| 67 |
+
| `chessops/badrap-result.js` | @badrap/result | 0.3.1 | `MIT` | [badrap/result](https://github.com/badrap/result) |
|
| 68 |
+
| `chessground.base.css` | Chessground stylesheet | 10.1.1 | `GPL-3.0-or-later` | [lichess-org/chessground](https://github.com/lichess-org/chessground) |
|
| 69 |
+
|
| 70 |
+
`frontend/src/vendor/` holds the pinned ESM distribution and its
|
| 71 |
+
`@badrap/result` runtime dependency; `vite build` bundles them into
|
| 72 |
+
`static/dist/app.js` together with the frontend's own code. They are
|
| 73 |
+
not fetched from a CDN at request time. `chessground.base.css` is
|
| 74 |
+
bundled into `static/dist/app.css`.
|
| 75 |
+
|
| 76 |
+
## React / build dependencies: `frontend/`
|
| 77 |
+
|
| 78 |
+
| Component | Version | License | Source |
|
| 79 |
+
|---|---|---|---|
|
| 80 |
+
| React | 18.3.1 | `MIT` | [facebook/react](https://github.com/facebook/react) |
|
| 81 |
+
| React DOM | 18.3.1 | `MIT` | [facebook/react](https://github.com/facebook/react) |
|
| 82 |
+
| Scheduler | 0.23.2 | `MIT` | [facebook/react](https://github.com/facebook/react) (React DOM's runtime dependency) |
|
| 83 |
+
|
| 84 |
+
Installed via `npm install` from `frontend/package.json` and bundled
|
| 85 |
+
into `static/dist/app.js` by `vite build`; not present anywhere else in
|
| 86 |
+
the served application. Vite and `@vitejs/plugin-react` are build-time
|
| 87 |
+
only and are not distributed to clients.
|
| 88 |
+
|
| 89 |
+
## Fonts: `frontend/src/assets/fonts/`
|
| 90 |
+
|
| 91 |
+
| File(s) | Typeface | License | Author |
|
| 92 |
+
|---|---|---|---|
|
| 93 |
+
| `Inter-Regular.woff2`, `Inter-Medium.woff2`, `Inter-SemiBold.woff2` | Inter | `OFL-1.1` | [Rasmus Andersson](https://rsms.me/inter/) |
|
| 94 |
+
| `JetBrainsMono-Regular.woff2`, `JetBrainsMono-Medium.woff2` | JetBrains Mono | `OFL-1.1` | [JetBrains](https://www.jetbrains.com/lp/mono/) |
|
| 95 |
+
|
| 96 |
+
The build copies the fonts to `static/dist/fonts/`.
|
| 97 |
+
|
| 98 |
+
## Chess piece set: `static/pieces/` (via lichess.org)
|
| 99 |
+
|
| 100 |
+
| Directory | License | Author | Source |
|
| 101 |
+
|---|---|---|---|
|
| 102 |
+
| `cburnett/` | `GPL-2.0-or-later` | Colin M.L. Burnett | [lila COPYING.md](https://github.com/lichess-org/lila/blob/master/COPYING.md) |
|
| 103 |
+
|
| 104 |
+
## Python dependencies: `requirements.txt`
|
| 105 |
+
|
| 106 |
+
| Package | License | Notes |
|
| 107 |
+
|---|---|---|
|
| 108 |
+
| `chess` (installed from `noobpwnftw/python-chess`, `add-chesstb-tablebases` branch) | `GPL-3.0-or-later` | Fork of [niklasf/python-chess](https://github.com/niklasf/python-chess). Both the base `chess` package and `chess.chesstb` are imported directly by `app.py`. |
|
| 109 |
+
| Flask | `BSD-3-Clause` | [pallets/flask](https://github.com/pallets/flask) |
|
| 110 |
+
| lz4 | `BSD-3-Clause` | [python-lz4/python-lz4](https://github.com/python-lz4/python-lz4). Optional but recommended: `chess.chesstb` picks it up itself at import time to accelerate its own block decompression, falling back to its own pure-Python decoder if not present. `app.py` doesn't import or reference it directly. |
|
| 111 |
+
| waitress | `ZPL-2.1` | [Pylons/waitress](https://github.com/Pylons/waitress) |
|
| 112 |
+
| requests | `Apache-2.0` | [psf/requests](https://github.com/psf/requests). Imported only when `TABLEBASE_PATH` is a remote `http(s)://` URL (`remote/remote_source.py`, `remote/remote_fallback.py`). |
|
| 113 |
+
| Flask-Limiter | `MIT` | [alisaifee/flask-limiter](https://github.com/alisaifee/flask-limiter). Provides the per-IP rate limiting on `/probe` and `/probe/stream` - see `config.py`'s `PROBE_RATE_LIMIT`. |
|
| 114 |
+
|
| 115 |
+
## License texts
|
| 116 |
+
|
| 117 |
+
| SPDX identifier | Text |
|
| 118 |
+
|---|---|
|
| 119 |
+
| `GPL-3.0-or-later` | <https://www.gnu.org/licenses/gpl-3.0.txt> |
|
| 120 |
+
| `GPL-2.0-or-later` | <https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt> |
|
| 121 |
+
| `BSD-2-Clause` | <https://spdx.org/licenses/BSD-2-Clause.html> |
|
| 122 |
+
| `BSD-3-Clause` | <https://spdx.org/licenses/BSD-3-Clause.html> |
|
| 123 |
+
| `OFL-1.1` | <https://scripts.sil.org/OFL> |
|
| 124 |
+
| `ZPL-2.1` | <https://opensource.org/license/zpl-2-1/> |
|
| 125 |
+
| `Apache-2.0` | <https://www.apache.org/licenses/LICENSE-2.0.txt> |
|
| 126 |
+
| `MIT` | <https://spdx.org/licenses/MIT.html> |
|
| 127 |
+
|
| 128 |
+
## Tablebase data
|
| 129 |
+
|
| 130 |
+
Not covered by this document. This project only displays results from
|
| 131 |
+
ChessTB tablebase files supplied by the user (see `README.md` →
|
| 132 |
+
"Getting the tablebase files"). No tablebase data is bundled with the
|
| 133 |
+
app.
|
app.py
ADDED
|
@@ -0,0 +1,2048 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py: ChessTB Explorer back-end
|
| 3 |
+
|
| 4 |
+
A single-file Flask application that probes a ChessTB endgame tablebase
|
| 5 |
+
for a submitted FEN position and returns every legal move ranked by
|
| 6 |
+
Distance to Zeroing (DTZ), Distance to Conversion (DTC), Distance to
|
| 7 |
+
Mate (DTM), and DTM under the 50-move rule (DTM50).
|
| 8 |
+
|
| 9 |
+
DTC is 50-move-rule-aware in the same way DTM50 is (it is always probed
|
| 10 |
+
at the position's own halfmove clock), but unlike DTM50 it has no
|
| 11 |
+
"flat"/clock-independent sibling column of its own -- there is only ever
|
| 12 |
+
one DTC value for a position. A ``.lzdtc`` table only exists for material
|
| 13 |
+
with at least one pawn; for pawnless material `chess.chesstb` derives an
|
| 14 |
+
equivalent answer from DTZ/WDL instead (see chess.chesstb.ProbeResult),
|
| 15 |
+
so `has_dtc` is False only when a pawnful material's own
|
| 16 |
+
`.lzdtc` file genuinely hasn't been generated/shipped.
|
| 17 |
+
|
| 18 |
+
Routes
|
| 19 |
+
------
|
| 20 |
+
POST /probe - evaluate a FEN position (JSON)
|
| 21 |
+
POST /probe/stream - evaluate with SSE streaming progress
|
| 22 |
+
GET / - main UI
|
| 23 |
+
GET /health - readiness check
|
| 24 |
+
GET /admin - cache dashboard HTML page, or a login form if not authenticated
|
| 25 |
+
POST /admin/login - start an admin session (JSON {token})
|
| 26 |
+
POST /admin/logout - end the admin session
|
| 27 |
+
POST /admin/cache/clear - purge both LRU caches
|
| 28 |
+
GET /admin/cache/stats - cache hit-rate statistics
|
| 29 |
+
GET /openapi.yaml - OpenAPI 3.0 specification
|
| 30 |
+
|
| 31 |
+
/admin and /admin/cache/* require admin authentication -- either a
|
| 32 |
+
session cookie started via /admin/login, or an "Authorization: Bearer
|
| 33 |
+
<ADMIN_TOKEN>" header. See config.py's "Admin" section and README.md's
|
| 34 |
+
"Security notes" for how ADMIN_TOKEN is configured.
|
| 35 |
+
|
| 36 |
+
/probe and /probe/stream are rate-limited per client IP (config.py's
|
| 37 |
+
PROBE_RATE_LIMIT); a client over the limit gets a 429 with a JSON body.
|
| 38 |
+
/admin/login has its own, separate limit (config.py's
|
| 39 |
+
ADMIN_LOGIN_RATE_LIMIT), covering both failed and successful attempts.
|
| 40 |
+
|
| 41 |
+
Each move entry includes a child_fen so the client can pre-fetch the next
|
| 42 |
+
probe, and each response includes a summary of wins/draws/losses/unknown
|
| 43 |
+
across all legal moves. A position not covered by the loaded tablebase
|
| 44 |
+
raises MissingTableError, which is returned as structured JSON
|
| 45 |
+
({error_code, piece_count}). Root-JSON and child-probe cache sizes are set
|
| 46 |
+
by config.EVALUATE_CACHE_SIZE / config.PROBE_CACHE_SIZE.
|
| 47 |
+
|
| 48 |
+
Every request to /probe and /probe/stream carries an explicit "variant"
|
| 49 |
+
field ("standard" or "chess960", defaulting to "standard" when omitted)
|
| 50 |
+
alongside "fen" -- the two rulesets are never inferred from a FEN's
|
| 51 |
+
contents, since the orthodox starting arrangement is itself a legal
|
| 52 |
+
Chess960 starting position and would otherwise be ambiguous. A submitted
|
| 53 |
+
FEN's castling-availability field accepts "-" or any combination of "K",
|
| 54 |
+
"Q", "k", "q" for variant="standard", plus rook-file letters ("a"-"h" /
|
| 55 |
+
"A"-"H") for variant="chess960"; a right with no matching king/rook pair
|
| 56 |
+
is rejected as invalid input before the board is ever probed (see
|
| 57 |
+
_validate_and_build_board). A position with castling rights is otherwise
|
| 58 |
+
probed like any other: `chess.chesstb` resolves it when the loaded
|
| 59 |
+
tablebase set carries a table for that material (see config.py's
|
| 60 |
+
TABLEBASE_PATH and tablebase_router.py -- unaffected by variant,
|
| 61 |
+
since a Chess960 board's clean_castling_rights() reports the same way a
|
| 62 |
+
Standard board's does), and reports it as not covered when it doesn't.
|
| 63 |
+
|
| 64 |
+
Running this file directly (`python app.py`) serves the app via waitress
|
| 65 |
+
(a production-grade WSGI server) by default. Set DEBUG = True in
|
| 66 |
+
config.py to use Flask's own dev server instead (auto-reload +
|
| 67 |
+
interactive debugger) for local development. See README.md
|
| 68 |
+
"Installation" / "Running in production" for details.
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
from __future__ import annotations
|
| 72 |
+
|
| 73 |
+
from collections import OrderedDict, namedtuple
|
| 74 |
+
from concurrent.futures import (
|
| 75 |
+
ThreadPoolExecutor,
|
| 76 |
+
wait as futures_wait,
|
| 77 |
+
ALL_COMPLETED,
|
| 78 |
+
FIRST_COMPLETED,
|
| 79 |
+
)
|
| 80 |
+
from dataclasses import dataclass
|
| 81 |
+
from flask import (
|
| 82 |
+
Flask, render_template, request, jsonify, Response,
|
| 83 |
+
stream_with_context, send_file, session,
|
| 84 |
+
)
|
| 85 |
+
from flask_limiter import Limiter
|
| 86 |
+
from flask_limiter.util import get_remote_address
|
| 87 |
+
from functools import lru_cache, wraps
|
| 88 |
+
from typing import TypedDict
|
| 89 |
+
from waitress import serve as waitress_serve
|
| 90 |
+
from werkzeug.middleware.proxy_fix import ProxyFix
|
| 91 |
+
import atexit
|
| 92 |
+
import chess
|
| 93 |
+
import config
|
| 94 |
+
import dtz_anchor
|
| 95 |
+
import hmac
|
| 96 |
+
import json
|
| 97 |
+
import os
|
| 98 |
+
import re
|
| 99 |
+
import logging
|
| 100 |
+
import secrets
|
| 101 |
+
import tablebase_router
|
| 102 |
+
import threading
|
| 103 |
+
import time
|
| 104 |
+
|
| 105 |
+
# ── Logging ───────────────────────────────────────────────────────────────────
|
| 106 |
+
|
| 107 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
| 108 |
+
log = logging.getLogger(__name__)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# ── ChessTB backend ──────────────────────���────────────────────────────────────
|
| 112 |
+
|
| 113 |
+
# chess.chesstb comes from noobpwnftw's add-chesstb-tablebases fork of
|
| 114 |
+
# python-chess (see requirements.txt) - open_tablebase, ProbeResult's raw
|
| 115 |
+
# wdl/dtz/dtc/dtm/dtm50 fields, MissingTableError, and the WIN/CURSED_WIN/
|
| 116 |
+
# DRAW/BLESSED_LOSS/LOSE constants all come from that module.
|
| 117 |
+
import chess.chesstb as chesstb
|
| 118 |
+
|
| 119 |
+
log.info("chess.chesstb backend: noobpwnftw/python-chess (add-chesstb-tablebases)")
|
| 120 |
+
|
| 121 |
+
# Remote (HTTP) tablebase support, in two flavours -- config.REMOTE_MODE
|
| 122 |
+
# picks one (see tablebase_router.open_one):
|
| 123 |
+
# remote/remote_direct.py ("direct") probe in place over byte ranges
|
| 124 |
+
# remote/remote_fallback.py ("download") whole-file download, disk-cached
|
| 125 |
+
# See each file's module docstring for the design and the trade-off.
|
| 126 |
+
# looks_like_remote()/RemoteSourceError come from remote/remote_source.py.
|
| 127 |
+
from remote import remote_direct, remote_fallback, remote_source
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ── Type aliases ──────────────────────────────────────────────────────────────
|
| 131 |
+
|
| 132 |
+
class MoveEntry(TypedDict):
|
| 133 |
+
san: str
|
| 134 |
+
plies: int
|
| 135 |
+
order: int # DTC only: pawn pushes still owed before a conversion (0 for every other metric)
|
| 136 |
+
is_mate: bool
|
| 137 |
+
outcome: str # "win"|"cursed_win"|"draw"|"blessed_loss"|"loss"|"unknown"|"not_available"
|
| 138 |
+
available: bool # False when this move's own metric table isn't present (outcome is then "not_available")
|
| 139 |
+
child_fen: str | None # FEN after this move; None for terminal / unknown
|
| 140 |
+
draw_reason: str | None # "stalemate"|"insufficient_material"|None
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
class DtzMoveEntry(MoveEntry, total=False):
|
| 144 |
+
rounded: bool # DTZ rows only, when available: plies may be a ply off
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@dataclass
|
| 148 |
+
class _DtzMove:
|
| 149 |
+
row: int # index into the DTZ rows
|
| 150 |
+
my_wdl: int
|
| 151 |
+
keeps_class: bool
|
| 152 |
+
option: dtz_anchor.MoveOption
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
MoveList = list[MoveEntry]
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ── Config validation helpers ─────────────────────────────────────────────────
|
| 159 |
+
# Centralise the "read a config.py value, check its type, validate range"
|
| 160 |
+
# pattern that AppConfig.from_config() below needs once per setting.
|
| 161 |
+
|
| 162 |
+
def _validated_int(name: str, value, *, min_val: int = 1, max_val: int | None = None) -> int:
|
| 163 |
+
if isinstance(value, str):
|
| 164 |
+
# PORT and TRUSTED_PROXY_COUNT are the two config.py settings read
|
| 165 |
+
# as a raw string rather than pre-converted at import time (see
|
| 166 |
+
# config.py's own comments on each) -- specifically so a bad
|
| 167 |
+
# value raises here, inside AppConfig.from_config()'s try/except,
|
| 168 |
+
# rather than as an uncaught ValueError from a bare int() call
|
| 169 |
+
# during module import, before this module has an exception handler
|
| 170 |
+
# in place for it.
|
| 171 |
+
try:
|
| 172 |
+
value = int(value.strip())
|
| 173 |
+
except ValueError:
|
| 174 |
+
raise ValueError(f"config.{name} must be an integer, got {value!r}") from None
|
| 175 |
+
if not isinstance(value, int) or isinstance(value, bool):
|
| 176 |
+
raise ValueError(f"config.{name} must be an integer, got {value!r}")
|
| 177 |
+
if max_val is None:
|
| 178 |
+
if value < min_val:
|
| 179 |
+
raise ValueError(f"config.{name} must be >= {min_val}, got {value}")
|
| 180 |
+
elif not (min_val <= value <= max_val):
|
| 181 |
+
raise ValueError(f"config.{name} must be {min_val}-{max_val}, got {value}")
|
| 182 |
+
return value
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _validated_float(name: str, value) -> float:
|
| 186 |
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
| 187 |
+
raise ValueError(f"config.{name} must be a number, got {value!r}")
|
| 188 |
+
value = float(value)
|
| 189 |
+
if value <= 0:
|
| 190 |
+
raise ValueError(f"config.{name} must be > 0, got {value}")
|
| 191 |
+
return value
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _validated_str(name: str, value) -> str:
|
| 195 |
+
if not isinstance(value, str):
|
| 196 |
+
raise ValueError(f"config.{name} must be a string, got {value!r}")
|
| 197 |
+
return value
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _validated_path_spec(name: str, value) -> str | list[str]:
|
| 201 |
+
"""TABLEBASE_PATH: a
|
| 202 |
+
single path (local directory or http(s):// URL) or a list of them for
|
| 203 |
+
a hybrid source (see tablebase_router.py). Each list entry must
|
| 204 |
+
itself be a string -- a nested list isn't a valid hybrid source."""
|
| 205 |
+
if isinstance(value, str):
|
| 206 |
+
return value
|
| 207 |
+
if isinstance(value, (list, tuple)) and all(isinstance(v, str) for v in value):
|
| 208 |
+
return list(value)
|
| 209 |
+
raise ValueError(f"config.{name} must be a string or a list of strings, got {value!r}")
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _validated_choice(name: str, value, allowed: tuple) -> str:
|
| 213 |
+
if value not in allowed:
|
| 214 |
+
raise ValueError(
|
| 215 |
+
f"config.{name} must be one of {', '.join(map(repr, allowed))}, got {value!r}")
|
| 216 |
+
return value
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _validated_bool(name: str, value) -> bool:
|
| 220 |
+
if not isinstance(value, bool):
|
| 221 |
+
raise ValueError(f"config.{name} must be True or False, got {value!r}")
|
| 222 |
+
return value
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ── Validated configuration ───────────────────────────────────────────────────
|
| 226 |
+
|
| 227 |
+
@dataclass(frozen=True)
|
| 228 |
+
class AppConfig:
|
| 229 |
+
tablebase_path: str | list[str]
|
| 230 |
+
probe_threads: int
|
| 231 |
+
parallel_threshold: int
|
| 232 |
+
probe_timeout: float
|
| 233 |
+
host: str
|
| 234 |
+
port: int
|
| 235 |
+
evaluate_cache_size: int
|
| 236 |
+
probe_cache_size: int
|
| 237 |
+
block_cache_bytes: int
|
| 238 |
+
debug: bool
|
| 239 |
+
waitress_threads: int
|
| 240 |
+
# Only used for an http(s):// URL entry in tablebase_path (see "ChessTB
|
| 241 |
+
# backend" above and remote/remote_source.py) -- ignored entirely for
|
| 242 |
+
# a local-directory entry. Bounds an on-disk LRU of whole downloaded
|
| 243 |
+
# table files cached by remote/remote_fallback.py (see that file's
|
| 244 |
+
# module docstring for the full design).
|
| 245 |
+
remote_mode: str
|
| 246 |
+
remote_page_cache_bytes: int
|
| 247 |
+
remote_page_size_bytes: int
|
| 248 |
+
remote_timeout_secs: float
|
| 249 |
+
remote_max_retries: int
|
| 250 |
+
remote_pool_maxsize: int
|
| 251 |
+
admin_token: str
|
| 252 |
+
secret_key: str
|
| 253 |
+
source_repository_url: str
|
| 254 |
+
probe_rate_limit: str
|
| 255 |
+
prefetch_rate_limit: str
|
| 256 |
+
admin_login_rate_limit: str
|
| 257 |
+
trusted_proxy_count: int
|
| 258 |
+
session_cookie_secure: bool
|
| 259 |
+
# Hard-coded limit, not one of config.py's settings -- kept as a
|
| 260 |
+
# dataclass field (rather than a bare module constant) so it reads
|
| 261 |
+
# alongside every other request-validation knob above.
|
| 262 |
+
max_fen_length: int = 100
|
| 263 |
+
|
| 264 |
+
@classmethod
|
| 265 |
+
def from_config(cls) -> "AppConfig":
|
| 266 |
+
# PROBE_THREADS is the one setting with a computed (not literal)
|
| 267 |
+
# default, so a None left in config.py falls back to a
|
| 268 |
+
# CPU-count-based figure instead of going through _validated_int.
|
| 269 |
+
threads = (
|
| 270 |
+
_validated_int("PROBE_THREADS", config.PROBE_THREADS)
|
| 271 |
+
if config.PROBE_THREADS is not None
|
| 272 |
+
else min(16, (os.cpu_count() or 4) * 2)
|
| 273 |
+
)
|
| 274 |
+
# Same "None means auto" shape as PROBE_THREADS above, computed
|
| 275 |
+
# from it: a connection pool smaller than the probe thread count
|
| 276 |
+
# gives up connection reuse for whichever requests land once the
|
| 277 |
+
# pool is exhausted (see remote/remote_source.py).
|
| 278 |
+
pool_maxsize = (
|
| 279 |
+
_validated_int("REMOTE_POOL_MAXSIZE", config.REMOTE_POOL_MAXSIZE)
|
| 280 |
+
if config.REMOTE_POOL_MAXSIZE is not None
|
| 281 |
+
else max(threads * 2, 20)
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
return cls(
|
| 285 |
+
tablebase_path=_validated_path_spec("TABLEBASE_PATH", config.TABLEBASE_PATH),
|
| 286 |
+
probe_threads=threads,
|
| 287 |
+
parallel_threshold=_validated_int("PROBE_PARALLEL_THRESHOLD", config.PROBE_PARALLEL_THRESHOLD),
|
| 288 |
+
probe_timeout=_validated_float("PROBE_TIMEOUT_SECS", config.PROBE_TIMEOUT_SECS),
|
| 289 |
+
host=_validated_str("HOST", config.HOST),
|
| 290 |
+
port=_validated_int("PORT", config.PORT, max_val=65535),
|
| 291 |
+
evaluate_cache_size=_validated_int("EVALUATE_CACHE_SIZE", config.EVALUATE_CACHE_SIZE),
|
| 292 |
+
probe_cache_size=_validated_int("PROBE_CACHE_SIZE", config.PROBE_CACHE_SIZE),
|
| 293 |
+
block_cache_bytes=_validated_int("BLOCK_CACHE_BYTES", config.BLOCK_CACHE_BYTES),
|
| 294 |
+
# DEBUG also selects the server used by
|
| 295 |
+
# `if __name__ == "__main__"` below: the Flask dev server when
|
| 296 |
+
# True, waitress otherwise (see README.md "Running in production").
|
| 297 |
+
debug=_validated_bool("DEBUG", config.DEBUG),
|
| 298 |
+
waitress_threads=_validated_int("WAITRESS_THREADS", config.WAITRESS_THREADS),
|
| 299 |
+
remote_mode=_validated_choice("REMOTE_MODE", config.REMOTE_MODE, ("direct", "download")),
|
| 300 |
+
remote_page_cache_bytes=_validated_int(
|
| 301 |
+
"REMOTE_PAGE_CACHE_BYTES", config.REMOTE_PAGE_CACHE_BYTES),
|
| 302 |
+
remote_page_size_bytes=_validated_int(
|
| 303 |
+
"REMOTE_PAGE_SIZE_BYTES", config.REMOTE_PAGE_SIZE_BYTES),
|
| 304 |
+
remote_timeout_secs=_validated_float("REMOTE_TIMEOUT_SECS", config.REMOTE_TIMEOUT_SECS),
|
| 305 |
+
remote_max_retries=_validated_int("REMOTE_MAX_RETRIES", config.REMOTE_MAX_RETRIES),
|
| 306 |
+
remote_pool_maxsize=pool_maxsize,
|
| 307 |
+
admin_token=_validated_str("ADMIN_TOKEN", config.ADMIN_TOKEN),
|
| 308 |
+
secret_key=_validated_str("FLASK_SECRET_KEY", config.FLASK_SECRET_KEY),
|
| 309 |
+
source_repository_url=_validated_str("SOURCE_REPOSITORY_URL", config.SOURCE_REPOSITORY_URL),
|
| 310 |
+
probe_rate_limit=_validated_str("PROBE_RATE_LIMIT", config.PROBE_RATE_LIMIT),
|
| 311 |
+
prefetch_rate_limit=_validated_str("PREFETCH_RATE_LIMIT", config.PREFETCH_RATE_LIMIT),
|
| 312 |
+
admin_login_rate_limit=_validated_str("ADMIN_LOGIN_RATE_LIMIT", config.ADMIN_LOGIN_RATE_LIMIT),
|
| 313 |
+
trusted_proxy_count=_validated_int(
|
| 314 |
+
"TRUSTED_PROXY_COUNT", config.TRUSTED_PROXY_COUNT, min_val=0, max_val=10),
|
| 315 |
+
session_cookie_secure=_validated_bool("SESSION_COOKIE_SECURE", config.SESSION_COOKIE_SECURE),
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
try:
|
| 320 |
+
cfg = AppConfig.from_config()
|
| 321 |
+
except (ValueError, AttributeError) as e:
|
| 322 |
+
log.error("Configuration error: %s — fix config.py and restart.", e)
|
| 323 |
+
raise SystemExit(1) from e
|
| 324 |
+
|
| 325 |
+
# Exception type to treat as "couldn't reach the remote tablebase" in the
|
| 326 |
+
# /probe and /probe/stream handlers below.
|
| 327 |
+
_REMOTE_SOURCE_ERROR = remote_source.RemoteSourceError
|
| 328 |
+
|
| 329 |
+
if not cfg.tablebase_path:
|
| 330 |
+
log.warning("TABLEBASE_PATH is not set — tablebase lookups will fail.")
|
| 331 |
+
|
| 332 |
+
if not cfg.admin_token:
|
| 333 |
+
log.warning(
|
| 334 |
+
"ADMIN_TOKEN is not set — /admin and /admin/cache/* will respond "
|
| 335 |
+
"503 until it's configured (see config.py's \"Admin\" section)."
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
# ── Flask app ─────────────────────────────────────────────────────────────────
|
| 340 |
+
|
| 341 |
+
app = Flask(__name__)
|
| 342 |
+
app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 # 4 KB
|
| 343 |
+
|
| 344 |
+
# secrets.token_hex(32) generated fresh at process startup when
|
| 345 |
+
# FLASK_SECRET_KEY isn't set (see config.py) -- every admin session is then
|
| 346 |
+
# invalidated by a restart, requiring a new login; it never
|
| 347 |
+
# breaks anything the app itself depends on, since sessions here hold
|
| 348 |
+
# nothing but the admin_ok flag set by /admin/login.
|
| 349 |
+
app.secret_key = cfg.secret_key or secrets.token_hex(32)
|
| 350 |
+
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
| 351 |
+
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
| 352 |
+
# Independent of cfg.debug -- see config.py's SESSION_COOKIE_SECURE for
|
| 353 |
+
# why: DEBUG is a development/runtime behavior switch, not a reliable
|
| 354 |
+
# indicator of whether the browser reaches this deployment over HTTPS.
|
| 355 |
+
app.config["SESSION_COOKIE_SECURE"] = cfg.session_cookie_secure
|
| 356 |
+
|
| 357 |
+
# Without this, request.remote_addr -- and therefore get_remote_address()
|
| 358 |
+
# below, which keys both rate limiters -- is whichever host made the TCP
|
| 359 |
+
# connection to this process. Behind a reverse proxy (Render's own
|
| 360 |
+
# edge/load-balancer in front of the render.yaml deployment this project
|
| 361 |
+
# ships with, or any other reverse proxy placed in front of it) that's the
|
| 362 |
+
# proxy's own address, not the actual client's, so every client sharing
|
| 363 |
+
# that proxy would collapse onto one rate-limit key. ProxyFix instead
|
| 364 |
+
# trusts exactly cfg.trusted_proxy_count X-Forwarded-For hops closest to
|
| 365 |
+
# this app and reads the real client IP from ahead of them -- set to 0
|
| 366 |
+
# this is a no-op, appropriate only for a deployment with no reverse proxy
|
| 367 |
+
# in front of it at all (an untrusted client's own X-Forwarded-For header
|
| 368 |
+
# would otherwise be trusted, letting it spoof its rate-limit key).
|
| 369 |
+
#
|
| 370 |
+
# x_proto does the equivalent correction for X-Forwarded-Proto, which a
|
| 371 |
+
# TLS-terminating reverse proxy sets to tell this app the scheme the
|
| 372 |
+
# client actually connected with -- without it, request.scheme /
|
| 373 |
+
# request.is_secure (and any URL this app builds with
|
| 374 |
+
# url_for(..., _external=True)) would read as plain http:// even when the
|
| 375 |
+
# client's own connection was https://, since Flask only ever sees the
|
| 376 |
+
# proxy's own (typically-HTTP) hop to this app. Same trusted-hop count as
|
| 377 |
+
# x_for, for the same reason: a value here that doesn't match the real
|
| 378 |
+
# proxy chain either misreads the scheme or lets a client spoof it.
|
| 379 |
+
#
|
| 380 |
+
# This only actually matters for cfg.debug's Flask-dev-server path below --
|
| 381 |
+
# under waitress (cfg.debug False, the production path), waitress parses
|
| 382 |
+
# X-Forwarded-For and corrects REMOTE_ADDR itself before this app ever
|
| 383 |
+
# sees the request, taking priority over -- and making redundant, but
|
| 384 |
+
# harmlessly so -- the correction this makes. See the waitress_serve(...)
|
| 385 |
+
# call below for that side of it.
|
| 386 |
+
if cfg.trusted_proxy_count > 0:
|
| 387 |
+
app.wsgi_app = ProxyFix(
|
| 388 |
+
app.wsgi_app, x_for=cfg.trusted_proxy_count, x_proto=cfg.trusted_proxy_count)
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
@app.errorhandler(413)
|
| 392 |
+
def request_too_large(e: Exception) -> tuple:
|
| 393 |
+
return jsonify({"error": "Request body too large (max 4 KB)."}), 413
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
# ── Rate limiting ────────────────────────────────────────────────────────────
|
| 397 |
+
# Per-IP limits on /probe, /probe/stream (config.py's PROBE_RATE_LIMIT, or
|
| 398 |
+
# PREFETCH_RATE_LIMIT for a request tagged "X-Prefetch: 1" -- see
|
| 399 |
+
# _request_probe_limit below) and, separately, /admin/login (config.py's
|
| 400 |
+
# ADMIN_LOGIN_RATE_LIMIT -- see README.md's "Security notes").
|
| 401 |
+
# default_limits=[] means nothing is limited except the routes explicitly
|
| 402 |
+
# decorated with @_probe_rate_limit / @_admin_login_rate_limit below; the
|
| 403 |
+
# in-memory storage is per-process, which is enough for a single-container
|
| 404 |
+
# deployment but doesn't share state across multiple replicas. key_func
|
| 405 |
+
# reads request.remote_addr, which ProxyFix (see the Flask app section
|
| 406 |
+
# above) has already corrected for cfg.trusted_proxy_count reverse-proxy
|
| 407 |
+
# hops.
|
| 408 |
+
limiter = Limiter(
|
| 409 |
+
key_func=get_remote_address, app=app, default_limits=[], storage_uri="memory://",
|
| 410 |
+
headers_enabled=True)
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def _request_probe_limit() -> str:
|
| 414 |
+
# X-Prefetch is client-asserted and therefore untrusted as a basis for
|
| 415 |
+
# relaxing enforcement -- it only ever selects a separate, tighter
|
| 416 |
+
# budget than the default, never bypasses PROBE_RATE_LIMIT. A request
|
| 417 |
+
# without the header, or when PREFETCH_RATE_LIMIT is unset, always
|
| 418 |
+
# falls back to the normal probe budget.
|
| 419 |
+
if request.headers.get("X-Prefetch") == "1" and cfg.prefetch_rate_limit:
|
| 420 |
+
return cfg.prefetch_rate_limit
|
| 421 |
+
return cfg.probe_rate_limit
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def _probe_rate_limit(view):
|
| 425 |
+
# A plain pass-through when neither limit is configured, rather than
|
| 426 |
+
# a zero/near-zero limiter.limit() call, so "disabled" actually means
|
| 427 |
+
# unlimited rather than "limited to approximately nothing." A
|
| 428 |
+
# callable limit_value (rather than the plain string
|
| 429 |
+
# _admin_login_rate_limit below uses) is what lets a single decorator
|
| 430 |
+
# route a hover-prefetch request to its own, tighter budget -- see
|
| 431 |
+
# _request_probe_limit above -- flask-limiter re-evaluates it on
|
| 432 |
+
# every request rather than once at decoration time.
|
| 433 |
+
if not cfg.probe_rate_limit and not cfg.prefetch_rate_limit:
|
| 434 |
+
return view
|
| 435 |
+
return limiter.limit(_request_probe_limit)(view)
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def _admin_login_rate_limit(view):
|
| 439 |
+
# Independent of _probe_rate_limit above -- /admin/login is a
|
| 440 |
+
# brute-forceable shared-secret check, not a CPU-bound probe, so it
|
| 441 |
+
# gets its own (much tighter) limit rather than reusing or being
|
| 442 |
+
# covered by PROBE_RATE_LIMIT.
|
| 443 |
+
if not cfg.admin_login_rate_limit:
|
| 444 |
+
return view
|
| 445 |
+
return limiter.limit(cfg.admin_login_rate_limit)(view)
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
@app.errorhandler(429)
|
| 449 |
+
def rate_limit_exceeded(e: Exception) -> tuple:
|
| 450 |
+
return jsonify({
|
| 451 |
+
"error": "Too many requests. Please slow down and try again shortly.",
|
| 452 |
+
}), 429
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
# ── Tablebase + thread pool ───────────────────────────────────────────────────
|
| 456 |
+
# All material, including castling and recursive dependencies, uses the
|
| 457 |
+
# same ordered source list. The adapter shares one decoded-block cache.
|
| 458 |
+
try:
|
| 459 |
+
_tb_source = tablebase_router.open_many(
|
| 460 |
+
cfg.tablebase_path, label="TABLEBASE_PATH", cfg=cfg,
|
| 461 |
+
remote_source=remote_source, remote_direct=remote_direct,
|
| 462 |
+
remote_download=remote_fallback,
|
| 463 |
+
)
|
| 464 |
+
except Exception as e:
|
| 465 |
+
log.warning("Failed to open TABLEBASE_PATH: %s", e)
|
| 466 |
+
_tb_source = None
|
| 467 |
+
|
| 468 |
+
TB = (
|
| 469 |
+
tablebase_router.SourcedTablebase(
|
| 470 |
+
_tb_source, remote_source=remote_source,
|
| 471 |
+
block_cache_bytes=cfg.block_cache_bytes,
|
| 472 |
+
)
|
| 473 |
+
if _tb_source is not None else None
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
_executor = ThreadPoolExecutor(max_workers=cfg.probe_threads, thread_name_prefix="tb_probe")
|
| 477 |
+
log.info(
|
| 478 |
+
"Thread pool: %d workers, parallel_threshold=%d, timeout=%.0fs, "
|
| 479 |
+
"eval_cache=%d, probe_cache=%d, block_cache_bytes=%d",
|
| 480 |
+
cfg.probe_threads, cfg.parallel_threshold, cfg.probe_timeout,
|
| 481 |
+
cfg.evaluate_cache_size, cfg.probe_cache_size, cfg.block_cache_bytes,
|
| 482 |
+
)
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
@atexit.register
|
| 486 |
+
def _close_tb() -> None:
|
| 487 |
+
try:
|
| 488 |
+
if TB is not None:
|
| 489 |
+
TB.close()
|
| 490 |
+
except Exception as e:
|
| 491 |
+
log.warning("Error closing tablebase: %s", e)
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
@atexit.register
|
| 495 |
+
def _shutdown_executor() -> None:
|
| 496 |
+
try:
|
| 497 |
+
_executor.shutdown(wait=True, cancel_futures=True)
|
| 498 |
+
except Exception as e:
|
| 499 |
+
log.warning("Error shutting down thread pool: %s", e)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
# ── Security headers ──────────────────────────────────────────────────────────
|
| 503 |
+
|
| 504 |
+
@app.after_request
|
| 505 |
+
def set_security_headers(response: Response) -> Response:
|
| 506 |
+
# base-uri and form-action are set explicitly since default-src doesn't
|
| 507 |
+
# cover them; object-src, though covered, is set to 'none' since the app
|
| 508 |
+
# has no use for <object>/<embed>. Allow Hugging Face's Space page
|
| 509 |
+
# to embed the application; CSP controls the permitted ancestors.
|
| 510 |
+
response.headers["Content-Security-Policy"] = (
|
| 511 |
+
"default-src 'self'; script-src 'self'; "
|
| 512 |
+
"style-src 'self' 'unsafe-inline'; "
|
| 513 |
+
"font-src 'self'; img-src 'self' data:; connect-src 'self'; "
|
| 514 |
+
"object-src 'none'; base-uri 'self'; form-action 'self'; "
|
| 515 |
+
"frame-ancestors 'self' https://huggingface.co;"
|
| 516 |
+
)
|
| 517 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 518 |
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
| 519 |
+
return response
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
# ── Admin authentication ────────────────────────────────────────────────────
|
| 523 |
+
# Gates /admin and /admin/cache/* behind cfg.admin_token (see config.py's
|
| 524 |
+
# "Admin" section). Two ways to authenticate:
|
| 525 |
+
# - Browser: POST /admin/login with the token starts a signed session
|
| 526 |
+
# cookie (see app.secret_key above); subsequent requests from the same
|
| 527 |
+
# browser are authenticated via that cookie automatically.
|
| 528 |
+
# - API client: send "Authorization: Bearer <ADMIN_TOKEN>" directly,
|
| 529 |
+
# no session/cookie needed.
|
| 530 |
+
# If cfg.admin_token is empty, every admin route responds 503 rather than
|
| 531 |
+
# running with no password -- there is no "admin panel open to everyone"
|
| 532 |
+
# fallback.
|
| 533 |
+
|
| 534 |
+
def _admin_token_matches(candidate: str) -> bool:
|
| 535 |
+
# hmac.compare_digest for constant-time comparison -- a plain `==`
|
| 536 |
+
# would leak the token's length/prefix through response-timing
|
| 537 |
+
# differences to an attacker making repeated guesses.
|
| 538 |
+
return bool(cfg.admin_token) and hmac.compare_digest(candidate, cfg.admin_token)
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
def _is_admin_authenticated() -> bool:
|
| 542 |
+
if session.get("admin_ok"):
|
| 543 |
+
return True
|
| 544 |
+
auth_header = request.headers.get("Authorization", "")
|
| 545 |
+
if auth_header.startswith("Bearer "):
|
| 546 |
+
return _admin_token_matches(auth_header[len("Bearer "):])
|
| 547 |
+
return False
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def _require_admin(view):
|
| 551 |
+
@wraps(view)
|
| 552 |
+
def wrapped(*args, **kwargs):
|
| 553 |
+
if not cfg.admin_token:
|
| 554 |
+
return jsonify({
|
| 555 |
+
"error": "Admin panel disabled: ADMIN_TOKEN is not configured.",
|
| 556 |
+
}), 503
|
| 557 |
+
if not _is_admin_authenticated():
|
| 558 |
+
return jsonify({"error": "Admin authentication required."}), 401
|
| 559 |
+
return view(*args, **kwargs)
|
| 560 |
+
return wrapped
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
# ── FEN normalisation ─────────────────────────────────────────────────────────
|
| 564 |
+
|
| 565 |
+
_FEN_FIELD_DEFAULTS = ("8/8/8/8/8/8/8/8", "w", "-", "-", "0", "1")
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
def _normalize_fen(fen: str) -> str:
|
| 569 |
+
parts = fen.split()
|
| 570 |
+
while len(parts) < 6:
|
| 571 |
+
parts.append(_FEN_FIELD_DEFAULTS[len(parts)])
|
| 572 |
+
return " ".join(parts[:6])
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
# ── Sort helpers ──────────────────────────────────────────────────────────────
|
| 576 |
+
|
| 577 |
+
_OUTCOME_RANK: dict[int, int] = {2: 0, 1: 1, 0: 2, -1: 3, -2: 4}
|
| 578 |
+
_PROMOTION_RANK = {"Q": 0, "R": 1, "B": 2, "N": 3}
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
def _move_tiebreak(san: str) -> tuple[int, str, int, str]:
|
| 582 |
+
"""Prefer immediate mate, then SAN order with Q/R/B/N promotions.
|
| 583 |
+
|
| 584 |
+
The SAN prefix groups promotions along the same path. This key is
|
| 585 |
+
used only after the metric's outcome, DTC order, and distance, so a
|
| 586 |
+
better underpromotion still beats a worse queen promotion. Immediate
|
| 587 |
+
mate must beat a tied zeroing/conversion move (DTZ/DTC can score both
|
| 588 |
+
as one ply), including when the mating move is an underpromotion.
|
| 589 |
+
"""
|
| 590 |
+
mate_rank = 0 if san.endswith("#") else 1
|
| 591 |
+
prefix, separator, promotion = san.partition("=")
|
| 592 |
+
if separator:
|
| 593 |
+
return mate_rank, prefix, _PROMOTION_RANK[promotion[0]], promotion[1:]
|
| 594 |
+
return mate_rank, san, 0, ""
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
def _outcome_rank(wdl_val: int) -> int:
|
| 598 |
+
return _OUTCOME_RANK[wdl_val]
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
def _ply_rank(wdl_val: int, eff_val: int) -> int:
|
| 602 |
+
if wdl_val > 0: return eff_val
|
| 603 |
+
if wdl_val < 0: return -abs(eff_val)
|
| 604 |
+
return 0
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
def _order_rank(wdl_val: int, order: int) -> int:
|
| 608 |
+
"""Sort key for DTC's "pushes still owed" component. DTC's own notion
|
| 609 |
+
of a better line is pushes first, then plies (see ProbeResult's
|
| 610 |
+
docstring in chess.chesstb): fewer pushes owed is better while
|
| 611 |
+
winning, more is better while losing (it's more delay bought before
|
| 612 |
+
the opponent's own clock forces a conversion). order is already an
|
| 613 |
+
unsigned count, so - unlike _ply_rank - there's no magnitude to
|
| 614 |
+
unwrap, only a direction to apply."""
|
| 615 |
+
if wdl_val > 0: return order
|
| 616 |
+
if wdl_val < 0: return -order
|
| 617 |
+
return 0
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
_WDL_TO_OUTCOME_LABEL: dict[int, str] = {
|
| 621 |
+
2: "win", 1: "cursed_win", 0: "draw", -1: "blessed_loss", -2: "loss",
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
def _outcome_label(wdl_val: int) -> str:
|
| 626 |
+
return _WDL_TO_OUTCOME_LABEL[wdl_val]
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
def _effective_move_wdl(move_wdl: int, root_wdl: int, eff_distance: int) -> int:
|
| 630 |
+
"""The win/cursed-win (or loss/blessed-loss) bucket a move belongs in,
|
| 631 |
+
used for both its outcome label and its sort-key rank. A raw win/loss
|
| 632 |
+
(move_wdl 2/-2) downgrades to cursed_win/blessed_loss (1/-1) when its
|
| 633 |
+
own eff_distance exceeds chesstb.MAX_NON_CURSED_DTZ. The result is then capped
|
| 634 |
+
at root_wdl, since a move can never be better than the root position's
|
| 635 |
+
own optimal value - this cap is one-directional, as a move can be
|
| 636 |
+
worse than root_wdl (e.g. a blunder turning a blessed_loss root into a
|
| 637 |
+
pure -2 loss). Draws pass through unchanged."""
|
| 638 |
+
if abs(eff_distance) > chesstb.MAX_NON_CURSED_DTZ:
|
| 639 |
+
if move_wdl == 2:
|
| 640 |
+
move_wdl = 1
|
| 641 |
+
elif move_wdl == -2:
|
| 642 |
+
move_wdl = -1
|
| 643 |
+
return _capped_wdl(move_wdl, root_wdl)
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
def _effective_distance(wdl: int, raw_distance: int) -> int:
|
| 647 |
+
if wdl != 0 and raw_distance == 0:
|
| 648 |
+
return 1 if wdl > 0 else -1
|
| 649 |
+
if raw_distance > 0:
|
| 650 |
+
return raw_distance + 1
|
| 651 |
+
if raw_distance < 0:
|
| 652 |
+
return raw_distance - 1
|
| 653 |
+
return 0
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
def _is_cursed_exit(parent: chess.Board, child: chess.Board, child_wdl: int) -> bool:
|
| 657 |
+
"""True when a move into a cursed or blessed child (signed child_wdl)
|
| 658 |
+
gives up castling rights."""
|
| 659 |
+
return (abs(child_wdl) == 1
|
| 660 |
+
and chess.popcount(child.clean_castling_rights())
|
| 661 |
+
< chess.popcount(parent.clean_castling_rights()))
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
def _capped_wdl(move_wdl: int, root_wdl: int) -> int:
|
| 665 |
+
"""move_wdl capped at root_wdl: a move can never be better than the root
|
| 666 |
+
position's own optimal value (see _effective_move_wdl). Used by the
|
| 667 |
+
clock-independent DTZ/DTM columns."""
|
| 668 |
+
return root_wdl if move_wdl > root_wdl else move_wdl
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
# ── Variant ────────────────────────────────────────────────────────────────
|
| 672 |
+
#
|
| 673 |
+
# "standard" | "chess960". Chess960 changes only the interpretation of
|
| 674 |
+
# castling rights/moves -- board geometry, piece movement, and tablebase
|
| 675 |
+
# material selection are unaffected (see tablebase_router.py, unchanged by
|
| 676 |
+
# variant). The two rulesets are never inferred from a FEN's contents: the
|
| 677 |
+
# orthodox starting array is itself one of the 960 legal Chess960 starting
|
| 678 |
+
# arrangements, so the same FEN text is genuinely ambiguous without this
|
| 679 |
+
# explicit field. An omitted variant defaults to "standard".
|
| 680 |
+
_ALLOWED_VARIANTS = frozenset({"standard", "chess960"})
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
def _normalize_variant(value: object) -> str:
|
| 684 |
+
if value is None:
|
| 685 |
+
return "standard"
|
| 686 |
+
if not isinstance(value, str):
|
| 687 |
+
raise ValueError("variant must be a string.")
|
| 688 |
+
normalized = value.strip().lower()
|
| 689 |
+
if normalized not in _ALLOWED_VARIANTS:
|
| 690 |
+
raise ValueError("Unsupported variant: expected 'standard' or 'chess960'.")
|
| 691 |
+
return normalized
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
def _build_board(variant: str, fen: str) -> chess.Board:
|
| 695 |
+
"""The single place a chess.Board is constructed from a request- or
|
| 696 |
+
move-generation-supplied FEN string. chess960=True changes only how
|
| 697 |
+
the fork parses/serializes the castling field and how it represents
|
| 698 |
+
castling moves -- everything else about the resulting Board (piece
|
| 699 |
+
movement, check/mate detection, piece_map, etc.) behaves identically
|
| 700 |
+
to the variant="standard" path."""
|
| 701 |
+
return chess.Board(fen, chess960=(variant == "chess960"))
|
| 702 |
+
|
| 703 |
+
|
| 704 |
+
# ── FEN pre-validation ────────────────────────────────────────────────────────
|
| 705 |
+
|
| 706 |
+
_EP_PATTERN = re.compile(r"^(-|[a-h][36])$")
|
| 707 |
+
_CASTLING_PATTERN_STANDARD = re.compile(r"^K?Q?k?q?$")
|
| 708 |
+
# Chess960 castling rights use conventional K/Q/k/q where applicable and
|
| 709 |
+
# rook-file letters for explicit rook identification. Semantic validity is
|
| 710 |
+
# checked after board construction.
|
| 711 |
+
_CASTLING_PATTERN_CHESS960 = re.compile(r"^[A-Ha-hKQkq]{1,4}$")
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
def _validate_fen_parts(variant: str, parts: list[str]) -> str | None:
|
| 715 |
+
if len(parts) < 2:
|
| 716 |
+
return "FEN must have at least a piece-placement field and a side-to-move field."
|
| 717 |
+
if parts[1] not in ("w", "b"):
|
| 718 |
+
return f"Invalid side to move: '{parts[1]}' (expected 'w' or 'b')."
|
| 719 |
+
if len(parts) >= 3 and parts[2] != "-":
|
| 720 |
+
if variant == "chess960":
|
| 721 |
+
if not _CASTLING_PATTERN_CHESS960.match(parts[2]):
|
| 722 |
+
return (
|
| 723 |
+
f"Invalid castling availability: '{parts[2]}' (expected some combination "
|
| 724 |
+
"of 'K', 'Q', 'k', 'q', rook-file letters 'a'-'h'/'A'-'H', or '-')."
|
| 725 |
+
)
|
| 726 |
+
elif not _CASTLING_PATTERN_STANDARD.match(parts[2]):
|
| 727 |
+
return (
|
| 728 |
+
f"Invalid castling availability: '{parts[2]}' "
|
| 729 |
+
"(expected some combination of 'K', 'Q', 'k', 'q', or '-')."
|
| 730 |
+
)
|
| 731 |
+
if len(parts) >= 4 and not _EP_PATTERN.match(parts[3]):
|
| 732 |
+
return (
|
| 733 |
+
f"Invalid en-passant square: '{parts[3]}' "
|
| 734 |
+
"(expected '-' or a valid target square such as 'e3' or 'd6')."
|
| 735 |
+
)
|
| 736 |
+
if len(parts) >= 5:
|
| 737 |
+
try:
|
| 738 |
+
hmc = int(parts[4])
|
| 739 |
+
if hmc < 0:
|
| 740 |
+
return "Halfmove clock must be a non-negative integer."
|
| 741 |
+
except ValueError:
|
| 742 |
+
return f"Invalid halfmove clock: '{parts[4]}' (expected a non-negative integer)."
|
| 743 |
+
if len(parts) >= 6:
|
| 744 |
+
try:
|
| 745 |
+
fmc = int(parts[5])
|
| 746 |
+
if fmc < 1:
|
| 747 |
+
return "Fullmove number must be a positive integer."
|
| 748 |
+
except ValueError:
|
| 749 |
+
return f"Invalid fullmove number: '{parts[5]}' (expected a positive integer)."
|
| 750 |
+
return None
|
| 751 |
+
|
| 752 |
+
|
| 753 |
+
# ── Signed-value helpers ──────────────────────────────────────────────────────
|
| 754 |
+
#
|
| 755 |
+
# WIN/CURSED_WIN/DRAW/BLESSED_LOSS/LOSE -> +2/+1/0/-1/-2, matching
|
| 756 |
+
# chess.syzygy's convention -- built from chess.chesstb's public class
|
| 757 |
+
# constants rather than its private _WDL_SIGNED, since this app has no
|
| 758 |
+
# reason to depend on a leading-underscore upstream symbol for a five-entry
|
| 759 |
+
# map it can just as easily own. This is what _probe_board below builds its
|
| 760 |
+
# signed wdl/dtc_wdl/dtm50_wdl fields on.
|
| 761 |
+
|
| 762 |
+
_SIGNED_WDL: dict[int, int] = {
|
| 763 |
+
chesstb.WIN: 2, chesstb.CURSED_WIN: 1, chesstb.DRAW: 0,
|
| 764 |
+
chesstb.BLESSED_LOSS: -1, chesstb.LOSE: -2,
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
|
| 768 |
+
def _signed_wdl(wdl: int) -> int | None:
|
| 769 |
+
"""Signed WDL for a raw wdl/dtc_wdl/dtm50_wdl class value."""
|
| 770 |
+
return _SIGNED_WDL.get(wdl)
|
| 771 |
+
|
| 772 |
+
|
| 773 |
+
# ── Raw probe ─────────────────────────────────────────────────────────────────
|
| 774 |
+
|
| 775 |
+
def _probe_board(board: chess.Board) -> chesstb.ProbeResult | None:
|
| 776 |
+
# A single combined probe() call computes WDL + DTZ + DTC + DTM + DTM50
|
| 777 |
+
# together. The raw ProbeResult is returned as-is: its constructor
|
| 778 |
+
# zero-initialises dtz/dtc_order/dtc/dtm/dtm50 and sets
|
| 779 |
+
# wdl/dtc_wdl/dtm50_wdl to ILLEGAL whenever the matching has_* flag is
|
| 780 |
+
# False, and _signed_wdl() maps ILLEGAL to None. WDL is the only metric
|
| 781 |
+
# required - a position whose WDL can't be resolved is not probeable and
|
| 782 |
+
# returns None. DTZ, DTC, DTM, and DTM50 are each independently
|
| 783 |
+
# optional; has_dtz/has_dtc/has_dtm/has_dtm50 report which of the four
|
| 784 |
+
# the loaded tables answered for this position.
|
| 785 |
+
r = TB.probe(board)
|
| 786 |
+
if r.status != "ok" or _signed_wdl(r.wdl) is None:
|
| 787 |
+
return None
|
| 788 |
+
return r
|
| 789 |
+
|
| 790 |
+
|
| 791 |
+
# ── Child-position probe cache ────────────────────────────────────────────────
|
| 792 |
+
|
| 793 |
+
class ProbeInFlightTimeout(Exception):
|
| 794 |
+
"""A probe of this FEN was already running and didn't finish in time.
|
| 795 |
+
Retryable, exactly like the probe having timed out directly."""
|
| 796 |
+
|
| 797 |
+
|
| 798 |
+
class _InFlightProbe:
|
| 799 |
+
"""Slot for one probe in progress. The thread that creates it does the
|
| 800 |
+
probing; every other thread asking for the same FEN waits on it."""
|
| 801 |
+
|
| 802 |
+
__slots__ = ("done", "result", "error")
|
| 803 |
+
|
| 804 |
+
def __init__(self) -> None:
|
| 805 |
+
self.done = threading.Event()
|
| 806 |
+
self.result: chesstb.ProbeResult | None = None
|
| 807 |
+
self.error: BaseException | None = None
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
_inflight_lock = threading.Lock()
|
| 811 |
+
_inflight: dict[tuple[str, str], _InFlightProbe] = {} # (variant, fen) -> slot
|
| 812 |
+
|
| 813 |
+
# Set (per calling thread) by _probe_fen_with_deadline below, so
|
| 814 |
+
# _probe_fen_deduped's in-flight wait can use a request's own remaining
|
| 815 |
+
# deadline instead of a fresh cfg.probe_timeout -- see that function's
|
| 816 |
+
# docstring for why a plain module-level value wouldn't work here. .board
|
| 817 |
+
# is set the same way, for the same reason, when the caller already has a
|
| 818 |
+
# chess.Board built from this exact fen in scope -- see
|
| 819 |
+
# _probe_fen_with_deadline's board parameter.
|
| 820 |
+
_probe_tls = threading.local()
|
| 821 |
+
|
| 822 |
+
|
| 823 |
+
def _probe_fen_deduped(variant: str, fen: str) -> chesstb.ProbeResult | None:
|
| 824 |
+
"""Runs at most one probe per (variant, fen) at a time, however many
|
| 825 |
+
callers ask.
|
| 826 |
+
|
| 827 |
+
_probe_fen's lru_cache only dedupes probes that have already finished:
|
| 828 |
+
concurrent callers all miss it and all probe the same position. That
|
| 829 |
+
happens routinely: /probe/stream pre-warms a child FEN that
|
| 830 |
+
evaluate_all_moves then asks for again, and a retry after a timeout
|
| 831 |
+
re-asks for a FEN whose first probe is still running. Each duplicate costs
|
| 832 |
+
a full remote probe and occupies a pool worker.
|
| 833 |
+
|
| 834 |
+
A non-owner's wait is bounded by the calling thread's own remaining
|
| 835 |
+
deadline (see _probe_fen_with_deadline) so one hung probe can't pin a
|
| 836 |
+
waiter beyond its own request's budget -- falling back to a fresh
|
| 837 |
+
cfg.probe_timeout when no deadline was set, for a caller reached
|
| 838 |
+
outside a deadline-aware path. The wait expiring raises, which reads
|
| 839 |
+
downstream as a retryable failure just like the probe itself timing out.
|
| 840 |
+
|
| 841 |
+
The owner builds the chess.Board to probe from (variant, fen) itself
|
| 842 |
+
unless the calling thread already left one in _probe_tls.board (see
|
| 843 |
+
_probe_fen_with_deadline's board parameter) -- for a root-fen probe,
|
| 844 |
+
the caller has usually already built one anyway (to validate the FEN,
|
| 845 |
+
render the board, etc.), and re-parsing the same string a second time
|
| 846 |
+
here is pure waste. variant is required even when board is supplied,
|
| 847 |
+
since it is also this function's in-flight/dedup key -- the same FEN
|
| 848 |
+
text probes to a different (and possibly incompatible) result under
|
| 849 |
+
Standard vs Chess960 rules (see the module-level variant comment), so
|
| 850 |
+
a key of fen alone would let one variant's probe be served to the
|
| 851 |
+
other's caller.
|
| 852 |
+
"""
|
| 853 |
+
deadline = getattr(_probe_tls, "deadline", None)
|
| 854 |
+
if deadline is not None and time.monotonic() >= deadline:
|
| 855 |
+
raise ProbeInFlightTimeout("Probe deadline expired before work started")
|
| 856 |
+
key = (variant, fen)
|
| 857 |
+
with _inflight_lock:
|
| 858 |
+
slot = _inflight.get(key)
|
| 859 |
+
if slot is None:
|
| 860 |
+
slot = _inflight[key] = _InFlightProbe()
|
| 861 |
+
owner = True
|
| 862 |
+
else:
|
| 863 |
+
owner = False
|
| 864 |
+
|
| 865 |
+
if not owner:
|
| 866 |
+
deadline = getattr(_probe_tls, "deadline", None)
|
| 867 |
+
timeout = (deadline - time.monotonic()) if deadline is not None else cfg.probe_timeout
|
| 868 |
+
if timeout <= 0 or not slot.done.wait(max(0, timeout)):
|
| 869 |
+
raise ProbeInFlightTimeout(f"Timed out waiting on an in-flight probe of variant={variant} {fen}")
|
| 870 |
+
if slot.error is not None:
|
| 871 |
+
raise slot.error
|
| 872 |
+
return slot.result
|
| 873 |
+
|
| 874 |
+
try:
|
| 875 |
+
board = getattr(_probe_tls, "board", None)
|
| 876 |
+
slot.result = _probe_board(board if board is not None else _build_board(variant, fen))
|
| 877 |
+
return slot.result
|
| 878 |
+
except BaseException as exc:
|
| 879 |
+
slot.error = exc
|
| 880 |
+
raise
|
| 881 |
+
finally:
|
| 882 |
+
# Drop the slot before waking the waiters, so a caller arriving in
|
| 883 |
+
# between starts a fresh probe rather than joining a finished one.
|
| 884 |
+
with _inflight_lock:
|
| 885 |
+
_inflight.pop(key, None)
|
| 886 |
+
slot.done.set()
|
| 887 |
+
|
| 888 |
+
|
| 889 |
+
@lru_cache(maxsize=cfg.probe_cache_size)
|
| 890 |
+
def _probe_fen(variant: str, fen: str) -> chesstb.ProbeResult | None:
|
| 891 |
+
return _probe_fen_deduped(variant, fen)
|
| 892 |
+
|
| 893 |
+
|
| 894 |
+
def _probe_fen_with_deadline(
|
| 895 |
+
variant: str, fen: str, deadline: float | None, board: chess.Board | None = None,
|
| 896 |
+
) -> chesstb.ProbeResult | None:
|
| 897 |
+
"""Calls _probe_fen(variant, fen), but first makes `deadline` (and,
|
| 898 |
+
optionally, `board`) visible to _probe_fen_deduped's in-flight wait /
|
| 899 |
+
owner branch on this thread.
|
| 900 |
+
|
| 901 |
+
_probe_fen must stay a (variant, fen)-only lru_cache -- adding deadline
|
| 902 |
+
as a real parameter would make it part of the cache key, and since a
|
| 903 |
+
deadline is different on every call, that would turn the cache into a
|
| 904 |
+
permanent miss. A plain thread-local set by the caller doesn't work
|
| 905 |
+
either when this runs via _executor.submit(_probe_fen_with_deadline,
|
| 906 |
+
variant, fen, ...): submit() runs the call on a pool worker thread, not
|
| 907 |
+
the submitting thread, so a thread-local the submitter set wouldn't be
|
| 908 |
+
visible there. Passing deadline as a real argument to this wrapper --
|
| 909 |
+
called either directly or as the submitted callable itself -- sidesteps
|
| 910 |
+
both problems: it's a normal argument up to here, then becomes this
|
| 911 |
+
worker thread's own thread-local for the duration of the call.
|
| 912 |
+
|
| 913 |
+
board is optional and exists purely to avoid re-parsing a FEN this
|
| 914 |
+
thread already has a chess.Board for -- pass it only when `board` was
|
| 915 |
+
itself built from this exact (variant, fen) pair, right here, in this
|
| 916 |
+
same function call's caller, moments before this call. _probe_fen_deduped
|
| 917 |
+
has no way to verify the two actually match (board.fen() isn't
|
| 918 |
+
reliably equal to a shortened/unpadded input fen string, so comparing
|
| 919 |
+
them isn't a safety net either), so passing a board that doesn't
|
| 920 |
+
correspond to (variant, fen) silently returns a wrong probe result.
|
| 921 |
+
Child boards copied by _collect_move_info() satisfy the same contract.
|
| 922 |
+
"""
|
| 923 |
+
_probe_tls.deadline = deadline
|
| 924 |
+
_probe_tls.board = board
|
| 925 |
+
try:
|
| 926 |
+
return _probe_fen(variant, fen)
|
| 927 |
+
finally:
|
| 928 |
+
_probe_tls.deadline = None
|
| 929 |
+
_probe_tls.board = None
|
| 930 |
+
|
| 931 |
+
|
| 932 |
+
|
| 933 |
+
# ── Move evaluation ───────────────────────────────────────────────────────────
|
| 934 |
+
|
| 935 |
+
def _apply_sign(magnitude: int, sign_wdl: int) -> int:
|
| 936 |
+
"""Gives an unsigned ply magnitude the sign implied by a signed WDL
|
| 937 |
+
value: positive for a winning sign_wdl, negative for a losing one,
|
| 938 |
+
zero for a draw. Shared by every DTZ/DTC/DTM/DTM50 assembly step in
|
| 939 |
+
evaluate_all_moves below, so the four metrics can't drift apart on
|
| 940 |
+
how a magnitude gets its sign."""
|
| 941 |
+
if sign_wdl > 0:
|
| 942 |
+
return magnitude
|
| 943 |
+
if sign_wdl < 0:
|
| 944 |
+
return -magnitude
|
| 945 |
+
return 0
|
| 946 |
+
|
| 947 |
+
|
| 948 |
+
def _collect_move_info(board: chess.Board) -> list[tuple[str, bool, bool, bool, str | None, str | None, chess.Board | None]]:
|
| 949 |
+
move_info: list[tuple[str, bool, bool, bool, str | None, str | None, chess.Board | None]] = []
|
| 950 |
+
for move in board.legal_moves:
|
| 951 |
+
san = board.san(move)
|
| 952 |
+
# Bypasses expensive Board.is_checkmate() checks by parsing the SAN string directly
|
| 953 |
+
move_is_mate = san.endswith("#")
|
| 954 |
+
move_is_zeroing = board.is_zeroing(move)
|
| 955 |
+
# DTC's own notion of "conversion" (a capture or a promotion) is
|
| 956 |
+
# narrower than move_is_zeroing (capture OR any pawn move): a quiet
|
| 957 |
+
# pawn push zeroes the clock too, but chess.chesstb.Tablebase.
|
| 958 |
+
# _dtc_move_kind does not treat it as a conversion, only as one of
|
| 959 |
+
# the pushes the winning side still owes (see the DTC row below,
|
| 960 |
+
# this field's only consumer). is_capture() rather than a bare
|
| 961 |
+
# to-square occupancy check so an
|
| 962 |
+
# en passant capture - whose destination square is empty - still
|
| 963 |
+
# counts as a conversion here.
|
| 964 |
+
move_is_conversion = board.is_capture(move) or move.promotion is not None
|
| 965 |
+
draw_reason: str | None = None
|
| 966 |
+
child_board: chess.Board | None = None
|
| 967 |
+
board.push(move)
|
| 968 |
+
try:
|
| 969 |
+
if not move_is_mate:
|
| 970 |
+
# Bypasses Board.is_stalemate() by stopping at the first legal move found
|
| 971 |
+
if not any(board.generate_legal_moves()):
|
| 972 |
+
draw_reason = "stalemate"
|
| 973 |
+
elif board.is_insufficient_material():
|
| 974 |
+
draw_reason = "insufficient_material"
|
| 975 |
+
child_fen: str | None = None if (move_is_mate or draw_reason) else board.fen()
|
| 976 |
+
if child_fen is not None:
|
| 977 |
+
# Upstream probes positions, not repetition history. Preserve
|
| 978 |
+
# the clock, en passant and Chess960 state without the stack.
|
| 979 |
+
child_board = board.copy(stack=False)
|
| 980 |
+
finally:
|
| 981 |
+
board.pop()
|
| 982 |
+
move_info.append((san, move_is_mate, move_is_zeroing, move_is_conversion, draw_reason, child_fen, child_board))
|
| 983 |
+
return move_info
|
| 984 |
+
|
| 985 |
+
|
| 986 |
+
def evaluate_all_moves(
|
| 987 |
+
board: chess.Board,
|
| 988 |
+
root_wdl: int,
|
| 989 |
+
variant: str = "standard",
|
| 990 |
+
bypass_parallel: bool = False,
|
| 991 |
+
precomputed_move_info: list[tuple[str, bool, bool, bool, str | None, str | None, chess.Board | None]] | None = None,
|
| 992 |
+
deadline: float | None = None,
|
| 993 |
+
root_dtz: int | None = None,
|
| 994 |
+
root_dtz_rounded: bool = False,
|
| 995 |
+
) -> tuple[MoveList, MoveList, MoveList, MoveList, bool, dict[str, int], int | None]:
|
| 996 |
+
"""Returns the four ranked move lists (DTZ, DTC, DTM, DTM50, in that
|
| 997 |
+
order), a "complete" flag: False when at least one move shows "unknown"
|
| 998 |
+
for a retryable reason (probe timeout or error) rather than because its
|
| 999 |
+
table is genuinely absent (callers use it to decide whether the result
|
| 1000 |
+
is worth caching - see evaluate_fen()), the wins/draws/losses/unknown
|
| 1001 |
+
summary across all legal moves, and the root's exact DTZ when
|
| 1002 |
+
cross-checking the moves resolved it (None otherwise).
|
| 1003 |
+
|
| 1004 |
+
root_dtz/root_dtz_rounded are the root's own DTZ, None when its material
|
| 1005 |
+
has no DTZ table. The DTZ rows' rounded values are resolved against them
|
| 1006 |
+
by dtz_anchor.resolve().
|
| 1007 |
+
|
| 1008 |
+
variant applies to every child position probed below, matching the
|
| 1009 |
+
ruleset `board` itself was built under (see _build_board) -- board.push()/
|
| 1010 |
+
pop() inside _collect_move_info already preserves board.chess960 for
|
| 1011 |
+
move generation and child.fen() serialization for free, since it's the
|
| 1012 |
+
same Board object throughout; variant only needs to be threaded through
|
| 1013 |
+
explicitly here because child positions are (re-)probed from their FEN
|
| 1014 |
+
string, not from the Board object itself (see _probe_fen_with_deadline).
|
| 1015 |
+
|
| 1016 |
+
deadline is an absolute time.monotonic() value bounding child probing."""
|
| 1017 |
+
effective_deadline = deadline if deadline is not None else time.monotonic() + cfg.probe_timeout
|
| 1018 |
+
|
| 1019 |
+
# Phase 1: collect move metadata (no TB calls) - reuses the caller's own
|
| 1020 |
+
# pass over board.legal_moves() when one is supplied, instead of
|
| 1021 |
+
# re-walking every legal move a second time for the same board.
|
| 1022 |
+
move_info = precomputed_move_info if precomputed_move_info is not None else _collect_move_info(board)
|
| 1023 |
+
|
| 1024 |
+
# Phase 2: probe child positions
|
| 1025 |
+
unique_fens: set[str] = {child_fen for _, _, _, _, _, child_fen, _ in move_info if child_fen is not None}
|
| 1026 |
+
probe_cache: dict[str, chesstb.ProbeResult | None] = {}
|
| 1027 |
+
child_boards: dict[str, chess.Board] = {
|
| 1028 |
+
child_fen: child_board
|
| 1029 |
+
for _, _, _, _, _, child_fen, child_board in move_info
|
| 1030 |
+
if child_fen is not None and child_board is not None
|
| 1031 |
+
}
|
| 1032 |
+
|
| 1033 |
+
# Child FENs that failed for a retryable reason. A probe that timed out is
|
| 1034 |
+
# still running in the pool and will populate _probe_fen's own cache, so a
|
| 1035 |
+
# later attempt at this position resolves it; a probe that raised may
|
| 1036 |
+
# succeed on a retry too. Neither is the same as _probe_fen returning None,
|
| 1037 |
+
# which means tb_not_found and won't change however often it's re-probed.
|
| 1038 |
+
transient_failures: set[str] = set()
|
| 1039 |
+
|
| 1040 |
+
# Probe sufficiently large uncached batches through the thread pool.
|
| 1041 |
+
if len(unique_fens) >= cfg.parallel_threshold and not bypass_parallel:
|
| 1042 |
+
futures = {
|
| 1043 |
+
fen: _executor.submit(
|
| 1044 |
+
_probe_fen_with_deadline, variant, fen, effective_deadline, child_boards.get(fen)
|
| 1045 |
+
)
|
| 1046 |
+
for fen in unique_fens
|
| 1047 |
+
}
|
| 1048 |
+
remaining = max(0.0, effective_deadline - time.monotonic())
|
| 1049 |
+
done, _ = futures_wait(
|
| 1050 |
+
futures.values(), timeout=remaining, return_when=ALL_COMPLETED
|
| 1051 |
+
)
|
| 1052 |
+
for fen, fut in futures.items():
|
| 1053 |
+
if fut in done:
|
| 1054 |
+
try:
|
| 1055 |
+
probe_cache[fen] = fut.result()
|
| 1056 |
+
except Exception as exc:
|
| 1057 |
+
log.warning("Probe error for variant=%s %s: %s", variant, fen, exc)
|
| 1058 |
+
probe_cache[fen] = None
|
| 1059 |
+
transient_failures.add(fen)
|
| 1060 |
+
else:
|
| 1061 |
+
fut.cancel() # Drop queued work; running probes can still warm the cache.
|
| 1062 |
+
log.warning("Probe timed out for variant=%s %s", variant, fen)
|
| 1063 |
+
probe_cache[fen] = None
|
| 1064 |
+
transient_failures.add(fen)
|
| 1065 |
+
else:
|
| 1066 |
+
# Probe smaller batches, and batches whose children are already cached,
|
| 1067 |
+
# sequentially to avoid unnecessary thread-pool overhead.
|
| 1068 |
+
for fen in unique_fens:
|
| 1069 |
+
try:
|
| 1070 |
+
probe_cache[fen] = _probe_fen_with_deadline(
|
| 1071 |
+
variant, fen, effective_deadline, child_boards.get(fen)
|
| 1072 |
+
)
|
| 1073 |
+
except Exception as exc:
|
| 1074 |
+
log.warning("Probe error for variant=%s %s: %s", variant, fen, exc)
|
| 1075 |
+
probe_cache[fen] = None
|
| 1076 |
+
transient_failures.add(fen)
|
| 1077 |
+
|
| 1078 |
+
# Phase 3: assemble and sort
|
| 1079 |
+
dtz_rows: list[tuple] = []
|
| 1080 |
+
dtc_rows: list[tuple] = []
|
| 1081 |
+
dtm_rows: list[tuple] = []
|
| 1082 |
+
dtm50_rows: list[tuple] = []
|
| 1083 |
+
|
| 1084 |
+
# Summary outcomes respect each child's halfmove clock. DTM50 and DTC
|
| 1085 |
+
# already resolve the rule; clock-independent WDL cannot establish a
|
| 1086 |
+
# rule50 win or loss when both of those metrics are unavailable.
|
| 1087 |
+
move_outcomes: list[str] = []
|
| 1088 |
+
|
| 1089 |
+
# Sort key/entry pair for a move whose own metric table doesn't cover
|
| 1090 |
+
# it: flatly "Not Available", the same shape for DTZ, DTC, DTM, and
|
| 1091 |
+
# DTM50 alike rather than a value borrowed from another metric.
|
| 1092 |
+
# has_order adds DTC's extra pushes-owed slot to the sort key.
|
| 1093 |
+
def _not_available(
|
| 1094 |
+
san: str, child_fen: str | None, move_is_mate: bool,
|
| 1095 |
+
draw_reason: str | None, has_order: bool = False,
|
| 1096 |
+
) -> tuple[tuple, MoveEntry]:
|
| 1097 |
+
key = (
|
| 1098 |
+
(_outcome_rank(0) + 10, 0, 0, _move_tiebreak(san)) if has_order
|
| 1099 |
+
else (_outcome_rank(0) + 10, 0, _move_tiebreak(san))
|
| 1100 |
+
)
|
| 1101 |
+
entry: MoveEntry = {
|
| 1102 |
+
"san": san, "plies": 0, "order": 0, "is_mate": move_is_mate, "available": False,
|
| 1103 |
+
"outcome": "not_available", "child_fen": child_fen, "draw_reason": draw_reason,
|
| 1104 |
+
}
|
| 1105 |
+
return key, entry
|
| 1106 |
+
|
| 1107 |
+
def _dtz_row(
|
| 1108 |
+
san: str, my_wdl: int, eff_dtz: int, rounded: bool, move_is_mate: bool,
|
| 1109 |
+
child_fen: str | None, draw_reason: str | None,
|
| 1110 |
+
) -> tuple[tuple, DtzMoveEntry, int]:
|
| 1111 |
+
eff_wdl = _effective_move_wdl(my_wdl, root_wdl, eff_dtz)
|
| 1112 |
+
key = (_outcome_rank(eff_wdl), _ply_rank(eff_wdl, eff_dtz), _move_tiebreak(san))
|
| 1113 |
+
entry: DtzMoveEntry = {
|
| 1114 |
+
"san": san, "plies": abs(eff_dtz), "order": 0, "is_mate": move_is_mate, "available": True,
|
| 1115 |
+
"outcome": _outcome_label(eff_wdl), "child_fen": child_fen, "draw_reason": draw_reason,
|
| 1116 |
+
"rounded": rounded,
|
| 1117 |
+
}
|
| 1118 |
+
return key, entry, eff_wdl
|
| 1119 |
+
|
| 1120 |
+
dtz_moves: list[_DtzMove] = []
|
| 1121 |
+
dtz_complete = True
|
| 1122 |
+
|
| 1123 |
+
for san, move_is_mate, move_is_zeroing, move_is_conversion, draw_reason, child_fen, child_board in move_info:
|
| 1124 |
+
|
| 1125 |
+
if move_is_mate:
|
| 1126 |
+
opp_wdl = opp_dtc_wdl = opp_dtm50_wdl = -2
|
| 1127 |
+
opp_dtz = opp_dtc = opp_dtc_order = opp_dtm = opp_dtm50 = 0
|
| 1128 |
+
opp_has_dtz = opp_has_dtc = opp_has_dtm = opp_has_dtm50 = True
|
| 1129 |
+
opp_dtz_rounded = False
|
| 1130 |
+
|
| 1131 |
+
elif draw_reason:
|
| 1132 |
+
opp_wdl = opp_dtc_wdl = opp_dtm50_wdl = 0
|
| 1133 |
+
opp_dtz = opp_dtc = opp_dtc_order = opp_dtm = opp_dtm50 = 0
|
| 1134 |
+
opp_has_dtz = opp_has_dtc = opp_has_dtm = opp_has_dtm50 = True
|
| 1135 |
+
opp_dtz_rounded = False
|
| 1136 |
+
|
| 1137 |
+
else:
|
| 1138 |
+
probe_result = probe_cache.get(child_fen)
|
| 1139 |
+
if probe_result is None:
|
| 1140 |
+
log.warning("No probe result for %s (%s); showing unknown.", child_fen, san)
|
| 1141 |
+
stub: MoveEntry = {
|
| 1142 |
+
"san": san, "plies": 0, "order": 0, "is_mate": False, "available": False,
|
| 1143 |
+
"outcome": "unknown", "child_fen": child_fen, "draw_reason": None,
|
| 1144 |
+
}
|
| 1145 |
+
stub_key = (_outcome_rank(0) + 10, 0, _move_tiebreak(san))
|
| 1146 |
+
dtc_stub_key = (_outcome_rank(0) + 10, 0, 0, _move_tiebreak(san))
|
| 1147 |
+
dtz_rows.append((stub_key, stub))
|
| 1148 |
+
dtc_rows.append((dtc_stub_key, stub))
|
| 1149 |
+
dtm_rows.append((stub_key, stub))
|
| 1150 |
+
dtm50_rows.append((stub_key, stub))
|
| 1151 |
+
move_outcomes.append("unknown")
|
| 1152 |
+
dtz_complete = False
|
| 1153 |
+
continue
|
| 1154 |
+
opp_wdl = _signed_wdl(probe_result.wdl)
|
| 1155 |
+
opp_has_dtz, opp_dtz = probe_result.has_dtz, probe_result.dtz
|
| 1156 |
+
opp_dtz_rounded = probe_result.dtz_rounded
|
| 1157 |
+
opp_has_dtc = probe_result.has_dtc
|
| 1158 |
+
opp_dtc_wdl = _signed_wdl(probe_result.dtc_wdl)
|
| 1159 |
+
opp_dtc_order, opp_dtc = probe_result.dtc_order, probe_result.dtc
|
| 1160 |
+
opp_has_dtm, opp_dtm = probe_result.has_dtm, probe_result.dtm
|
| 1161 |
+
opp_has_dtm50 = probe_result.has_dtm50
|
| 1162 |
+
opp_dtm50_wdl = _signed_wdl(probe_result.dtm50_wdl)
|
| 1163 |
+
opp_dtm50 = probe_result.dtm50
|
| 1164 |
+
|
| 1165 |
+
my_wdl = -opp_wdl
|
| 1166 |
+
if opp_has_dtm50:
|
| 1167 |
+
summary_wdl = -opp_dtm50_wdl
|
| 1168 |
+
elif opp_has_dtc:
|
| 1169 |
+
summary_wdl = -opp_dtc_wdl
|
| 1170 |
+
else:
|
| 1171 |
+
# Draws, cursed wins and blessed losses are draws under the
|
| 1172 |
+
# rule even without a clock-aware table. True wins/losses
|
| 1173 |
+
# need one of those tables to resolve the remaining budget.
|
| 1174 |
+
summary_wdl = 0 if abs(my_wdl) <= 1 else None
|
| 1175 |
+
move_outcomes.append(
|
| 1176 |
+
"win" if summary_wdl == 2 else
|
| 1177 |
+
"loss" if summary_wdl == -2 else
|
| 1178 |
+
"unknown" if summary_wdl is None else "draw"
|
| 1179 |
+
)
|
| 1180 |
+
|
| 1181 |
+
# DTZ row.
|
| 1182 |
+
if opp_has_dtz:
|
| 1183 |
+
my_dtz = _apply_sign(opp_dtz, my_wdl)
|
| 1184 |
+
# If this move is itself a zeroing move (capture/pawn-push/
|
| 1185 |
+
# promotion), it is the zeroing move, so its DTZ is 1 ply - not
|
| 1186 |
+
# 1 + the child position's own distance to its next
|
| 1187 |
+
# zeroing move, which is what feeding my_dtz straight into
|
| 1188 |
+
# _effective_distance would compute (double-counting a second
|
| 1189 |
+
# zeroing event nobody asked for). _effective_distance
|
| 1190 |
+
# already has a branch for exactly this ("no further distance
|
| 1191 |
+
# needed, the move itself resolves it"), so call it with
|
| 1192 |
+
# raw_distance=0 instead of reimplementing that branch here.
|
| 1193 |
+
cursed_exit = (not move_is_zeroing and child_board is not None
|
| 1194 |
+
and _is_cursed_exit(board, child_board, opp_wdl))
|
| 1195 |
+
option = dtz_anchor.move_option(opp_dtz, opp_dtz_rounded, move_is_zeroing, cursed_exit)
|
| 1196 |
+
if move_is_zeroing:
|
| 1197 |
+
eff_dtz = _effective_distance(my_wdl, 0)
|
| 1198 |
+
elif cursed_exit:
|
| 1199 |
+
# CORRECTNESS.md Theorem 12.15: a rounded derived child
|
| 1200 |
+
# can be even. Its true exit values then span y+1..y+3;
|
| 1201 |
+
# report y+2 so the displayed error stays within one ply.
|
| 1202 |
+
# option.value still carries the possible true distances
|
| 1203 |
+
# for the anchor, rather than this representative value.
|
| 1204 |
+
exit_dtz = opp_dtz + 2 if opp_dtz_rounded else option.value[0]
|
| 1205 |
+
eff_dtz = _apply_sign(exit_dtz, my_wdl)
|
| 1206 |
+
else:
|
| 1207 |
+
eff_dtz = _effective_distance(my_wdl, my_dtz)
|
| 1208 |
+
dtz_key, dtz_entry, eff_wdl = _dtz_row(
|
| 1209 |
+
san, my_wdl, eff_dtz, len(set(option.value)) > 1, move_is_mate, child_fen, draw_reason,
|
| 1210 |
+
)
|
| 1211 |
+
outcome = dtz_entry["outcome"]
|
| 1212 |
+
dtz_rows.append((dtz_key, dtz_entry))
|
| 1213 |
+
dtz_moves.append(_DtzMove(len(dtz_rows) - 1, my_wdl, eff_wdl == root_wdl, option))
|
| 1214 |
+
else:
|
| 1215 |
+
dtz_rows.append(_not_available(san, child_fen, move_is_mate, draw_reason))
|
| 1216 |
+
dtz_complete = False
|
| 1217 |
+
|
| 1218 |
+
# DTC row. DTC's own notion of a better move is pushes-owed first, then
|
| 1219 |
+
# plies (see _order_rank's docstring), so the sort key carries both,
|
| 1220 |
+
# unlike DTZ/DTM/DTM50 above and below which only ever compare
|
| 1221 |
+
# plies.
|
| 1222 |
+
#
|
| 1223 |
+
# A capture or a promotion (move_is_conversion) is the conversion,
|
| 1224 |
+
# at this move: 0 pushes still owed, 1 ply to get there - the
|
| 1225 |
+
# child's own pushes/plies describe its next, later conversion,
|
| 1226 |
+
# which is irrelevant once this one has already happened.
|
| 1227 |
+
#
|
| 1228 |
+
# A quiet pawn push zeroes the clock too (move_is_zeroing) but is
|
| 1229 |
+
# not a conversion - chess.chesstb.Tablebase._dtc_move_kind treats
|
| 1230 |
+
# it as spending one of the pushes still owed rather than settling
|
| 1231 |
+
# the count, so the wait still resets to 1 ply the same way a
|
| 1232 |
+
# conversion's does, but pushes owed does not reset to 0.
|
| 1233 |
+
#
|
| 1234 |
+
# The child is probed at its own halfmove clock (see _probe_board)
|
| 1235 |
+
# and its dtc_order counts pushes owed from that position onward,
|
| 1236 |
+
# so it does not include the push spent getting there. For a root
|
| 1237 |
+
# reporting dtc_order=N whose best move is a push, the child
|
| 1238 |
+
# reports dtc_order=N-1; the +1 below restores the push this move
|
| 1239 |
+
# spent.
|
| 1240 |
+
#
|
| 1241 |
+
# Whether the +1 applies depends on which side is pushing: dtc_order
|
| 1242 |
+
# counts the pushes still owed by whichever side is winning, and
|
| 1243 |
+
# only the WINNING side's own push spends one of them. A defensive
|
| 1244 |
+
# push by the losing side leaves that budget untouched, so it
|
| 1245 |
+
# carries the child's order over unchanged, same as a quiet move;
|
| 1246 |
+
# hence the my_dtc_wdl > 0 guard below.
|
| 1247 |
+
if opp_has_dtc:
|
| 1248 |
+
my_dtc_wdl = -opp_dtc_wdl
|
| 1249 |
+
if move_is_mate or draw_reason or move_is_conversion:
|
| 1250 |
+
# A capture, a promotion, checkmate, or an immediate draw
|
| 1251 |
+
# are all fully resolved right here: 0 pushes still owed,
|
| 1252 |
+
# 1 ply to get there. move_is_mate/draw_reason already
|
| 1253 |
+
# hardwire opp_dtc_order/opp_dtc to 0 above, so this is a
|
| 1254 |
+
# no-op arithmetically for those in the ordinary case - it
|
| 1255 |
+
# matters for the case where the move is also a quiet
|
| 1256 |
+
# pawn push, e.g. checkmate delivered by discovered check
|
| 1257 |
+
# from a pawn advance that neither captures nor promotes.
|
| 1258 |
+
# Without this guard that would fall into the push branch
|
| 1259 |
+
# below and pick up a spurious +1: mate ends the game, so
|
| 1260 |
+
# nothing is owed no matter how the pawn got there.
|
| 1261 |
+
raw_order = 0
|
| 1262 |
+
eff_dtc = _effective_distance(my_dtc_wdl, 0)
|
| 1263 |
+
elif move_is_zeroing: # a quiet pawn push, not a conversion
|
| 1264 |
+
raw_order = opp_dtc_order + (1 if my_dtc_wdl > 0 else 0)
|
| 1265 |
+
eff_dtc = _effective_distance(my_dtc_wdl, 0)
|
| 1266 |
+
else:
|
| 1267 |
+
raw_order = opp_dtc_order
|
| 1268 |
+
my_dtc = _apply_sign(opp_dtc, my_dtc_wdl)
|
| 1269 |
+
eff_dtc = _effective_distance(my_dtc_wdl, my_dtc)
|
| 1270 |
+
# A draw has nothing owed regardless of the raw push count
|
| 1271 |
+
# above - e.g. a defensive push into a dead draw (stalemate,
|
| 1272 |
+
# insufficient material, or a budget that no longer fits the
|
| 1273 |
+
# clock) must not report a nonzero push count just because it
|
| 1274 |
+
# happened to be a push; _order_rank sorts it correctly either
|
| 1275 |
+
# way (it treats wdl_val == 0 as rank 0 regardless), but the
|
| 1276 |
+
# value shown to the client should read 0 like plies does.
|
| 1277 |
+
eff_order = raw_order if my_dtc_wdl != 0 else 0
|
| 1278 |
+
dtc_key = (
|
| 1279 |
+
_outcome_rank(my_dtc_wdl),
|
| 1280 |
+
_order_rank(my_dtc_wdl, eff_order),
|
| 1281 |
+
_ply_rank(my_dtc_wdl, eff_dtc),
|
| 1282 |
+
_move_tiebreak(san),
|
| 1283 |
+
)
|
| 1284 |
+
dtc_rows.append((dtc_key, {
|
| 1285 |
+
"san": san, "plies": abs(eff_dtc), "order": eff_order, "is_mate": move_is_mate,
|
| 1286 |
+
"available": True, "outcome": _outcome_label(my_dtc_wdl),
|
| 1287 |
+
"child_fen": child_fen, "draw_reason": draw_reason,
|
| 1288 |
+
}))
|
| 1289 |
+
else:
|
| 1290 |
+
dtc_rows.append(_not_available(san, child_fen, move_is_mate, draw_reason, has_order=True))
|
| 1291 |
+
|
| 1292 |
+
# DTM row.
|
| 1293 |
+
if opp_has_dtm:
|
| 1294 |
+
my_dtm = _apply_sign(opp_dtm, my_wdl)
|
| 1295 |
+
eff_dtm = _effective_distance(my_wdl, my_dtm)
|
| 1296 |
+
if opp_has_dtz:
|
| 1297 |
+
# DTZ and DTM share the same win/cursed-win (loss/blessed-
|
| 1298 |
+
# loss) bucket and label as the DTZ row above, since that
|
| 1299 |
+
# status is a single fact about eff_dtz and root_wdl rather
|
| 1300 |
+
# than a separate one per metric.
|
| 1301 |
+
dtm_key = (_outcome_rank(eff_wdl), _ply_rank(eff_wdl, eff_dtm), _move_tiebreak(san))
|
| 1302 |
+
dtm_outcome = outcome
|
| 1303 |
+
else:
|
| 1304 |
+
# Without DTZ the bucket comes from WDL alone.
|
| 1305 |
+
dtm_eff_wdl = _capped_wdl(my_wdl, root_wdl)
|
| 1306 |
+
dtm_key = (_outcome_rank(dtm_eff_wdl), _ply_rank(dtm_eff_wdl, eff_dtm), _move_tiebreak(san))
|
| 1307 |
+
dtm_outcome = _outcome_label(dtm_eff_wdl)
|
| 1308 |
+
dtm_rows.append((dtm_key, {
|
| 1309 |
+
"san": san, "plies": abs(eff_dtm), "order": 0, "is_mate": move_is_mate, "available": True,
|
| 1310 |
+
"outcome": dtm_outcome, "child_fen": child_fen, "draw_reason": draw_reason,
|
| 1311 |
+
}))
|
| 1312 |
+
else:
|
| 1313 |
+
dtm_rows.append(_not_available(san, child_fen, move_is_mate, draw_reason))
|
| 1314 |
+
|
| 1315 |
+
# DTM50 row.
|
| 1316 |
+
if opp_has_dtm50:
|
| 1317 |
+
my_dtm50_wdl = -opp_dtm50_wdl
|
| 1318 |
+
my_dtm50 = _apply_sign(opp_dtm50, my_dtm50_wdl)
|
| 1319 |
+
eff_dtm50 = _effective_distance(my_dtm50_wdl, my_dtm50)
|
| 1320 |
+
dtm50_key = (_outcome_rank(my_dtm50_wdl), _ply_rank(my_dtm50_wdl, eff_dtm50), _move_tiebreak(san))
|
| 1321 |
+
dtm50_rows.append((dtm50_key, {
|
| 1322 |
+
"san": san, "plies": abs(eff_dtm50), "order": 0, "is_mate": move_is_mate, "available": True,
|
| 1323 |
+
"outcome": _outcome_label(my_dtm50_wdl), "child_fen": child_fen, "draw_reason": draw_reason,
|
| 1324 |
+
}))
|
| 1325 |
+
else:
|
| 1326 |
+
dtm50_rows.append(_not_available(san, child_fen, move_is_mate, draw_reason))
|
| 1327 |
+
|
| 1328 |
+
# Cross-check rounded DTZ values against each other; skipped when any move
|
| 1329 |
+
# lacks a DTZ, since that move could be the one supplying the root's value.
|
| 1330 |
+
resolved_dtz: int | None = None
|
| 1331 |
+
if dtz_complete and root_dtz is not None and root_wdl != 0:
|
| 1332 |
+
keepers = [m for m in dtz_moves if m.keeps_class]
|
| 1333 |
+
if root_dtz_rounded or any(len(set(m.option.value)) > 1 for m in keepers):
|
| 1334 |
+
resolution = dtz_anchor.resolve(
|
| 1335 |
+
root_dtz, root_dtz_rounded, [m.option for m in keepers], root_wdl > 0,
|
| 1336 |
+
)
|
| 1337 |
+
resolved_dtz = resolution.root
|
| 1338 |
+
for k, value in resolution.pinned.items():
|
| 1339 |
+
move = keepers[k]
|
| 1340 |
+
row = dtz_rows[move.row][1]
|
| 1341 |
+
dtz_rows[move.row] = _dtz_row(
|
| 1342 |
+
row["san"], move.my_wdl, _apply_sign(value, move.my_wdl), False,
|
| 1343 |
+
row["is_mate"], row["child_fen"], row["draw_reason"],
|
| 1344 |
+
)[:2]
|
| 1345 |
+
|
| 1346 |
+
dtz_rows.sort(key=lambda r: r[0])
|
| 1347 |
+
dtc_rows.sort(key=lambda r: r[0])
|
| 1348 |
+
dtm_rows.sort(key=lambda r: r[0])
|
| 1349 |
+
dtm50_rows.sort(key=lambda r: r[0])
|
| 1350 |
+
|
| 1351 |
+
summary = {
|
| 1352 |
+
"wins": sum(1 for o in move_outcomes if o in ("win", "cursed_win")),
|
| 1353 |
+
"draws": sum(1 for o in move_outcomes if o == "draw"),
|
| 1354 |
+
"losses": sum(1 for o in move_outcomes if o in ("loss", "blessed_loss")),
|
| 1355 |
+
"unknown": sum(1 for o in move_outcomes if o == "unknown"),
|
| 1356 |
+
}
|
| 1357 |
+
|
| 1358 |
+
return (
|
| 1359 |
+
[r[1] for r in dtz_rows],
|
| 1360 |
+
[r[1] for r in dtc_rows],
|
| 1361 |
+
[r[1] for r in dtm_rows],
|
| 1362 |
+
[r[1] for r in dtm50_rows],
|
| 1363 |
+
not transient_failures,
|
| 1364 |
+
summary,
|
| 1365 |
+
resolved_dtz,
|
| 1366 |
+
)
|
| 1367 |
+
|
| 1368 |
+
|
| 1369 |
+
# ── Root Probe Calculation ────────────────────────────────────────────────────
|
| 1370 |
+
|
| 1371 |
+
_evaluate_tls = threading.local()
|
| 1372 |
+
|
| 1373 |
+
|
| 1374 |
+
def _validate_and_build_board(variant: str, fen: str) -> chess.Board:
|
| 1375 |
+
"""
|
| 1376 |
+
Single authoritative implementation of root-FEN validation, shared by
|
| 1377 |
+
/probe (via evaluate_fen) and /probe/stream (which needs a validated
|
| 1378 |
+
board up front, before it can start emitting SSE progress events).
|
| 1379 |
+
Raises RuntimeError if the tablebase isn't loaded, or ValueError for any
|
| 1380 |
+
FEN/board problem - callers are expected to catch and format those.
|
| 1381 |
+
|
| 1382 |
+
Legality (castling rights matching an actual king/rook pair, no pawns
|
| 1383 |
+
on the back rank, exactly one king per side, etc.) is entirely
|
| 1384 |
+
delegated to board.is_valid() for both variants -- this function adds
|
| 1385 |
+
no Standard-only assumptions of its own (e.g. requiring castling rooks
|
| 1386 |
+
on a1/h1/a8/h8) that would misclassify a legal Chess960 position as
|
| 1387 |
+
invalid. See _build_board for how the variant affects construction.
|
| 1388 |
+
"""
|
| 1389 |
+
if TB is None:
|
| 1390 |
+
raise RuntimeError(
|
| 1391 |
+
"Tablebase not initialised. Check TABLEBASE_PATH.")
|
| 1392 |
+
|
| 1393 |
+
parts = fen.split()
|
| 1394 |
+
fmt_error = _validate_fen_parts(variant, parts)
|
| 1395 |
+
if fmt_error:
|
| 1396 |
+
raise ValueError(fmt_error)
|
| 1397 |
+
|
| 1398 |
+
try:
|
| 1399 |
+
board = _build_board(variant, fen)
|
| 1400 |
+
except ValueError as e:
|
| 1401 |
+
raise ValueError(f"Malformed FEN: {e}") from e
|
| 1402 |
+
|
| 1403 |
+
castling_field = parts[2] if len(parts) >= 3 else "-"
|
| 1404 |
+
if variant == "chess960" and castling_field != "-":
|
| 1405 |
+
if board.castling_rights.bit_count() != len(castling_field):
|
| 1406 |
+
raise ValueError("Invalid castling availability: duplicate castling rights.")
|
| 1407 |
+
|
| 1408 |
+
if not board.is_valid():
|
| 1409 |
+
raise ValueError(
|
| 1410 |
+
"Invalid board position (missing kings, adjacent kings, pawn on "
|
| 1411 |
+
"back rank, a castling right with no matching king/rook pair, etc.)."
|
| 1412 |
+
)
|
| 1413 |
+
|
| 1414 |
+
return board
|
| 1415 |
+
|
| 1416 |
+
|
| 1417 |
+
def _missing_table_error_payload(variant: str, fen: str) -> dict:
|
| 1418 |
+
"""
|
| 1419 |
+
Single authoritative shape for the "position not covered by the loaded
|
| 1420 |
+
tablebase" error, shared by /probe and /probe/stream so the two
|
| 1421 |
+
transports (plain JSON vs SSE) can't drift out of sync on wording,
|
| 1422 |
+
error_code, or how piece_count is computed.
|
| 1423 |
+
"""
|
| 1424 |
+
try:
|
| 1425 |
+
piece_count = len(_build_board(variant, fen).piece_map())
|
| 1426 |
+
except Exception:
|
| 1427 |
+
piece_count = None
|
| 1428 |
+
return {
|
| 1429 |
+
"error": "Position not covered by the loaded tablebase.",
|
| 1430 |
+
"error_code": "missing_table",
|
| 1431 |
+
"piece_count": piece_count,
|
| 1432 |
+
}
|
| 1433 |
+
|
| 1434 |
+
|
| 1435 |
+
class _IncompleteResult(Exception):
|
| 1436 |
+
"""Carries a probe result that must not be cached - see evaluate_fen()."""
|
| 1437 |
+
|
| 1438 |
+
def __init__(self, json_str: str) -> None:
|
| 1439 |
+
super().__init__("probe incomplete")
|
| 1440 |
+
self.json_str = json_str
|
| 1441 |
+
|
| 1442 |
+
|
| 1443 |
+
def _evaluate_fen_impl(variant: str, fen: str) -> str:
|
| 1444 |
+
"""
|
| 1445 |
+
Probes the tablebase for a root FEN and returns a pre-serialized JSON string.
|
| 1446 |
+
Routes the root probe through the same shared _probe_fen cache used for
|
| 1447 |
+
child positions, so a position that was just probed as a child (e.g. the
|
| 1448 |
+
position the user just moved into) is served from cache instead of
|
| 1449 |
+
re-hitting the tablebase.
|
| 1450 |
+
|
| 1451 |
+
(variant, fen) are the cache key, so a caller that already built a
|
| 1452 |
+
board/move_info for this exact pair -- e.g. probe_stream()'s SSE handler,
|
| 1453 |
+
which needs both up front to size its progress bar -- hands them over via _evaluate_tls
|
| 1454 |
+
instead, letting this call skip redoing that work. Ignored on a cache
|
| 1455 |
+
hit, since the function body below never runs in that case.
|
| 1456 |
+
|
| 1457 |
+
Raises _IncompleteResult (carrying the same JSON) when a move came back
|
| 1458 |
+
unknown for a retryable reason, so evaluate_fen() can serve it without
|
| 1459 |
+
caching it.
|
| 1460 |
+
"""
|
| 1461 |
+
precomputed_board = getattr(_evaluate_tls, "board", None)
|
| 1462 |
+
precomputed_move_info = getattr(_evaluate_tls, "move_info", None)
|
| 1463 |
+
board = precomputed_board if precomputed_board is not None else _validate_and_build_board(variant, fen)
|
| 1464 |
+
|
| 1465 |
+
# A fresh cfg.probe_timeout-sized budget for the root probe alone,
|
| 1466 |
+
# independent of how long any earlier phase of the same request (e.g.
|
| 1467 |
+
# probe_stream()'s own pre-warm step) already took, so a slow earlier
|
| 1468 |
+
# phase cannot erode the time this one gets. The child batch below
|
| 1469 |
+
# carries its own equally-sized fresh budget (see child_deadline),
|
| 1470 |
+
# matching the independent-budget-per-phase pattern probe_stream()
|
| 1471 |
+
# applies between its pre-warm step and the root probe above it. The
|
| 1472 |
+
# two independently-budgeted phases put a single /probe request's
|
| 1473 |
+
# worst case at up to 2 * cfg.probe_timeout.
|
| 1474 |
+
root_deadline = time.monotonic() + cfg.probe_timeout
|
| 1475 |
+
|
| 1476 |
+
if board.is_checkmate():
|
| 1477 |
+
return json.dumps({
|
| 1478 |
+
"wdl": -2, "dtz": 0, "dtz_rounded": False, "dtc": [-2, 0, 0], "dtm": 0, "dtm50": [-2, 0],
|
| 1479 |
+
"dtz_available": True, "dtc_available": True, "dtm_available": True, "dtm50_available": True,
|
| 1480 |
+
"moves_dtz": [], "moves_dtc": [], "moves_dtm": [], "moves_dtm50": [],
|
| 1481 |
+
"summary": {"wins": 0, "draws": 0, "losses": 0, "unknown": 0},
|
| 1482 |
+
"draw_reason": None,
|
| 1483 |
+
})
|
| 1484 |
+
|
| 1485 |
+
if board.is_stalemate() or board.is_insufficient_material():
|
| 1486 |
+
return json.dumps({
|
| 1487 |
+
"wdl": 0, "dtz": 0, "dtz_rounded": False, "dtc": [0, 0, 0], "dtm": 0, "dtm50": [0, 0],
|
| 1488 |
+
"dtz_available": True, "dtc_available": True, "dtm_available": True, "dtm50_available": True,
|
| 1489 |
+
"moves_dtz": [], "moves_dtc": [], "moves_dtm": [], "moves_dtm50": [],
|
| 1490 |
+
"summary": {"wins": 0, "draws": 0, "losses": 0, "unknown": 0},
|
| 1491 |
+
"draw_reason": "stalemate" if board.is_stalemate() else "insufficient_material",
|
| 1492 |
+
})
|
| 1493 |
+
|
| 1494 |
+
# Route the root probe through the shared child-probe cache. board was
|
| 1495 |
+
# either handed over via _evaluate_tls or just built above from this
|
| 1496 |
+
# exact fen, so it's passed through here too rather than making
|
| 1497 |
+
# _probe_fen_deduped re-parse the same string a second time.
|
| 1498 |
+
root = _probe_fen_with_deadline(variant, fen, root_deadline, board)
|
| 1499 |
+
if root is None:
|
| 1500 |
+
raise chesstb.MissingTableError(f"Position not in tablebase: variant={variant} {fen}")
|
| 1501 |
+
|
| 1502 |
+
bypass_parallel = getattr(_evaluate_tls, "bypass_parallel", False)
|
| 1503 |
+
# Own fresh budget, not the remainder of root_deadline -- see the
|
| 1504 |
+
# comment above root_deadline.
|
| 1505 |
+
child_deadline = time.monotonic() + cfg.probe_timeout
|
| 1506 |
+
moves_dtz, moves_dtc, moves_dtm, moves_dtm50, complete, summary, resolved_dtz = evaluate_all_moves(
|
| 1507 |
+
board, _signed_wdl(root.wdl), variant, bypass_parallel=bypass_parallel,
|
| 1508 |
+
precomputed_move_info=precomputed_move_info, deadline=child_deadline,
|
| 1509 |
+
root_dtz=root.dtz if root.has_dtz else None, root_dtz_rounded=root.dtz_rounded,
|
| 1510 |
+
)
|
| 1511 |
+
root_dtz, root_dtz_rounded = (
|
| 1512 |
+
(root.dtz, root.dtz_rounded) if resolved_dtz is None else (resolved_dtz, False)
|
| 1513 |
+
)
|
| 1514 |
+
|
| 1515 |
+
# dtz/dtc/dtm/dtm50 are the root's own metrics, each independently
|
| 1516 |
+
# unavailable when this material has no DTZ/DTC/DTM/DTM50 table - see
|
| 1517 |
+
# _probe_board. The paired *_available flag is the authoritative signal
|
| 1518 |
+
# for that; the value fields fall back to null/[null, null(, null)]
|
| 1519 |
+
# rather than being omitted, so every response has the same fixed shape
|
| 1520 |
+
# regardless of coverage. dtc is a 3-tuple [wdl, pushes_owed, plies]
|
| 1521 |
+
# rather than dtm50's pair, since - unlike dtm50 - its distance is
|
| 1522 |
+
# priced in two components, not one; see chess.chesstb.ProbeResult and
|
| 1523 |
+
# the DTC row comment in evaluate_all_moves. root is the raw
|
| 1524 |
+
# ProbeResult _probe_board returns, so its wdl/dtc_wdl/dtm50_wdl are
|
| 1525 |
+
# still the unsigned chess.chesstb class values here - each goes
|
| 1526 |
+
# through _signed_wdl() below, same as every other consumer of a raw
|
| 1527 |
+
# ProbeResult in this file.
|
| 1528 |
+
json_str = json.dumps({
|
| 1529 |
+
"wdl": _signed_wdl(root.wdl),
|
| 1530 |
+
"dtz": root_dtz if root.has_dtz else None,
|
| 1531 |
+
"dtz_available": root.has_dtz,
|
| 1532 |
+
"dtz_rounded": root_dtz_rounded if root.has_dtz else None,
|
| 1533 |
+
"dtc": [_signed_wdl(root.dtc_wdl), root.dtc_order, root.dtc] if root.has_dtc else [None, None, None],
|
| 1534 |
+
"dtc_available": root.has_dtc,
|
| 1535 |
+
"dtm": root.dtm if root.has_dtm else None,
|
| 1536 |
+
"dtm_available": root.has_dtm,
|
| 1537 |
+
"dtm50": [_signed_wdl(root.dtm50_wdl), root.dtm50] if root.has_dtm50 else [None, None],
|
| 1538 |
+
"dtm50_available": root.has_dtm50,
|
| 1539 |
+
"moves_dtz": moves_dtz,
|
| 1540 |
+
"moves_dtc": moves_dtc,
|
| 1541 |
+
"moves_dtm": moves_dtm,
|
| 1542 |
+
"moves_dtm50": moves_dtm50,
|
| 1543 |
+
"summary": summary,
|
| 1544 |
+
"draw_reason": None, # root always has legal moves here, so never terminal
|
| 1545 |
+
})
|
| 1546 |
+
if not complete:
|
| 1547 |
+
raise _IncompleteResult(json_str)
|
| 1548 |
+
return json_str
|
| 1549 |
+
|
| 1550 |
+
|
| 1551 |
+
# An explicit LRU lets SSE check for a complete response before building
|
| 1552 |
+
# boards, walking legal moves or submitting child probes. Failed/incomplete
|
| 1553 |
+
# evaluations never enter this cache. Computation stays outside the lock.
|
| 1554 |
+
_evaluation_cache: OrderedDict[tuple[str, str], str] = OrderedDict()
|
| 1555 |
+
_evaluation_cache_lock = threading.Lock()
|
| 1556 |
+
_evaluation_hits = _evaluation_misses = 0
|
| 1557 |
+
_EvaluationCacheInfo = namedtuple("CacheInfo", "hits misses maxsize currsize")
|
| 1558 |
+
|
| 1559 |
+
|
| 1560 |
+
def _cached_evaluation(variant: str, fen: str, *, count_miss: bool = True) -> str | None:
|
| 1561 |
+
global _evaluation_hits, _evaluation_misses
|
| 1562 |
+
with _evaluation_cache_lock:
|
| 1563 |
+
key = (variant, fen)
|
| 1564 |
+
result = _evaluation_cache.get(key)
|
| 1565 |
+
if result is not None:
|
| 1566 |
+
_evaluation_cache.move_to_end(key)
|
| 1567 |
+
_evaluation_hits += 1
|
| 1568 |
+
elif count_miss:
|
| 1569 |
+
_evaluation_misses += 1
|
| 1570 |
+
return result
|
| 1571 |
+
|
| 1572 |
+
|
| 1573 |
+
def evaluate_fen(variant: str, fen: str) -> str:
|
| 1574 |
+
cached = _cached_evaluation(variant, fen)
|
| 1575 |
+
if cached is not None:
|
| 1576 |
+
return cached
|
| 1577 |
+
try:
|
| 1578 |
+
result = _evaluate_fen_impl(variant, fen)
|
| 1579 |
+
except _IncompleteResult as incomplete:
|
| 1580 |
+
return incomplete.json_str
|
| 1581 |
+
with _evaluation_cache_lock:
|
| 1582 |
+
key = (variant, fen)
|
| 1583 |
+
_evaluation_cache[key] = result
|
| 1584 |
+
_evaluation_cache.move_to_end(key)
|
| 1585 |
+
while len(_evaluation_cache) > cfg.evaluate_cache_size:
|
| 1586 |
+
_evaluation_cache.popitem(last=False)
|
| 1587 |
+
return result
|
| 1588 |
+
|
| 1589 |
+
|
| 1590 |
+
def _clear_evaluation_cache() -> None:
|
| 1591 |
+
global _evaluation_hits, _evaluation_misses
|
| 1592 |
+
with _evaluation_cache_lock:
|
| 1593 |
+
_evaluation_cache.clear()
|
| 1594 |
+
_evaluation_hits = _evaluation_misses = 0
|
| 1595 |
+
|
| 1596 |
+
|
| 1597 |
+
def _evaluation_cache_info():
|
| 1598 |
+
with _evaluation_cache_lock:
|
| 1599 |
+
return _EvaluationCacheInfo(
|
| 1600 |
+
_evaluation_hits, _evaluation_misses, cfg.evaluate_cache_size, len(_evaluation_cache),
|
| 1601 |
+
)
|
| 1602 |
+
|
| 1603 |
+
|
| 1604 |
+
evaluate_fen.cache_clear = _clear_evaluation_cache # type: ignore[attr-defined]
|
| 1605 |
+
evaluate_fen.cache_info = _evaluation_cache_info # type: ignore[attr-defined]
|
| 1606 |
+
|
| 1607 |
+
|
| 1608 |
+
# ── Common (variant, FEN) extraction + validation helper ──────────────────────
|
| 1609 |
+
|
| 1610 |
+
def _extract_probe_request(data: dict | None) -> tuple[str, str, Response | tuple[Response, int] | None]:
|
| 1611 |
+
"""Single authoritative request-body parser shared by /probe and
|
| 1612 |
+
/probe/stream, so the two transports can't drift on what counts as a
|
| 1613 |
+
valid variant/FEN or how each is rejected. Returns (variant, fen,
|
| 1614 |
+
error_response); error_response is None on success and variant/fen are
|
| 1615 |
+
both "" when it isn't."""
|
| 1616 |
+
if not data or "fen" not in data:
|
| 1617 |
+
return "", "", (jsonify({"error": "Missing FEN in request body."}), 400)
|
| 1618 |
+
fen_raw = data["fen"]
|
| 1619 |
+
if not isinstance(fen_raw, str):
|
| 1620 |
+
return "", "", (jsonify({"error": "FEN must be a string."}), 400)
|
| 1621 |
+
fen = fen_raw.strip()
|
| 1622 |
+
if not fen:
|
| 1623 |
+
return "", "", (jsonify({"error": "FEN string is empty."}), 400)
|
| 1624 |
+
if len(fen) > cfg.max_fen_length:
|
| 1625 |
+
return "", "", (jsonify({"error": f"FEN string too long (max {cfg.max_fen_length})."}), 400)
|
| 1626 |
+
|
| 1627 |
+
try:
|
| 1628 |
+
variant = _normalize_variant(data.get("variant"))
|
| 1629 |
+
except ValueError as e:
|
| 1630 |
+
return "", "", (jsonify({"error": str(e)}), 400)
|
| 1631 |
+
|
| 1632 |
+
return variant, _normalize_fen(fen), None
|
| 1633 |
+
|
| 1634 |
+
|
| 1635 |
+
# ── Routes ────────────────────────────────────────────────────────────────────
|
| 1636 |
+
|
| 1637 |
+
@app.route("/")
|
| 1638 |
+
def index() -> str:
|
| 1639 |
+
return render_template("index.html", source_repository_url=cfg.source_repository_url)
|
| 1640 |
+
|
| 1641 |
+
|
| 1642 |
+
@app.route("/probe", methods=["POST"])
|
| 1643 |
+
@_probe_rate_limit
|
| 1644 |
+
def probe() -> Response | tuple[Response, int]:
|
| 1645 |
+
body = request.get_json(silent=True)
|
| 1646 |
+
variant, fen, err = _extract_probe_request(body)
|
| 1647 |
+
if err:
|
| 1648 |
+
return err
|
| 1649 |
+
|
| 1650 |
+
try:
|
| 1651 |
+
json_str = evaluate_fen(variant, fen)
|
| 1652 |
+
return app.response_class(json_str, status=200, mimetype="application/json")
|
| 1653 |
+
|
| 1654 |
+
except chesstb.MissingTableError:
|
| 1655 |
+
return jsonify(_missing_table_error_payload(variant, fen)), 400
|
| 1656 |
+
|
| 1657 |
+
except RuntimeError as e:
|
| 1658 |
+
# Tablebase not initialised (TABLEBASE_PATH failed to load) --
|
| 1659 |
+
# same condition and status code as /health's own "degraded"
|
| 1660 |
+
# response for this, and the same message _validate_and_build_board
|
| 1661 |
+
# raises for /probe/stream to report over SSE.
|
| 1662 |
+
return jsonify({"error": str(e)}), 503
|
| 1663 |
+
|
| 1664 |
+
except ValueError as e:
|
| 1665 |
+
return jsonify({"error": str(e)}), 400
|
| 1666 |
+
|
| 1667 |
+
except _REMOTE_SOURCE_ERROR as e:
|
| 1668 |
+
log.warning("Remote tablebase fetch failed probing variant=%s FEN %s: %s", variant, fen, e)
|
| 1669 |
+
return jsonify({
|
| 1670 |
+
"error": "Could not reach the remote tablebase. Try again shortly.",
|
| 1671 |
+
"error_code": "remote_unavailable",
|
| 1672 |
+
}), 502
|
| 1673 |
+
|
| 1674 |
+
except ProbeInFlightTimeout as e:
|
| 1675 |
+
log.warning("%s", e)
|
| 1676 |
+
return jsonify({
|
| 1677 |
+
"error": "Probe timed out. Try again shortly.",
|
| 1678 |
+
"error_code": "probe_timeout",
|
| 1679 |
+
}), 503
|
| 1680 |
+
|
| 1681 |
+
except Exception:
|
| 1682 |
+
log.exception("Unhandled error probing variant=%s FEN: %s", variant, fen)
|
| 1683 |
+
return jsonify({"error": "Internal server error."}), 500
|
| 1684 |
+
|
| 1685 |
+
|
| 1686 |
+
def _stream_error_payload(variant: str, fen: str, exc: BaseException) -> dict:
|
| 1687 |
+
"""SSE error payload for an exception raised while probing (variant, fen),
|
| 1688 |
+
keeping wording, error_code, and logging identical no matter which probe
|
| 1689 |
+
step inside probe_stream() raised it."""
|
| 1690 |
+
if isinstance(exc, chesstb.MissingTableError):
|
| 1691 |
+
return {"status": "error", **_missing_table_error_payload(variant, fen)}
|
| 1692 |
+
if isinstance(exc, _REMOTE_SOURCE_ERROR):
|
| 1693 |
+
log.warning("Remote tablebase fetch failed in probe/stream for variant=%s %s: %s", variant, fen, exc)
|
| 1694 |
+
return {
|
| 1695 |
+
"status": "error",
|
| 1696 |
+
"error": "Could not reach the remote tablebase. Try again shortly.",
|
| 1697 |
+
"error_code": "remote_unavailable",
|
| 1698 |
+
}
|
| 1699 |
+
if isinstance(exc, ProbeInFlightTimeout):
|
| 1700 |
+
log.warning("%s", exc)
|
| 1701 |
+
return {
|
| 1702 |
+
"status": "error",
|
| 1703 |
+
"error": "Probe timed out. Try again shortly.",
|
| 1704 |
+
"error_code": "probe_timeout",
|
| 1705 |
+
}
|
| 1706 |
+
log.exception("Error in probe/stream for variant=%s %s", variant, fen)
|
| 1707 |
+
return {"status": "error", "error": str(exc)}
|
| 1708 |
+
|
| 1709 |
+
|
| 1710 |
+
@app.route("/probe/stream", methods=["POST"])
|
| 1711 |
+
@_probe_rate_limit
|
| 1712 |
+
def probe_stream() -> Response | tuple[Response, int]:
|
| 1713 |
+
body = request.get_json(silent=True)
|
| 1714 |
+
variant, fen, err = _extract_probe_request(body)
|
| 1715 |
+
if err:
|
| 1716 |
+
return err
|
| 1717 |
+
|
| 1718 |
+
def _sse(obj: dict) -> str:
|
| 1719 |
+
return f"data: {json.dumps(obj)}\n\n"
|
| 1720 |
+
|
| 1721 |
+
def generate():
|
| 1722 |
+
cached = _cached_evaluation(variant, fen, count_miss=False)
|
| 1723 |
+
if cached is not None:
|
| 1724 |
+
yield 'data: {"status":"done",' + cached[1:] + '\n\n'
|
| 1725 |
+
return
|
| 1726 |
+
# Validated up front (rather than only inside evaluate_fen below) so
|
| 1727 |
+
# the validation error can be reported immediately over SSE instead of
|
| 1728 |
+
# only finding out after pre-warming child positions. Uses the same
|
| 1729 |
+
# _validate_and_build_board() that evaluate_fen() itself calls, so
|
| 1730 |
+
# there's one authoritative implementation of "what makes a FEN
|
| 1731 |
+
# probeable" instead of two hand-written copies that could disagree.
|
| 1732 |
+
try:
|
| 1733 |
+
root_board = _validate_and_build_board(variant, fen)
|
| 1734 |
+
except (RuntimeError, ValueError) as e:
|
| 1735 |
+
yield _sse({"status": "error", "error": str(e)})
|
| 1736 |
+
return
|
| 1737 |
+
|
| 1738 |
+
# A terminal root (checkmate, stalemate, insufficient material) never
|
| 1739 |
+
# reaches the tablebase - see evaluate_fen()'s own early return for
|
| 1740 |
+
# these - so only a non-terminal root needs its coverage confirmed
|
| 1741 |
+
# here. Checking the root before pre-warming any child means a root
|
| 1742 |
+
# the tablebase doesn't cover is caught immediately: no child of an
|
| 1743 |
+
# uncovered root is coverable either, so pre-warming first would only
|
| 1744 |
+
# probe the entire move list to discover that after the fact. This
|
| 1745 |
+
# reads through the same cache evaluate_fen() itself reads below, so
|
| 1746 |
+
# a covered root costs nothing extra downstream.
|
| 1747 |
+
terminal = (
|
| 1748 |
+
root_board.is_checkmate()
|
| 1749 |
+
or root_board.is_stalemate()
|
| 1750 |
+
or root_board.is_insufficient_material()
|
| 1751 |
+
)
|
| 1752 |
+
if not terminal:
|
| 1753 |
+
try:
|
| 1754 |
+
# root_board was just built above from this exact (variant,
|
| 1755 |
+
# fen), so it's passed through here too rather than making
|
| 1756 |
+
# _probe_fen_deduped re-parse the same string a second
|
| 1757 |
+
# time -- see _probe_fen_with_deadline's board parameter.
|
| 1758 |
+
root_probe = _probe_fen_with_deadline(
|
| 1759 |
+
variant, fen, time.monotonic() + cfg.probe_timeout, root_board,
|
| 1760 |
+
)
|
| 1761 |
+
except Exception as e:
|
| 1762 |
+
yield _sse(_stream_error_payload(variant, fen, e))
|
| 1763 |
+
return
|
| 1764 |
+
if root_probe is None:
|
| 1765 |
+
yield _sse({"status": "error", **_missing_table_error_payload(variant, fen)})
|
| 1766 |
+
return
|
| 1767 |
+
|
| 1768 |
+
# Insufficient-material draws can still have legal moves, but
|
| 1769 |
+
# evaluate_fen returns a terminal response without using children.
|
| 1770 |
+
move_info = [] if terminal else _collect_move_info(root_board)
|
| 1771 |
+
child_fens: set[str] = {
|
| 1772 |
+
child_fen for _, _, _, _, _, child_fen, _ in move_info if child_fen is not None
|
| 1773 |
+
}
|
| 1774 |
+
child_boards: dict[str, chess.Board] = {
|
| 1775 |
+
child_fen: child_board
|
| 1776 |
+
for _, _, _, _, _, child_fen, child_board in move_info
|
| 1777 |
+
if child_fen is not None and child_board is not None
|
| 1778 |
+
}
|
| 1779 |
+
|
| 1780 |
+
total = len(child_fens)
|
| 1781 |
+
yield _sse({"status": "probing", "completed": 0, "total": total})
|
| 1782 |
+
|
| 1783 |
+
# Pre-warm child FENs in parallel using the native non-blocking cache.
|
| 1784 |
+
# Always via the thread pool (regardless of cfg.parallel_threshold, which
|
| 1785 |
+
# only governs evaluate_all_moves' own sequential/parallel choice below):
|
| 1786 |
+
# a bare sequential loop here would have no way to bound a single hung
|
| 1787 |
+
# _probe_fen_with_deadline() call, since there'd be no worker thread to
|
| 1788 |
+
# poll against; only a background thread lets this deadline actually
|
| 1789 |
+
# cut the wait short. This budget is independent of the root probe's
|
| 1790 |
+
# own, above - a slow root probe doesn't eat into pre-warm's time.
|
| 1791 |
+
deadline = time.monotonic() + cfg.probe_timeout
|
| 1792 |
+
futures = {
|
| 1793 |
+
cf: _executor.submit(
|
| 1794 |
+
_probe_fen_with_deadline, variant, cf, deadline, child_boards.get(cf)
|
| 1795 |
+
)
|
| 1796 |
+
for cf in child_fens
|
| 1797 |
+
}
|
| 1798 |
+
remaining = set(futures.values())
|
| 1799 |
+
done_count = 0
|
| 1800 |
+
prewarm_complete = True
|
| 1801 |
+
last_event = time.monotonic()
|
| 1802 |
+
try:
|
| 1803 |
+
while done_count < total:
|
| 1804 |
+
time_left = deadline - time.monotonic()
|
| 1805 |
+
if time_left <= 0:
|
| 1806 |
+
log.warning(
|
| 1807 |
+
"Probe pre-warm timed out for variant=%s %s (%d/%d child positions still pending)",
|
| 1808 |
+
variant, fen, total - done_count, total,
|
| 1809 |
+
)
|
| 1810 |
+
prewarm_complete = False
|
| 1811 |
+
break
|
| 1812 |
+
newly_done, remaining = futures_wait(
|
| 1813 |
+
remaining, timeout=min(0.3, time_left), return_when=FIRST_COMPLETED
|
| 1814 |
+
)
|
| 1815 |
+
if any(f.cancelled() or f.exception() is not None for f in newly_done):
|
| 1816 |
+
prewarm_complete = False
|
| 1817 |
+
if newly_done:
|
| 1818 |
+
done_count += len(newly_done)
|
| 1819 |
+
yield _sse({"status": "probing", "completed": done_count, "total": total})
|
| 1820 |
+
last_event = time.monotonic()
|
| 1821 |
+
elif time.monotonic() - last_event >= 1.0:
|
| 1822 |
+
# Keep the stream live and detect disconnects without
|
| 1823 |
+
# making the browser render identical progress updates.
|
| 1824 |
+
yield ": keep-alive\n\n"
|
| 1825 |
+
last_event = time.monotonic()
|
| 1826 |
+
finally:
|
| 1827 |
+
# Also runs when the streaming client disconnects. Running probes
|
| 1828 |
+
# may finish, but queued work should not delay newer requests.
|
| 1829 |
+
for future in remaining:
|
| 1830 |
+
future.cancel()
|
| 1831 |
+
|
| 1832 |
+
# If pre-warming above finished, every child FEN is already cached, so
|
| 1833 |
+
# evaluate_fen() below can fetch them all as cache hits sequentially,
|
| 1834 |
+
# without the overhead of resubmitting each one to the thread pool. If
|
| 1835 |
+
# pre-warming timed out instead, skip that shortcut: evaluate_all_moves()
|
| 1836 |
+
# inside evaluate_fen() then takes its own parallel path, with its own
|
| 1837 |
+
# fresh cfg.probe_timeout budget (see _evaluate_fen_impl), and reports
|
| 1838 |
+
# "unknown" for whatever's still unresolved once that budget passes.
|
| 1839 |
+
_evaluate_tls.bypass_parallel = prewarm_complete
|
| 1840 |
+
# Already built above (root_board, move_info) to size the progress
|
| 1841 |
+
# bar and know which child FENs to pre-warm - handing them to
|
| 1842 |
+
# evaluate_fen() here means a cache-miss FEN doesn't pay for a
|
| 1843 |
+
# second board build + legal-move walk right after this one.
|
| 1844 |
+
_evaluate_tls.board = root_board
|
| 1845 |
+
_evaluate_tls.move_info = move_info
|
| 1846 |
+
try:
|
| 1847 |
+
json_str = evaluate_fen(variant, fen)
|
| 1848 |
+
yield 'data: {"status":"done",' + json_str[1:] + '\n\n'
|
| 1849 |
+
|
| 1850 |
+
except Exception as e:
|
| 1851 |
+
yield _sse(_stream_error_payload(variant, fen, e))
|
| 1852 |
+
finally:
|
| 1853 |
+
_evaluate_tls.bypass_parallel = False
|
| 1854 |
+
_evaluate_tls.board = None
|
| 1855 |
+
_evaluate_tls.move_info = None
|
| 1856 |
+
|
| 1857 |
+
return Response(
|
| 1858 |
+
stream_with_context(generate()),
|
| 1859 |
+
content_type="text/event-stream",
|
| 1860 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 1861 |
+
)
|
| 1862 |
+
|
| 1863 |
+
|
| 1864 |
+
@app.route("/health")
|
| 1865 |
+
def health() -> Response:
|
| 1866 |
+
if TB is None:
|
| 1867 |
+
return jsonify({"status": "degraded", "reason": "Tablebase not initialised"}), 503
|
| 1868 |
+
return jsonify({"status": "ok"}), 200
|
| 1869 |
+
|
| 1870 |
+
|
| 1871 |
+
def _cache_info_dict(info) -> dict:
|
| 1872 |
+
hits, misses = info.hits, info.misses
|
| 1873 |
+
maxsize, currsize = info.maxsize, info.currsize
|
| 1874 |
+
total = hits + misses
|
| 1875 |
+
return {
|
| 1876 |
+
"hits": hits,
|
| 1877 |
+
"misses": misses,
|
| 1878 |
+
"hit_rate": round(hits / total, 4) if total else 0.0,
|
| 1879 |
+
"maxsize": maxsize,
|
| 1880 |
+
"currsize": currsize,
|
| 1881 |
+
}
|
| 1882 |
+
|
| 1883 |
+
|
| 1884 |
+
@app.route("/admin/cache/clear", methods=["POST"])
|
| 1885 |
+
@_require_admin
|
| 1886 |
+
def clear_cache() -> Response:
|
| 1887 |
+
evaluate_fen.cache_clear()
|
| 1888 |
+
_probe_fen.cache_clear()
|
| 1889 |
+
# The two LRUs above are app.py-level caches keyed on (variant, FEN)
|
| 1890 |
+
# pairs. They are not the only cache in the probing pipeline: each tier's
|
| 1891 |
+
# underlying tablebase can keep its own internal decoded-block cache
|
| 1892 |
+
# and, in remote mode, a downloaded-file cache (remote/remote_fallback.py).
|
| 1893 |
+
# SourcedTablebase.clear_caches()/cache_stats() are exposed on TB
|
| 1894 |
+
# itself, but only actually clear/report something for a tier backed
|
| 1895 |
+
# by a remote/remote_fallback.py Tablebase -- a plain local-directory
|
| 1896 |
+
# Tablebase has no such cache. cache_stats() coming back empty is what
|
| 1897 |
+
# distinguishes "nothing to clear" from "cleared something", since
|
| 1898 |
+
# every tier is asked unconditionally either way.
|
| 1899 |
+
tb_cache_info: dict = {}
|
| 1900 |
+
if TB is not None:
|
| 1901 |
+
TB.clear_caches()
|
| 1902 |
+
tb_cache_info = TB.cache_stats()
|
| 1903 |
+
tb_cache_cleared = bool(tb_cache_info)
|
| 1904 |
+
log.info(
|
| 1905 |
+
"All probe caches cleared (tablebase-internal cache %s).",
|
| 1906 |
+
"cleared" if tb_cache_cleared else "not applicable for this tablebase",
|
| 1907 |
+
)
|
| 1908 |
+
response = {
|
| 1909 |
+
"status": "ok",
|
| 1910 |
+
"message": "All probe caches cleared (root JSON cache + child probe cache"
|
| 1911 |
+
+ (" + tablebase-internal caches)." if tb_cache_cleared else ")."),
|
| 1912 |
+
"evaluate_fen_cache": _cache_info_dict(evaluate_fen.cache_info()),
|
| 1913 |
+
"probe_fen_cache": _cache_info_dict(_probe_fen.cache_info()),
|
| 1914 |
+
"tablebase_internal_cache_cleared": tb_cache_cleared,
|
| 1915 |
+
}
|
| 1916 |
+
if tb_cache_info:
|
| 1917 |
+
response["tablebase_cache"] = tb_cache_info
|
| 1918 |
+
return jsonify(response)
|
| 1919 |
+
|
| 1920 |
+
|
| 1921 |
+
@app.route("/admin/cache/stats", methods=["GET"])
|
| 1922 |
+
@_require_admin
|
| 1923 |
+
def cache_stats() -> Response:
|
| 1924 |
+
stats = {
|
| 1925 |
+
"evaluate_fen_cache": _cache_info_dict(evaluate_fen.cache_info()),
|
| 1926 |
+
"probe_fen_cache": _cache_info_dict(_probe_fen.cache_info()),
|
| 1927 |
+
"thread_pool": {
|
| 1928 |
+
"max_workers": cfg.probe_threads,
|
| 1929 |
+
"parallel_threshold": cfg.parallel_threshold,
|
| 1930 |
+
"probe_timeout_secs": cfg.probe_timeout,
|
| 1931 |
+
},
|
| 1932 |
+
"config": {
|
| 1933 |
+
"evaluate_cache_size": cfg.evaluate_cache_size,
|
| 1934 |
+
"probe_cache_size": cfg.probe_cache_size,
|
| 1935 |
+
"block_cache_bytes": cfg.block_cache_bytes,
|
| 1936 |
+
},
|
| 1937 |
+
"tablebase": {
|
| 1938 |
+
"configured": TB is not None,
|
| 1939 |
+
},
|
| 1940 |
+
}
|
| 1941 |
+
# SourcedTablebase.cache_stats() is exposed on TB and reports the configured source
|
| 1942 |
+
# -- a source contributes no key when it has no remote-backed cache to
|
| 1943 |
+
# report (see clear_cache() above for why a plain local-directory
|
| 1944 |
+
# Tablebase has none).
|
| 1945 |
+
if TB is not None:
|
| 1946 |
+
tb_cache = TB.cache_stats()
|
| 1947 |
+
if tb_cache:
|
| 1948 |
+
stats["tablebase_cache"] = tb_cache
|
| 1949 |
+
return jsonify(stats)
|
| 1950 |
+
|
| 1951 |
+
|
| 1952 |
+
@app.route("/admin/login", methods=["POST"])
|
| 1953 |
+
@_admin_login_rate_limit
|
| 1954 |
+
def admin_login() -> Response:
|
| 1955 |
+
if not cfg.admin_token:
|
| 1956 |
+
return jsonify({"error": "Admin panel disabled: ADMIN_TOKEN is not configured."}), 503
|
| 1957 |
+
body = request.get_json(silent=True) or {}
|
| 1958 |
+
token = body.get("token", "")
|
| 1959 |
+
if not isinstance(token, str) or not _admin_token_matches(token):
|
| 1960 |
+
return jsonify({"error": "Incorrect admin token."}), 401
|
| 1961 |
+
session["admin_ok"] = True
|
| 1962 |
+
return jsonify({"status": "ok"})
|
| 1963 |
+
|
| 1964 |
+
|
| 1965 |
+
@app.route("/admin/logout", methods=["POST"])
|
| 1966 |
+
def admin_logout() -> Response:
|
| 1967 |
+
session.pop("admin_ok", None)
|
| 1968 |
+
return jsonify({"status": "ok"})
|
| 1969 |
+
|
| 1970 |
+
|
| 1971 |
+
@app.route("/admin", methods=["GET"])
|
| 1972 |
+
def admin_dashboard() -> str | tuple[str, int]:
|
| 1973 |
+
# Renders the dashboard once authenticated (session cookie from
|
| 1974 |
+
# /admin/login); a login form otherwise. Unlike /admin/cache/* below,
|
| 1975 |
+
# this route can't just use _require_admin -- a browser visiting /admin
|
| 1976 |
+
# needs the login form itself, not a bare 401/503 JSON body. Matches
|
| 1977 |
+
# /admin/login and /admin/cache/*'s own 503 when ADMIN_TOKEN is unset,
|
| 1978 |
+
# rather than serving the dashboard shell with a 200.
|
| 1979 |
+
if not cfg.admin_token:
|
| 1980 |
+
return render_template(
|
| 1981 |
+
"admin.html", authenticated=False, admin_configured=False,
|
| 1982 |
+
source_repository_url=cfg.source_repository_url,
|
| 1983 |
+
), 503
|
| 1984 |
+
return render_template(
|
| 1985 |
+
"admin.html",
|
| 1986 |
+
authenticated=_is_admin_authenticated(),
|
| 1987 |
+
admin_configured=True,
|
| 1988 |
+
source_repository_url=cfg.source_repository_url,
|
| 1989 |
+
)
|
| 1990 |
+
|
| 1991 |
+
|
| 1992 |
+
# ── OpenAPI specification ─────────────────────────────────────────────────────
|
| 1993 |
+
# The specification is maintained in openapi.yaml (project root).
|
| 1994 |
+
|
| 1995 |
+
_OPENAPI_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "openapi.yaml")
|
| 1996 |
+
|
| 1997 |
+
|
| 1998 |
+
@app.route("/openapi.yaml")
|
| 1999 |
+
def openapi_spec() -> Response:
|
| 2000 |
+
return send_file(_OPENAPI_PATH, mimetype="application/yaml")
|
| 2001 |
+
|
| 2002 |
+
|
| 2003 |
+
if __name__ == "__main__":
|
| 2004 |
+
if cfg.debug:
|
| 2005 |
+
# Flask's own dev server: auto-reload + interactive debugger, for
|
| 2006 |
+
# local development only. `threaded=True` is what lets a long-lived
|
| 2007 |
+
# /probe/stream SSE connection coexist with other concurrent
|
| 2008 |
+
# requests on this single-process server (see README.md "Running in
|
| 2009 |
+
# production").
|
| 2010 |
+
log.info("DEBUG = True (config.py) — starting Flask development server on %s:%s", cfg.host, cfg.port)
|
| 2011 |
+
app.run(debug=True, host=cfg.host, port=cfg.port, threaded=True)
|
| 2012 |
+
else:
|
| 2013 |
+
# Production: serve via waitress, a pure-Python production-grade
|
| 2014 |
+
# WSGI server. `threads` gives waitress its own pool of
|
| 2015 |
+
# request-handling threads, playing the same role `threaded=True`
|
| 2016 |
+
# plays for the Flask dev server above - without it, a single
|
| 2017 |
+
# open /probe/stream connection could starve every other request
|
| 2018 |
+
# (see README.md "Running in production").
|
| 2019 |
+
#
|
| 2020 |
+
# trusted_proxy=* / trusted_proxy_count / trusted_proxy_headers:
|
| 2021 |
+
# waitress parses X-Forwarded-For and X-Forwarded-Proto and
|
| 2022 |
+
# rewrites REMOTE_ADDR / wsgi.url_scheme itself, before the
|
| 2023 |
+
# request ever reaches the Flask app object -- so it sits in front
|
| 2024 |
+
# of, and takes priority over, the ProxyFix layer wired onto
|
| 2025 |
+
# app.wsgi_app above. Without this, waitress's default
|
| 2026 |
+
# (trusted_proxy=None) strips both headers entirely as untrusted,
|
| 2027 |
+
# silently discarding them before ProxyFix could ever see them --
|
| 2028 |
+
# ProxyFix alone is not sufficient here. `"*"` trusts whichever
|
| 2029 |
+
# peer connects, rather than one specific IP, because Render's
|
| 2030 |
+
# edge/load-balancer's own address isn't a fixed, documented
|
| 2031 |
+
# value; this is safe under the same assumption
|
| 2032 |
+
# TRUSTED_PROXY_COUNT's docstring already states -- that nothing
|
| 2033 |
+
# but that one proxy hop can reach this process directly. Skipped
|
| 2034 |
+
# entirely when cfg.trusted_proxy_count is 0 (no reverse proxy in
|
| 2035 |
+
# front at all), leaving waitress's own default in place.
|
| 2036 |
+
log.info(
|
| 2037 |
+
"Starting waitress production server on %s:%s (threads=%s)",
|
| 2038 |
+
cfg.host, cfg.port, cfg.waitress_threads,
|
| 2039 |
+
)
|
| 2040 |
+
waitress_kwargs = {}
|
| 2041 |
+
if cfg.trusted_proxy_count > 0:
|
| 2042 |
+
waitress_kwargs.update(
|
| 2043 |
+
trusted_proxy="*",
|
| 2044 |
+
trusted_proxy_count=cfg.trusted_proxy_count,
|
| 2045 |
+
trusted_proxy_headers={"x-forwarded-for", "x-forwarded-proto"},
|
| 2046 |
+
)
|
| 2047 |
+
waitress_serve(
|
| 2048 |
+
app, host=cfg.host, port=cfg.port, threads=cfg.waitress_threads, **waitress_kwargs)
|
config.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
config.py: ChessTB Explorer configuration
|
| 3 |
+
|
| 4 |
+
Edit the values below directly, then run `python app.py`. This file
|
| 5 |
+
contains the primary application configuration. A subset of settings is
|
| 6 |
+
also readable from the process environment, with the literal value below
|
| 7 |
+
used when the environment variable is unset, allowing one committed
|
| 8 |
+
configuration file to serve multiple deployment platforms.
|
| 9 |
+
|
| 10 |
+
- ADMIN_TOKEN / FLASK_SECRET_KEY: always from the environment only
|
| 11 |
+
(no literal fallback below) so they can be set as platform secrets
|
| 12 |
+
(e.g. a Render "Environment Variable" marked secret, or a Hugging
|
| 13 |
+
Face Space "Secret") without ever committing them - see the "Admin"
|
| 14 |
+
section below.
|
| 15 |
+
- PORT: read from the environment with a literal fallback, because
|
| 16 |
+
platforms like Render assign the port dynamically per-deployment
|
| 17 |
+
(via the platform's PORT variable) rather than using a fixed value;
|
| 18 |
+
see the "Server" section below.
|
| 19 |
+
- TABLEBASE_PATH / SOURCE_REPOSITORY_URL: read from the environment with a literal fallback,
|
| 20 |
+
to allow a deployment (for example, a Render Web Service) to select a
|
| 21 |
+
different tablebase location or repository URL without modifying the
|
| 22 |
+
source file; editing the literal value below remains supported.
|
| 23 |
+
|
| 24 |
+
Invalid values (wrong type, out of range) make the app log an error and
|
| 25 |
+
exit at startup rather than run with a silently-wrong configuration;
|
| 26 |
+
see AppConfig.from_config() in app.py.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import os
|
| 30 |
+
|
| 31 |
+
# ── Tablebase location ──────────────────────────────────────────────────────
|
| 32 |
+
|
| 33 |
+
# Bucket URL or local tablebase root. Full tables are preferred over shipping
|
| 34 |
+
# tables; castling directories are selected automatically for each material.
|
| 35 |
+
# A list of explicit source directories is also supported.
|
| 36 |
+
TABLEBASE_PATH = os.environ.get(
|
| 37 |
+
"TABLEBASE_PATH", "https://huggingface.co/buckets/chesstb/chesstb/resolve",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# ── Server ───────────────────────────────────────────────────────────────────
|
| 41 |
+
|
| 42 |
+
# Set to True to run the app via Flask's own dev server (auto-reload,
|
| 43 |
+
# detailed error pages, interactive debugger) instead of waitress. Leave
|
| 44 |
+
# False for any deployment reachable from other machines - False is what
|
| 45 |
+
# makes `python app.py` serve via waitress, the production WSGI server
|
| 46 |
+
# this app ships with.
|
| 47 |
+
DEBUG = False
|
| 48 |
+
|
| 49 |
+
# Interface the server (waitress, or the Flask dev server if DEBUG is
|
| 50 |
+
# True) binds to. Set to "0.0.0.0" so the server accepts connections
|
| 51 |
+
# arriving at the container's network interface, as required by Render,
|
| 52 |
+
# Hugging Face Spaces, and any other containerized deployment. Use
|
| 53 |
+
# "127.0.0.1" instead only for single-machine local use with no other
|
| 54 |
+
# access control in front of it - see README.md's "Security notes".
|
| 55 |
+
HOST = "0.0.0.0"
|
| 56 |
+
|
| 57 |
+
# Port the server listens on - applies whether app.py ends up serving via
|
| 58 |
+
# waitress (DEBUG=False) or the Flask dev server (DEBUG=True).
|
| 59 |
+
#
|
| 60 |
+
# Read from a PORT environment variable first, falling back to 7860
|
| 61 |
+
# (Hugging Face Docker Spaces' default routed port) when unset. This
|
| 62 |
+
# matters specifically for Render: a Render web service is assigned its
|
| 63 |
+
# externally-routed port at deploy time via its own PORT variable
|
| 64 |
+
# (commonly 10000, but not guaranteed) rather than a fixed value. The
|
| 65 |
+
# container reads and binds to the injected port. Running locally with no
|
| 66 |
+
# PORT set uses 7860.
|
| 67 |
+
PORT = os.environ.get("PORT", "7860")
|
| 68 |
+
|
| 69 |
+
# Number of worker threads waitress uses to handle concurrent requests
|
| 70 |
+
# (only relevant when DEBUG=False). Needs to be more than 1 so that a
|
| 71 |
+
# long-lived /probe/stream SSE connection can't block every other request;
|
| 72 |
+
# see README.md's "Running in production" section. This is separate
|
| 73 |
+
# from PROBE_THREADS below, which sizes the pool used internally to
|
| 74 |
+
# parallelise tablebase probing, not to serve HTTP requests.
|
| 75 |
+
WAITRESS_THREADS = 4
|
| 76 |
+
|
| 77 |
+
# ── Probing ──────────────────────────────────────────────────────────────────
|
| 78 |
+
|
| 79 |
+
# Worker threads in the probe thread pool for evaluating a position's
|
| 80 |
+
# legal moves in parallel. Set to match a 2-vCPU host (e.g. Hugging
|
| 81 |
+
# Face's cpu-basic tier); raise it on a larger host, or leave as None to
|
| 82 |
+
# scale automatically instead (min(16, cpu_count() * 2)).
|
| 83 |
+
PROBE_THREADS = 2
|
| 84 |
+
|
| 85 |
+
# Minimum number of not-yet-cached child positions before a probe batch
|
| 86 |
+
# switches from sequential to the PROBE_THREADS thread pool. Lower this
|
| 87 |
+
# (or set to 1 to always parallelize) for workloads with many legal moves
|
| 88 |
+
# or when profiling shows sequential probing is a bottleneck. Raise it
|
| 89 |
+
# when thread hand-off overhead outweighs the benefit for the workload.
|
| 90 |
+
PROBE_PARALLEL_THRESHOLD = 4
|
| 91 |
+
|
| 92 |
+
# Wall-clock timeout, in seconds, for each probe phase. /probe gives the
|
| 93 |
+
# root probe and the child-move batch independent budgets, so a worst-case
|
| 94 |
+
# request can consume up to two PROBE_TIMEOUT_SECS intervals. /probe/stream
|
| 95 |
+
# uses the same setting for its root probe and child pre-warm/evaluation
|
| 96 |
+
# phases. Remaining probes are treated as unknown when a phase reaches its
|
| 97 |
+
# deadline rather than blocking the request indefinitely.
|
| 98 |
+
PROBE_TIMEOUT_SECS = 30
|
| 99 |
+
|
| 100 |
+
# ── Caching ──────────────────────────────────────────────────────────────────
|
| 101 |
+
|
| 102 |
+
# Defaults target one process on Hugging Face CPU Basic (2 vCPU, 16 GB
|
| 103 |
+
# RAM). With the four default remote sources, byte-cache budgets total
|
| 104 |
+
# 8.5 GiB: four 2 GiB page caches plus one shared 512 MiB block cache.
|
| 105 |
+
# These fill on demand. Leave the remaining RAM for result caches,
|
| 106 |
+
# table/index metadata, Python object overhead, and active requests.
|
| 107 |
+
|
| 108 |
+
# Max entries in the root-FEN result cache (full JSON responses served by
|
| 109 |
+
# evaluate_fen()). Raising this keeps more distinct root positions warm
|
| 110 |
+
# across a session at the cost of memory.
|
| 111 |
+
EVALUATE_CACHE_SIZE = 16384
|
| 112 |
+
|
| 113 |
+
# Max entries in the child-position probe cache (raw WDL/DTZ/DTC/DTM/DTM50
|
| 114 |
+
# tuples served by _probe_fen()), shared between root probes and every
|
| 115 |
+
# child position probed while ranking moves.
|
| 116 |
+
PROBE_CACHE_SIZE = 262144
|
| 117 |
+
|
| 118 |
+
# Size (in bytes) of chess.chesstb's own internal cache of decoded/
|
| 119 |
+
# decompressed tablebase blocks, shared across the WDL/DTZ/DTC/DTM/DTM50
|
| 120 |
+
# tables.
|
| 121 |
+
# One cache is shared across every source. Keep 512 MiB of decoded
|
| 122 |
+
# blocks to reduce repeated decompression on the 2-vCPU host.
|
| 123 |
+
BLOCK_CACHE_BYTES = 512 * 1024 * 1024
|
| 124 |
+
|
| 125 |
+
# ── Remote (URL) tablebase entries only ─────────────────────────────────────
|
| 126 |
+
# Everything below is ignored for a local-directory entry; it applies to
|
| 127 |
+
# every http(s):// entry in TABLEBASE_PATH (including entries inside
|
| 128 |
+
# a hybrid list - see
|
| 129 |
+
# above). See the module docstrings of remote/remote_direct.py and
|
| 130 |
+
# remote/remote_fallback.py for the full design.
|
| 131 |
+
|
| 132 |
+
# How a remote table's bytes reach the prober:
|
| 133 |
+
#
|
| 134 |
+
# "direct" -- probe the remote tables in place, fetching only the
|
| 135 |
+
# page-sized byte ranges each probe reads, nothing written
|
| 136 |
+
# to disk (remote/remote_direct.py).
|
| 137 |
+
# "download" -- fetch each table file in full on first touch and cache it
|
| 138 |
+
# on local disk (remote/remote_fallback.py). Slower first
|
| 139 |
+
# touch of a material and real disk usage, but every read
|
| 140 |
+
# after it is a local mmap read.
|
| 141 |
+
#
|
| 142 |
+
# "direct" is the preferred mode for browsing across many materials.
|
| 143 |
+
# "download" is preferable when a small set of materials is accessed
|
| 144 |
+
# repeatedly for a long session or when per-request network latency is high.
|
| 145 |
+
#
|
| 146 |
+
# "direct" is also the right choice for a PaaS deployment (e.g. Render)
|
| 147 |
+
# fronting a multi-terabyte tablebase set: it needs no persistent disk at
|
| 148 |
+
# all, so it sidesteps both the platform's per-GB disk cost at that scale
|
| 149 |
+
# and the fact that most platforms' single-service disks aren't sized for
|
| 150 |
+
# terabytes in the first place. "download" would need a disk (or enough
|
| 151 |
+
# ephemeral local storage) large enough for the materials probed during a
|
| 152 |
+
# session. Storage requirements therefore grow with the number and size of
|
| 153 |
+
# materials accessed.
|
| 154 |
+
REMOTE_MODE = "direct"
|
| 155 |
+
|
| 156 |
+
# Soft budget, in bytes, PER remote source in TABLEBASE_PATH, shared
|
| 157 |
+
# across that source's tables: bounds the in-memory page cache in
|
| 158 |
+
# "direct" mode, and the on-disk whole-file cache in "download" mode.
|
| 159 |
+
# Four default sources can retain 8 GiB of pages in total. Revisit this
|
| 160 |
+
# budget when adding sources or running multiple application processes.
|
| 161 |
+
REMOTE_PAGE_CACHE_BYTES = 2 * 1024 * 1024 * 1024
|
| 162 |
+
|
| 163 |
+
# Size, in bytes, of one page. In "direct" mode this is the granularity of
|
| 164 |
+
# every fetch, so it sets the trade between over-fetching (too large) and
|
| 165 |
+
# many round trips per probe (too small) -- 256 KiB keeps a table's whole
|
| 166 |
+
# header + index region within a page or two, which is what makes later
|
| 167 |
+
# probes of the same table cheap. In "download" mode it is only the chunk
|
| 168 |
+
# size used while streaming a full file down, and has no effect on which
|
| 169 |
+
# bytes end up fetched.
|
| 170 |
+
REMOTE_PAGE_SIZE_BYTES = 256 * 1024
|
| 171 |
+
|
| 172 |
+
# Per-HTTP-request timeout, in seconds, for both existence/size checks and
|
| 173 |
+
# the download itself against a remote tablebase.
|
| 174 |
+
REMOTE_TIMEOUT_SECS = 20.0
|
| 175 |
+
|
| 176 |
+
# Number of attempts for a single remote request before giving up (the
|
| 177 |
+
# request that triggered it then fails, surfacing as a probe error).
|
| 178 |
+
REMOTE_MAX_RETRIES = 3
|
| 179 |
+
|
| 180 |
+
# Max size of the HTTP connection pool each remote backend's session
|
| 181 |
+
# keeps open against its entry's host. Left as None so it scales
|
| 182 |
+
# with PROBE_THREADS automatically (max(PROBE_THREADS * 2, 20)) - set an
|
| 183 |
+
# explicit integer to override. Too small a pool under-serves concurrent
|
| 184 |
+
# probing (connections stop being reused once PROBE_THREADS exceeds it);
|
| 185 |
+
# too large rarely hurts beyond a handful of idle sockets, so there's
|
| 186 |
+
# little reason to set this below the auto-computed value.
|
| 187 |
+
REMOTE_POOL_MAXSIZE = None
|
| 188 |
+
|
| 189 |
+
# ── Rate limiting ────────────────────────────────────────────────────────────
|
| 190 |
+
|
| 191 |
+
# Per-client-IP request limit applied to /probe and /probe/stream - the
|
| 192 |
+
# two public, unauthenticated, CPU-bound endpoints - using flask-limiter's
|
| 193 |
+
# string syntax (e.g. "60 per minute", "10 per second"; multiple limits
|
| 194 |
+
# can be combined with ";"). Backed by an in-process memory store, which
|
| 195 |
+
# is enough for a single-container deployment (e.g. one Render Web
|
| 196 |
+
# Service instance) but does not share state across multiple replicas.
|
| 197 |
+
# Left empty, rate limiting is disabled entirely.
|
| 198 |
+
#
|
| 199 |
+
# 60 per minute comfortably covers normal browsing and autoplay at its
|
| 200 |
+
# default 1.25s delay; raise it if legitimate autoplay at a fast delay
|
| 201 |
+
# setting gets throttled, or lower it if the deployment sees abuse.
|
| 202 |
+
PROBE_RATE_LIMIT = "60 per minute"
|
| 203 |
+
|
| 204 |
+
# Per-client-IP limit applied only to requests that identify themselves as
|
| 205 |
+
# background hover-prefetch via the "X-Prefetch: 1" request header (see
|
| 206 |
+
# frontend/src/engine/tablebase.js's hover-prefetch). Deliberately separate from,
|
| 207 |
+
# and tighter than, PROBE_RATE_LIMIT -- a client-supplied header is not
|
| 208 |
+
# trusted enough to exempt a request from limiting altogether (any
|
| 209 |
+
# client could tag every request "prefetch" to bypass PROBE_RATE_LIMIT),
|
| 210 |
+
# only to route it to its own, smaller budget, so a sustained hover sweep
|
| 211 |
+
# can burn through this budget and start getting silently-dropped 429s on
|
| 212 |
+
# prefetches without ever touching the user's own click budget above.
|
| 213 |
+
# Left empty, prefetch-tagged requests fall back to sharing
|
| 214 |
+
# PROBE_RATE_LIMIT with everything else, as if this setting didn't exist.
|
| 215 |
+
PREFETCH_RATE_LIMIT = "30 per minute"
|
| 216 |
+
|
| 217 |
+
# Per-client-IP limit on /admin/login (config.py's ADMIN_TOKEN check),
|
| 218 |
+
# same string syntax as PROBE_RATE_LIMIT above and backed by the same
|
| 219 |
+
# in-process memory store. Separate from PROBE_RATE_LIMIT because
|
| 220 |
+
# /admin/login is a brute-forceable shared-secret check, not a CPU-bound
|
| 221 |
+
# probe -- it needs a tighter limit than "comfortably covers normal
|
| 222 |
+
# browsing" calls for. Applies to both failed and successful login
|
| 223 |
+
# attempts. Left empty, login attempts are not rate limited at all.
|
| 224 |
+
ADMIN_LOGIN_RATE_LIMIT = "5 per minute"
|
| 225 |
+
|
| 226 |
+
# Number of trusted reverse-proxy hops in front of this application.
|
| 227 |
+
# PROBE_RATE_LIMIT and ADMIN_LOGIN_RATE_LIMIT above both key on the
|
| 228 |
+
# client's IP address (request.remote_addr) -- behind a reverse proxy
|
| 229 |
+
# that's the proxy's own address unless it's corrected for, which would
|
| 230 |
+
# collapse every client behind that proxy onto one shared rate-limit key.
|
| 231 |
+
# The same trusted-hop count also governs whether X-Forwarded-Proto is
|
| 232 |
+
# trusted, which corrects request.scheme / request.is_secure (and any
|
| 233 |
+
# externally-generated URL) when TLS terminates at that reverse proxy
|
| 234 |
+
# rather than at this app itself.
|
| 235 |
+
#
|
| 236 |
+
# In production (DEBUG = False), app.py passes this to waitress's own
|
| 237 |
+
# trusted_proxy_count, since waitress -- the actual production WSGI
|
| 238 |
+
# server -- parses X-Forwarded-For/X-Forwarded-Proto and corrects
|
| 239 |
+
# REMOTE_ADDR/wsgi.url_scheme itself, before Flask ever sees the request.
|
| 240 |
+
# app.py also applies Werkzeug's ProxyFix with the same count, which
|
| 241 |
+
# makes the equivalent correction for the DEBUG = True Flask-dev-server
|
| 242 |
+
# path (waitress isn't involved there at all); under waitress it is
|
| 243 |
+
# redundant, since waitress performs the same correction itself.
|
| 244 |
+
#
|
| 245 |
+
# 1 is correct for this project's own render.yaml / "Deploying to Render"
|
| 246 |
+
# topology: Render's platform edge/load-balancer sits in front of a web
|
| 247 |
+
# service exactly once. Increase it when an additional reverse proxy is
|
| 248 |
+
# present in front of the service. Set to 0 when no reverse proxy is present
|
| 249 |
+
# -- a value that does not match the actual proxy chain
|
| 250 |
+
# leaves the rate limiter keying on the wrong address (or the scheme
|
| 251 |
+
# misdetected), or (set too high for no proxy at all) trusting a
|
| 252 |
+
# client-supplied X-Forwarded-For/X-Forwarded-Proto header directly,
|
| 253 |
+
# letting a client spoof its own rate-limit key or apparent scheme.
|
| 254 |
+
TRUSTED_PROXY_COUNT = os.environ.get("TRUSTED_PROXY_COUNT", "1")
|
| 255 |
+
|
| 256 |
+
# ── Admin ────────────────────────────────────────────────────────────────────
|
| 257 |
+
|
| 258 |
+
# Shared secret required to reach /admin, /admin/cache/stats, and
|
| 259 |
+
# /admin/cache/clear. Read from the ADMIN_TOKEN environment variable
|
| 260 |
+
# rather than hardcoded here - set it as a platform secret (e.g. a
|
| 261 |
+
# Render "Environment Variable" marked secret, generated automatically by
|
| 262 |
+
# render.yaml's generateValue: true) rather than committing it here.
|
| 263 |
+
# Left unset, every admin route responds 503 rather than running without
|
| 264 |
+
# a password. Visiting /admin with no active session shows a login form;
|
| 265 |
+
# a correct token there starts a signed session cookie. API clients may
|
| 266 |
+
# instead send "Authorization: Bearer <ADMIN_TOKEN>" directly.
|
| 267 |
+
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "")
|
| 268 |
+
|
| 269 |
+
# Key Flask uses to sign the admin session cookie. Read from the
|
| 270 |
+
# FLASK_SECRET_KEY environment variable for the same reason as
|
| 271 |
+
# ADMIN_TOKEN above. Left unset, a random key is generated at process
|
| 272 |
+
# startup instead - sessions still work, but every restart invalidates
|
| 273 |
+
# existing ones, requiring the admin to log in again. Set an explicit,
|
| 274 |
+
# persistent value if that's undesirable (e.g. behind a load balancer
|
| 275 |
+
# with multiple replicas, or to survive routine restarts).
|
| 276 |
+
FLASK_SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "")
|
| 277 |
+
|
| 278 |
+
# Whether the admin session cookie set by /admin/login requires HTTPS.
|
| 279 |
+
# Independent of DEBUG above -- DEBUG is a development/runtime behavior
|
| 280 |
+
# switch, not a reliable indicator of the deployment's transport security,
|
| 281 |
+
# so a deployment can legitimately run DEBUG=False (production-like
|
| 282 |
+
# behavior) while still being served over plain HTTP. Leaving this True
|
| 283 |
+
# for such a deployment sets the cookie Secure, which a browser then
|
| 284 |
+
# won't send back over a plain HTTP connection -- /admin/login would
|
| 285 |
+
# appear to succeed while every subsequent authenticated /admin/* request
|
| 286 |
+
# came back 401.
|
| 287 |
+
#
|
| 288 |
+
# Read from the environment, like TRUSTED_PROXY_COUNT above, so a
|
| 289 |
+
# deployment (e.g. render.yaml's Render blueprint, which sets this to
|
| 290 |
+
# "true" explicitly) can configure it without editing this file. Defaults
|
| 291 |
+
# to False (via SESSION_COOKIE_SECURE unset or anything other than
|
| 292 |
+
# "true") because that's what a local installation needs (README.md's
|
| 293 |
+
# "Installation"): `python app.py` served over http://127.0.0.1:7860 --
|
| 294 |
+
# plain HTTP -- after editing only TABLEBASE_PATH above, with DEBUG and
|
| 295 |
+
# this setting both left at their defaults.
|
| 296 |
+
#
|
| 297 |
+
# Set this to "true" for any deployment the browser reaches over HTTPS,
|
| 298 |
+
# including one behind a TLS-terminating reverse proxy such as Render's
|
| 299 |
+
# (the browser's own connection is what the Secure attribute governs, not
|
| 300 |
+
# the internal hop between the proxy and this process).
|
| 301 |
+
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "false").strip().lower() == "true"
|
| 302 |
+
|
| 303 |
+
# ── Links ────────────────────────────────────────────────────────────────────
|
| 304 |
+
|
| 305 |
+
# Repository URL for the source repository button shown in the header of every page.
|
| 306 |
+
# Left empty, the button is omitted entirely. Also readable from a
|
| 307 |
+
# SOURCE_REPOSITORY_URL environment variable (falling back to the literal string
|
| 308 |
+
# below when unset), so a platform deployment can set it from its own
|
| 309 |
+
# dashboard without a commit - see render.yaml.
|
| 310 |
+
SOURCE_REPOSITORY_URL = os.environ.get(
|
| 311 |
+
"SOURCE_REPOSITORY_URL",
|
| 312 |
+
"https://huggingface.co/spaces/chesstb/explorer/tree/main",
|
| 313 |
+
)
|
dtz_anchor.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Resolves rounded DTZ values by cross-checking a position's moves.
|
| 2 |
+
|
| 3 |
+
A rounded DTZ stands for a few candidate values. The root's DTZ is the best
|
| 4 |
+
value among the moves that keep its class (smallest when winning, largest
|
| 5 |
+
when losing), so root candidates that no consistent set of move values can
|
| 6 |
+
produce are discarded, and a move that must supply the root's value is pinned.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import operator
|
| 12 |
+
from typing import NamedTuple, Sequence
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class MoveOption(NamedTuple):
|
| 16 |
+
child: tuple[int, ...] # candidate DTZ values of the position after the move
|
| 17 |
+
value: tuple[int, ...] # the move's DTZ for each candidate in `child`
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class Resolution(NamedTuple):
|
| 21 |
+
root: int | None # exact root DTZ, or None when it cannot be determined
|
| 22 |
+
pinned: dict[int, int] # option index -> exact DTZ of that move
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def candidates(dtz: int, rounded: bool) -> tuple[int, ...]:
|
| 26 |
+
"""True values allowed by CORRECTNESS.md Theorem 12.15.
|
| 27 |
+
|
| 28 |
+
Rounded derived values can be even. An overestimate of one is only
|
| 29 |
+
possible at an odd true value, so an odd report cannot stand for the
|
| 30 |
+
preceding even value. Unrounded reports are exact.
|
| 31 |
+
"""
|
| 32 |
+
if not rounded:
|
| 33 |
+
return (dtz,)
|
| 34 |
+
if dtz % 2 == 1:
|
| 35 |
+
return (dtz, dtz + 1)
|
| 36 |
+
return (dtz - 1, dtz, dtz + 1)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def move_option(child_dtz: int, child_rounded: bool, zeroing: bool,
|
| 40 |
+
cursed_exit: bool) -> MoveOption:
|
| 41 |
+
"""A zeroing move is worth 1. A quiet move is worth one more than its
|
| 42 |
+
child, forced odd when it is a cursed exit."""
|
| 43 |
+
if zeroing:
|
| 44 |
+
return MoveOption((), (1,))
|
| 45 |
+
child = candidates(child_dtz, child_rounded)
|
| 46 |
+
value = tuple(((c + 1) | 1) if cursed_exit else c + 1 for c in child)
|
| 47 |
+
return MoveOption(child, value)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def resolve(root_dtz: int, root_rounded: bool, options: Sequence[MoveOption],
|
| 51 |
+
winning: bool) -> Resolution:
|
| 52 |
+
"""`options` are the moves that keep the root's class."""
|
| 53 |
+
at_least_as_good = operator.ge if winning else operator.le
|
| 54 |
+
feasible = [
|
| 55 |
+
x for x in candidates(root_dtz, root_rounded)
|
| 56 |
+
if all(any(at_least_as_good(v, x) for v in o.value) for o in options)
|
| 57 |
+
and any(x in o.value for o in options)
|
| 58 |
+
]
|
| 59 |
+
if len(feasible) != 1:
|
| 60 |
+
return Resolution(None, {})
|
| 61 |
+
x = feasible[0]
|
| 62 |
+
pinned: dict[int, int] = {}
|
| 63 |
+
for k, option in enumerate(options):
|
| 64 |
+
if len(set(option.value)) < 2:
|
| 65 |
+
continue
|
| 66 |
+
others_attain = any(x in other.value
|
| 67 |
+
for j, other in enumerate(options) if j != k)
|
| 68 |
+
kept = {v for v in option.value
|
| 69 |
+
if at_least_as_good(v, x) and (v == x or others_attain)}
|
| 70 |
+
if len(kept) == 1:
|
| 71 |
+
pinned[k] = kept.pop()
|
| 72 |
+
return Resolution(x, pinned)
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
frontend/admin.html
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<link rel="stylesheet" href="/src/styles/admin.css" />
|
| 5 |
+
</head>
|
| 6 |
+
<body></body>
|
| 7 |
+
</html>
|
frontend/index.html
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en" class="explorer">
|
| 3 |
+
<body>
|
| 4 |
+
<div id="root"></div>
|
| 5 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 6 |
+
</body>
|
| 7 |
+
</html>
|
frontend/package-lock.json
ADDED
|
@@ -0,0 +1,1698 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "chesstb-explorer-frontend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"lockfileVersion": 3,
|
| 5 |
+
"requires": true,
|
| 6 |
+
"packages": {
|
| 7 |
+
"": {
|
| 8 |
+
"name": "chesstb-explorer-frontend",
|
| 9 |
+
"version": "1.0.0",
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"react": "^18.3.1",
|
| 12 |
+
"react-dom": "^18.3.1"
|
| 13 |
+
},
|
| 14 |
+
"devDependencies": {
|
| 15 |
+
"@vitejs/plugin-react": "^4.3.4",
|
| 16 |
+
"vite": "^5.4.11"
|
| 17 |
+
}
|
| 18 |
+
},
|
| 19 |
+
"node_modules/@babel/code-frame": {
|
| 20 |
+
"version": "7.29.7",
|
| 21 |
+
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
| 22 |
+
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
| 23 |
+
"dev": true,
|
| 24 |
+
"license": "MIT",
|
| 25 |
+
"dependencies": {
|
| 26 |
+
"@babel/helper-validator-identifier": "^7.29.7",
|
| 27 |
+
"js-tokens": "^4.0.0",
|
| 28 |
+
"picocolors": "^1.1.1"
|
| 29 |
+
},
|
| 30 |
+
"engines": {
|
| 31 |
+
"node": ">=6.9.0"
|
| 32 |
+
}
|
| 33 |
+
},
|
| 34 |
+
"node_modules/@babel/compat-data": {
|
| 35 |
+
"version": "7.29.7",
|
| 36 |
+
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
| 37 |
+
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
| 38 |
+
"dev": true,
|
| 39 |
+
"license": "MIT",
|
| 40 |
+
"engines": {
|
| 41 |
+
"node": ">=6.9.0"
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
"node_modules/@babel/core": {
|
| 45 |
+
"version": "7.29.7",
|
| 46 |
+
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
| 47 |
+
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
| 48 |
+
"dev": true,
|
| 49 |
+
"license": "MIT",
|
| 50 |
+
"dependencies": {
|
| 51 |
+
"@babel/code-frame": "^7.29.7",
|
| 52 |
+
"@babel/generator": "^7.29.7",
|
| 53 |
+
"@babel/helper-compilation-targets": "^7.29.7",
|
| 54 |
+
"@babel/helper-module-transforms": "^7.29.7",
|
| 55 |
+
"@babel/helpers": "^7.29.7",
|
| 56 |
+
"@babel/parser": "^7.29.7",
|
| 57 |
+
"@babel/template": "^7.29.7",
|
| 58 |
+
"@babel/traverse": "^7.29.7",
|
| 59 |
+
"@babel/types": "^7.29.7",
|
| 60 |
+
"@jridgewell/remapping": "^2.3.5",
|
| 61 |
+
"convert-source-map": "^2.0.0",
|
| 62 |
+
"debug": "^4.1.0",
|
| 63 |
+
"gensync": "^1.0.0-beta.2",
|
| 64 |
+
"json5": "^2.2.3",
|
| 65 |
+
"semver": "^6.3.1"
|
| 66 |
+
},
|
| 67 |
+
"engines": {
|
| 68 |
+
"node": ">=6.9.0"
|
| 69 |
+
},
|
| 70 |
+
"funding": {
|
| 71 |
+
"type": "opencollective",
|
| 72 |
+
"url": "https://opencollective.com/babel"
|
| 73 |
+
}
|
| 74 |
+
},
|
| 75 |
+
"node_modules/@babel/generator": {
|
| 76 |
+
"version": "7.29.8",
|
| 77 |
+
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
|
| 78 |
+
"integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
|
| 79 |
+
"dev": true,
|
| 80 |
+
"license": "MIT",
|
| 81 |
+
"dependencies": {
|
| 82 |
+
"@babel/parser": "^7.29.8",
|
| 83 |
+
"@babel/types": "^7.29.8",
|
| 84 |
+
"@jridgewell/gen-mapping": "^0.3.12",
|
| 85 |
+
"@jridgewell/trace-mapping": "^0.3.28",
|
| 86 |
+
"jsesc": "^3.0.2"
|
| 87 |
+
},
|
| 88 |
+
"engines": {
|
| 89 |
+
"node": ">=6.9.0"
|
| 90 |
+
}
|
| 91 |
+
},
|
| 92 |
+
"node_modules/@babel/helper-compilation-targets": {
|
| 93 |
+
"version": "7.29.7",
|
| 94 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
| 95 |
+
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
| 96 |
+
"dev": true,
|
| 97 |
+
"license": "MIT",
|
| 98 |
+
"dependencies": {
|
| 99 |
+
"@babel/compat-data": "^7.29.7",
|
| 100 |
+
"@babel/helper-validator-option": "^7.29.7",
|
| 101 |
+
"browserslist": "^4.24.0",
|
| 102 |
+
"lru-cache": "^5.1.1",
|
| 103 |
+
"semver": "^6.3.1"
|
| 104 |
+
},
|
| 105 |
+
"engines": {
|
| 106 |
+
"node": ">=6.9.0"
|
| 107 |
+
}
|
| 108 |
+
},
|
| 109 |
+
"node_modules/@babel/helper-globals": {
|
| 110 |
+
"version": "7.29.7",
|
| 111 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
| 112 |
+
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
| 113 |
+
"dev": true,
|
| 114 |
+
"license": "MIT",
|
| 115 |
+
"engines": {
|
| 116 |
+
"node": ">=6.9.0"
|
| 117 |
+
}
|
| 118 |
+
},
|
| 119 |
+
"node_modules/@babel/helper-module-imports": {
|
| 120 |
+
"version": "7.29.7",
|
| 121 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
| 122 |
+
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
| 123 |
+
"dev": true,
|
| 124 |
+
"license": "MIT",
|
| 125 |
+
"dependencies": {
|
| 126 |
+
"@babel/traverse": "^7.29.7",
|
| 127 |
+
"@babel/types": "^7.29.7"
|
| 128 |
+
},
|
| 129 |
+
"engines": {
|
| 130 |
+
"node": ">=6.9.0"
|
| 131 |
+
}
|
| 132 |
+
},
|
| 133 |
+
"node_modules/@babel/helper-module-transforms": {
|
| 134 |
+
"version": "7.29.7",
|
| 135 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
| 136 |
+
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
| 137 |
+
"dev": true,
|
| 138 |
+
"license": "MIT",
|
| 139 |
+
"dependencies": {
|
| 140 |
+
"@babel/helper-module-imports": "^7.29.7",
|
| 141 |
+
"@babel/helper-validator-identifier": "^7.29.7",
|
| 142 |
+
"@babel/traverse": "^7.29.7"
|
| 143 |
+
},
|
| 144 |
+
"engines": {
|
| 145 |
+
"node": ">=6.9.0"
|
| 146 |
+
},
|
| 147 |
+
"peerDependencies": {
|
| 148 |
+
"@babel/core": "^7.0.0"
|
| 149 |
+
}
|
| 150 |
+
},
|
| 151 |
+
"node_modules/@babel/helper-plugin-utils": {
|
| 152 |
+
"version": "7.29.7",
|
| 153 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
|
| 154 |
+
"integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
|
| 155 |
+
"dev": true,
|
| 156 |
+
"license": "MIT",
|
| 157 |
+
"engines": {
|
| 158 |
+
"node": ">=6.9.0"
|
| 159 |
+
}
|
| 160 |
+
},
|
| 161 |
+
"node_modules/@babel/helper-string-parser": {
|
| 162 |
+
"version": "7.29.7",
|
| 163 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
| 164 |
+
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
| 165 |
+
"dev": true,
|
| 166 |
+
"license": "MIT",
|
| 167 |
+
"engines": {
|
| 168 |
+
"node": ">=6.9.0"
|
| 169 |
+
}
|
| 170 |
+
},
|
| 171 |
+
"node_modules/@babel/helper-validator-identifier": {
|
| 172 |
+
"version": "7.29.7",
|
| 173 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
| 174 |
+
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
| 175 |
+
"dev": true,
|
| 176 |
+
"license": "MIT",
|
| 177 |
+
"engines": {
|
| 178 |
+
"node": ">=6.9.0"
|
| 179 |
+
}
|
| 180 |
+
},
|
| 181 |
+
"node_modules/@babel/helper-validator-option": {
|
| 182 |
+
"version": "7.29.7",
|
| 183 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
| 184 |
+
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
| 185 |
+
"dev": true,
|
| 186 |
+
"license": "MIT",
|
| 187 |
+
"engines": {
|
| 188 |
+
"node": ">=6.9.0"
|
| 189 |
+
}
|
| 190 |
+
},
|
| 191 |
+
"node_modules/@babel/helpers": {
|
| 192 |
+
"version": "7.29.7",
|
| 193 |
+
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
| 194 |
+
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
| 195 |
+
"dev": true,
|
| 196 |
+
"license": "MIT",
|
| 197 |
+
"dependencies": {
|
| 198 |
+
"@babel/template": "^7.29.7",
|
| 199 |
+
"@babel/types": "^7.29.7"
|
| 200 |
+
},
|
| 201 |
+
"engines": {
|
| 202 |
+
"node": ">=6.9.0"
|
| 203 |
+
}
|
| 204 |
+
},
|
| 205 |
+
"node_modules/@babel/parser": {
|
| 206 |
+
"version": "7.29.8",
|
| 207 |
+
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
|
| 208 |
+
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
|
| 209 |
+
"dev": true,
|
| 210 |
+
"license": "MIT",
|
| 211 |
+
"dependencies": {
|
| 212 |
+
"@babel/types": "^7.29.8"
|
| 213 |
+
},
|
| 214 |
+
"bin": {
|
| 215 |
+
"parser": "bin/babel-parser.js"
|
| 216 |
+
},
|
| 217 |
+
"engines": {
|
| 218 |
+
"node": ">=6.0.0"
|
| 219 |
+
}
|
| 220 |
+
},
|
| 221 |
+
"node_modules/@babel/plugin-transform-react-jsx-self": {
|
| 222 |
+
"version": "7.29.7",
|
| 223 |
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
|
| 224 |
+
"integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
|
| 225 |
+
"dev": true,
|
| 226 |
+
"license": "MIT",
|
| 227 |
+
"dependencies": {
|
| 228 |
+
"@babel/helper-plugin-utils": "^7.29.7"
|
| 229 |
+
},
|
| 230 |
+
"engines": {
|
| 231 |
+
"node": ">=6.9.0"
|
| 232 |
+
},
|
| 233 |
+
"peerDependencies": {
|
| 234 |
+
"@babel/core": "^7.0.0-0"
|
| 235 |
+
}
|
| 236 |
+
},
|
| 237 |
+
"node_modules/@babel/plugin-transform-react-jsx-source": {
|
| 238 |
+
"version": "7.29.7",
|
| 239 |
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
|
| 240 |
+
"integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
|
| 241 |
+
"dev": true,
|
| 242 |
+
"license": "MIT",
|
| 243 |
+
"dependencies": {
|
| 244 |
+
"@babel/helper-plugin-utils": "^7.29.7"
|
| 245 |
+
},
|
| 246 |
+
"engines": {
|
| 247 |
+
"node": ">=6.9.0"
|
| 248 |
+
},
|
| 249 |
+
"peerDependencies": {
|
| 250 |
+
"@babel/core": "^7.0.0-0"
|
| 251 |
+
}
|
| 252 |
+
},
|
| 253 |
+
"node_modules/@babel/template": {
|
| 254 |
+
"version": "7.29.7",
|
| 255 |
+
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
| 256 |
+
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
| 257 |
+
"dev": true,
|
| 258 |
+
"license": "MIT",
|
| 259 |
+
"dependencies": {
|
| 260 |
+
"@babel/code-frame": "^7.29.7",
|
| 261 |
+
"@babel/parser": "^7.29.7",
|
| 262 |
+
"@babel/types": "^7.29.7"
|
| 263 |
+
},
|
| 264 |
+
"engines": {
|
| 265 |
+
"node": ">=6.9.0"
|
| 266 |
+
}
|
| 267 |
+
},
|
| 268 |
+
"node_modules/@babel/traverse": {
|
| 269 |
+
"version": "7.29.8",
|
| 270 |
+
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
|
| 271 |
+
"integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
|
| 272 |
+
"dev": true,
|
| 273 |
+
"license": "MIT",
|
| 274 |
+
"dependencies": {
|
| 275 |
+
"@babel/code-frame": "^7.29.7",
|
| 276 |
+
"@babel/generator": "^7.29.8",
|
| 277 |
+
"@babel/helper-globals": "^7.29.7",
|
| 278 |
+
"@babel/parser": "^7.29.8",
|
| 279 |
+
"@babel/template": "^7.29.7",
|
| 280 |
+
"@babel/types": "^7.29.8",
|
| 281 |
+
"debug": "^4.3.1"
|
| 282 |
+
},
|
| 283 |
+
"engines": {
|
| 284 |
+
"node": ">=6.9.0"
|
| 285 |
+
}
|
| 286 |
+
},
|
| 287 |
+
"node_modules/@babel/types": {
|
| 288 |
+
"version": "7.29.8",
|
| 289 |
+
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
| 290 |
+
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
| 291 |
+
"dev": true,
|
| 292 |
+
"license": "MIT",
|
| 293 |
+
"dependencies": {
|
| 294 |
+
"@babel/helper-string-parser": "^7.29.7",
|
| 295 |
+
"@babel/helper-validator-identifier": "^7.29.7"
|
| 296 |
+
},
|
| 297 |
+
"engines": {
|
| 298 |
+
"node": ">=6.9.0"
|
| 299 |
+
}
|
| 300 |
+
},
|
| 301 |
+
"node_modules/@esbuild/aix-ppc64": {
|
| 302 |
+
"version": "0.21.5",
|
| 303 |
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
| 304 |
+
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
| 305 |
+
"cpu": [
|
| 306 |
+
"ppc64"
|
| 307 |
+
],
|
| 308 |
+
"dev": true,
|
| 309 |
+
"license": "MIT",
|
| 310 |
+
"optional": true,
|
| 311 |
+
"os": [
|
| 312 |
+
"aix"
|
| 313 |
+
],
|
| 314 |
+
"engines": {
|
| 315 |
+
"node": ">=12"
|
| 316 |
+
}
|
| 317 |
+
},
|
| 318 |
+
"node_modules/@esbuild/android-arm": {
|
| 319 |
+
"version": "0.21.5",
|
| 320 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
| 321 |
+
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
| 322 |
+
"cpu": [
|
| 323 |
+
"arm"
|
| 324 |
+
],
|
| 325 |
+
"dev": true,
|
| 326 |
+
"license": "MIT",
|
| 327 |
+
"optional": true,
|
| 328 |
+
"os": [
|
| 329 |
+
"android"
|
| 330 |
+
],
|
| 331 |
+
"engines": {
|
| 332 |
+
"node": ">=12"
|
| 333 |
+
}
|
| 334 |
+
},
|
| 335 |
+
"node_modules/@esbuild/android-arm64": {
|
| 336 |
+
"version": "0.21.5",
|
| 337 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
| 338 |
+
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
| 339 |
+
"cpu": [
|
| 340 |
+
"arm64"
|
| 341 |
+
],
|
| 342 |
+
"dev": true,
|
| 343 |
+
"license": "MIT",
|
| 344 |
+
"optional": true,
|
| 345 |
+
"os": [
|
| 346 |
+
"android"
|
| 347 |
+
],
|
| 348 |
+
"engines": {
|
| 349 |
+
"node": ">=12"
|
| 350 |
+
}
|
| 351 |
+
},
|
| 352 |
+
"node_modules/@esbuild/android-x64": {
|
| 353 |
+
"version": "0.21.5",
|
| 354 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
| 355 |
+
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
| 356 |
+
"cpu": [
|
| 357 |
+
"x64"
|
| 358 |
+
],
|
| 359 |
+
"dev": true,
|
| 360 |
+
"license": "MIT",
|
| 361 |
+
"optional": true,
|
| 362 |
+
"os": [
|
| 363 |
+
"android"
|
| 364 |
+
],
|
| 365 |
+
"engines": {
|
| 366 |
+
"node": ">=12"
|
| 367 |
+
}
|
| 368 |
+
},
|
| 369 |
+
"node_modules/@esbuild/darwin-arm64": {
|
| 370 |
+
"version": "0.21.5",
|
| 371 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
| 372 |
+
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
| 373 |
+
"cpu": [
|
| 374 |
+
"arm64"
|
| 375 |
+
],
|
| 376 |
+
"dev": true,
|
| 377 |
+
"license": "MIT",
|
| 378 |
+
"optional": true,
|
| 379 |
+
"os": [
|
| 380 |
+
"darwin"
|
| 381 |
+
],
|
| 382 |
+
"engines": {
|
| 383 |
+
"node": ">=12"
|
| 384 |
+
}
|
| 385 |
+
},
|
| 386 |
+
"node_modules/@esbuild/darwin-x64": {
|
| 387 |
+
"version": "0.21.5",
|
| 388 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
| 389 |
+
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
| 390 |
+
"cpu": [
|
| 391 |
+
"x64"
|
| 392 |
+
],
|
| 393 |
+
"dev": true,
|
| 394 |
+
"license": "MIT",
|
| 395 |
+
"optional": true,
|
| 396 |
+
"os": [
|
| 397 |
+
"darwin"
|
| 398 |
+
],
|
| 399 |
+
"engines": {
|
| 400 |
+
"node": ">=12"
|
| 401 |
+
}
|
| 402 |
+
},
|
| 403 |
+
"node_modules/@esbuild/freebsd-arm64": {
|
| 404 |
+
"version": "0.21.5",
|
| 405 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
| 406 |
+
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
| 407 |
+
"cpu": [
|
| 408 |
+
"arm64"
|
| 409 |
+
],
|
| 410 |
+
"dev": true,
|
| 411 |
+
"license": "MIT",
|
| 412 |
+
"optional": true,
|
| 413 |
+
"os": [
|
| 414 |
+
"freebsd"
|
| 415 |
+
],
|
| 416 |
+
"engines": {
|
| 417 |
+
"node": ">=12"
|
| 418 |
+
}
|
| 419 |
+
},
|
| 420 |
+
"node_modules/@esbuild/freebsd-x64": {
|
| 421 |
+
"version": "0.21.5",
|
| 422 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
| 423 |
+
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
| 424 |
+
"cpu": [
|
| 425 |
+
"x64"
|
| 426 |
+
],
|
| 427 |
+
"dev": true,
|
| 428 |
+
"license": "MIT",
|
| 429 |
+
"optional": true,
|
| 430 |
+
"os": [
|
| 431 |
+
"freebsd"
|
| 432 |
+
],
|
| 433 |
+
"engines": {
|
| 434 |
+
"node": ">=12"
|
| 435 |
+
}
|
| 436 |
+
},
|
| 437 |
+
"node_modules/@esbuild/linux-arm": {
|
| 438 |
+
"version": "0.21.5",
|
| 439 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
| 440 |
+
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
| 441 |
+
"cpu": [
|
| 442 |
+
"arm"
|
| 443 |
+
],
|
| 444 |
+
"dev": true,
|
| 445 |
+
"license": "MIT",
|
| 446 |
+
"optional": true,
|
| 447 |
+
"os": [
|
| 448 |
+
"linux"
|
| 449 |
+
],
|
| 450 |
+
"engines": {
|
| 451 |
+
"node": ">=12"
|
| 452 |
+
}
|
| 453 |
+
},
|
| 454 |
+
"node_modules/@esbuild/linux-arm64": {
|
| 455 |
+
"version": "0.21.5",
|
| 456 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
| 457 |
+
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
| 458 |
+
"cpu": [
|
| 459 |
+
"arm64"
|
| 460 |
+
],
|
| 461 |
+
"dev": true,
|
| 462 |
+
"license": "MIT",
|
| 463 |
+
"optional": true,
|
| 464 |
+
"os": [
|
| 465 |
+
"linux"
|
| 466 |
+
],
|
| 467 |
+
"engines": {
|
| 468 |
+
"node": ">=12"
|
| 469 |
+
}
|
| 470 |
+
},
|
| 471 |
+
"node_modules/@esbuild/linux-ia32": {
|
| 472 |
+
"version": "0.21.5",
|
| 473 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
| 474 |
+
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
| 475 |
+
"cpu": [
|
| 476 |
+
"ia32"
|
| 477 |
+
],
|
| 478 |
+
"dev": true,
|
| 479 |
+
"license": "MIT",
|
| 480 |
+
"optional": true,
|
| 481 |
+
"os": [
|
| 482 |
+
"linux"
|
| 483 |
+
],
|
| 484 |
+
"engines": {
|
| 485 |
+
"node": ">=12"
|
| 486 |
+
}
|
| 487 |
+
},
|
| 488 |
+
"node_modules/@esbuild/linux-loong64": {
|
| 489 |
+
"version": "0.21.5",
|
| 490 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
| 491 |
+
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
| 492 |
+
"cpu": [
|
| 493 |
+
"loong64"
|
| 494 |
+
],
|
| 495 |
+
"dev": true,
|
| 496 |
+
"license": "MIT",
|
| 497 |
+
"optional": true,
|
| 498 |
+
"os": [
|
| 499 |
+
"linux"
|
| 500 |
+
],
|
| 501 |
+
"engines": {
|
| 502 |
+
"node": ">=12"
|
| 503 |
+
}
|
| 504 |
+
},
|
| 505 |
+
"node_modules/@esbuild/linux-mips64el": {
|
| 506 |
+
"version": "0.21.5",
|
| 507 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
| 508 |
+
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
| 509 |
+
"cpu": [
|
| 510 |
+
"mips64el"
|
| 511 |
+
],
|
| 512 |
+
"dev": true,
|
| 513 |
+
"license": "MIT",
|
| 514 |
+
"optional": true,
|
| 515 |
+
"os": [
|
| 516 |
+
"linux"
|
| 517 |
+
],
|
| 518 |
+
"engines": {
|
| 519 |
+
"node": ">=12"
|
| 520 |
+
}
|
| 521 |
+
},
|
| 522 |
+
"node_modules/@esbuild/linux-ppc64": {
|
| 523 |
+
"version": "0.21.5",
|
| 524 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
| 525 |
+
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
| 526 |
+
"cpu": [
|
| 527 |
+
"ppc64"
|
| 528 |
+
],
|
| 529 |
+
"dev": true,
|
| 530 |
+
"license": "MIT",
|
| 531 |
+
"optional": true,
|
| 532 |
+
"os": [
|
| 533 |
+
"linux"
|
| 534 |
+
],
|
| 535 |
+
"engines": {
|
| 536 |
+
"node": ">=12"
|
| 537 |
+
}
|
| 538 |
+
},
|
| 539 |
+
"node_modules/@esbuild/linux-riscv64": {
|
| 540 |
+
"version": "0.21.5",
|
| 541 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
| 542 |
+
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
| 543 |
+
"cpu": [
|
| 544 |
+
"riscv64"
|
| 545 |
+
],
|
| 546 |
+
"dev": true,
|
| 547 |
+
"license": "MIT",
|
| 548 |
+
"optional": true,
|
| 549 |
+
"os": [
|
| 550 |
+
"linux"
|
| 551 |
+
],
|
| 552 |
+
"engines": {
|
| 553 |
+
"node": ">=12"
|
| 554 |
+
}
|
| 555 |
+
},
|
| 556 |
+
"node_modules/@esbuild/linux-s390x": {
|
| 557 |
+
"version": "0.21.5",
|
| 558 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
| 559 |
+
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
| 560 |
+
"cpu": [
|
| 561 |
+
"s390x"
|
| 562 |
+
],
|
| 563 |
+
"dev": true,
|
| 564 |
+
"license": "MIT",
|
| 565 |
+
"optional": true,
|
| 566 |
+
"os": [
|
| 567 |
+
"linux"
|
| 568 |
+
],
|
| 569 |
+
"engines": {
|
| 570 |
+
"node": ">=12"
|
| 571 |
+
}
|
| 572 |
+
},
|
| 573 |
+
"node_modules/@esbuild/linux-x64": {
|
| 574 |
+
"version": "0.21.5",
|
| 575 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
| 576 |
+
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
| 577 |
+
"cpu": [
|
| 578 |
+
"x64"
|
| 579 |
+
],
|
| 580 |
+
"dev": true,
|
| 581 |
+
"license": "MIT",
|
| 582 |
+
"optional": true,
|
| 583 |
+
"os": [
|
| 584 |
+
"linux"
|
| 585 |
+
],
|
| 586 |
+
"engines": {
|
| 587 |
+
"node": ">=12"
|
| 588 |
+
}
|
| 589 |
+
},
|
| 590 |
+
"node_modules/@esbuild/netbsd-x64": {
|
| 591 |
+
"version": "0.21.5",
|
| 592 |
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
| 593 |
+
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
| 594 |
+
"cpu": [
|
| 595 |
+
"x64"
|
| 596 |
+
],
|
| 597 |
+
"dev": true,
|
| 598 |
+
"license": "MIT",
|
| 599 |
+
"optional": true,
|
| 600 |
+
"os": [
|
| 601 |
+
"netbsd"
|
| 602 |
+
],
|
| 603 |
+
"engines": {
|
| 604 |
+
"node": ">=12"
|
| 605 |
+
}
|
| 606 |
+
},
|
| 607 |
+
"node_modules/@esbuild/openbsd-x64": {
|
| 608 |
+
"version": "0.21.5",
|
| 609 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
| 610 |
+
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
| 611 |
+
"cpu": [
|
| 612 |
+
"x64"
|
| 613 |
+
],
|
| 614 |
+
"dev": true,
|
| 615 |
+
"license": "MIT",
|
| 616 |
+
"optional": true,
|
| 617 |
+
"os": [
|
| 618 |
+
"openbsd"
|
| 619 |
+
],
|
| 620 |
+
"engines": {
|
| 621 |
+
"node": ">=12"
|
| 622 |
+
}
|
| 623 |
+
},
|
| 624 |
+
"node_modules/@esbuild/sunos-x64": {
|
| 625 |
+
"version": "0.21.5",
|
| 626 |
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
| 627 |
+
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
| 628 |
+
"cpu": [
|
| 629 |
+
"x64"
|
| 630 |
+
],
|
| 631 |
+
"dev": true,
|
| 632 |
+
"license": "MIT",
|
| 633 |
+
"optional": true,
|
| 634 |
+
"os": [
|
| 635 |
+
"sunos"
|
| 636 |
+
],
|
| 637 |
+
"engines": {
|
| 638 |
+
"node": ">=12"
|
| 639 |
+
}
|
| 640 |
+
},
|
| 641 |
+
"node_modules/@esbuild/win32-arm64": {
|
| 642 |
+
"version": "0.21.5",
|
| 643 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
| 644 |
+
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
| 645 |
+
"cpu": [
|
| 646 |
+
"arm64"
|
| 647 |
+
],
|
| 648 |
+
"dev": true,
|
| 649 |
+
"license": "MIT",
|
| 650 |
+
"optional": true,
|
| 651 |
+
"os": [
|
| 652 |
+
"win32"
|
| 653 |
+
],
|
| 654 |
+
"engines": {
|
| 655 |
+
"node": ">=12"
|
| 656 |
+
}
|
| 657 |
+
},
|
| 658 |
+
"node_modules/@esbuild/win32-ia32": {
|
| 659 |
+
"version": "0.21.5",
|
| 660 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
| 661 |
+
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
| 662 |
+
"cpu": [
|
| 663 |
+
"ia32"
|
| 664 |
+
],
|
| 665 |
+
"dev": true,
|
| 666 |
+
"license": "MIT",
|
| 667 |
+
"optional": true,
|
| 668 |
+
"os": [
|
| 669 |
+
"win32"
|
| 670 |
+
],
|
| 671 |
+
"engines": {
|
| 672 |
+
"node": ">=12"
|
| 673 |
+
}
|
| 674 |
+
},
|
| 675 |
+
"node_modules/@esbuild/win32-x64": {
|
| 676 |
+
"version": "0.21.5",
|
| 677 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
| 678 |
+
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
| 679 |
+
"cpu": [
|
| 680 |
+
"x64"
|
| 681 |
+
],
|
| 682 |
+
"dev": true,
|
| 683 |
+
"license": "MIT",
|
| 684 |
+
"optional": true,
|
| 685 |
+
"os": [
|
| 686 |
+
"win32"
|
| 687 |
+
],
|
| 688 |
+
"engines": {
|
| 689 |
+
"node": ">=12"
|
| 690 |
+
}
|
| 691 |
+
},
|
| 692 |
+
"node_modules/@jridgewell/gen-mapping": {
|
| 693 |
+
"version": "0.3.13",
|
| 694 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
| 695 |
+
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
| 696 |
+
"dev": true,
|
| 697 |
+
"license": "MIT",
|
| 698 |
+
"dependencies": {
|
| 699 |
+
"@jridgewell/sourcemap-codec": "^1.5.0",
|
| 700 |
+
"@jridgewell/trace-mapping": "^0.3.24"
|
| 701 |
+
}
|
| 702 |
+
},
|
| 703 |
+
"node_modules/@jridgewell/remapping": {
|
| 704 |
+
"version": "2.3.5",
|
| 705 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
| 706 |
+
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
| 707 |
+
"dev": true,
|
| 708 |
+
"license": "MIT",
|
| 709 |
+
"dependencies": {
|
| 710 |
+
"@jridgewell/gen-mapping": "^0.3.5",
|
| 711 |
+
"@jridgewell/trace-mapping": "^0.3.24"
|
| 712 |
+
}
|
| 713 |
+
},
|
| 714 |
+
"node_modules/@jridgewell/resolve-uri": {
|
| 715 |
+
"version": "3.1.2",
|
| 716 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
| 717 |
+
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
| 718 |
+
"dev": true,
|
| 719 |
+
"license": "MIT",
|
| 720 |
+
"engines": {
|
| 721 |
+
"node": ">=6.0.0"
|
| 722 |
+
}
|
| 723 |
+
},
|
| 724 |
+
"node_modules/@jridgewell/sourcemap-codec": {
|
| 725 |
+
"version": "1.6.0",
|
| 726 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
| 727 |
+
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
| 728 |
+
"dev": true,
|
| 729 |
+
"license": "MIT"
|
| 730 |
+
},
|
| 731 |
+
"node_modules/@jridgewell/trace-mapping": {
|
| 732 |
+
"version": "0.3.31",
|
| 733 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
| 734 |
+
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
| 735 |
+
"dev": true,
|
| 736 |
+
"license": "MIT",
|
| 737 |
+
"dependencies": {
|
| 738 |
+
"@jridgewell/resolve-uri": "^3.1.0",
|
| 739 |
+
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 740 |
+
}
|
| 741 |
+
},
|
| 742 |
+
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
| 743 |
+
"version": "1.5.1",
|
| 744 |
+
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
| 745 |
+
"integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
|
| 746 |
+
"cpu": [
|
| 747 |
+
"x64"
|
| 748 |
+
],
|
| 749 |
+
"dev": true,
|
| 750 |
+
"license": "MIT",
|
| 751 |
+
"optional": true,
|
| 752 |
+
"os": [
|
| 753 |
+
"linux"
|
| 754 |
+
],
|
| 755 |
+
"engines": {
|
| 756 |
+
"node": "^22.20 || ^24.12 || >=25"
|
| 757 |
+
}
|
| 758 |
+
},
|
| 759 |
+
"node_modules/@rolldown/pluginutils": {
|
| 760 |
+
"version": "1.0.0-beta.27",
|
| 761 |
+
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
| 762 |
+
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
| 763 |
+
"dev": true,
|
| 764 |
+
"license": "MIT"
|
| 765 |
+
},
|
| 766 |
+
"node_modules/@rollup/rollup-android-arm-eabi": {
|
| 767 |
+
"version": "4.63.3",
|
| 768 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz",
|
| 769 |
+
"integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==",
|
| 770 |
+
"cpu": [
|
| 771 |
+
"arm"
|
| 772 |
+
],
|
| 773 |
+
"dev": true,
|
| 774 |
+
"license": "MIT",
|
| 775 |
+
"optional": true,
|
| 776 |
+
"os": [
|
| 777 |
+
"android"
|
| 778 |
+
]
|
| 779 |
+
},
|
| 780 |
+
"node_modules/@rollup/rollup-android-arm64": {
|
| 781 |
+
"version": "4.63.3",
|
| 782 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz",
|
| 783 |
+
"integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==",
|
| 784 |
+
"cpu": [
|
| 785 |
+
"arm64"
|
| 786 |
+
],
|
| 787 |
+
"dev": true,
|
| 788 |
+
"license": "MIT",
|
| 789 |
+
"optional": true,
|
| 790 |
+
"os": [
|
| 791 |
+
"android"
|
| 792 |
+
]
|
| 793 |
+
},
|
| 794 |
+
"node_modules/@rollup/rollup-darwin-arm64": {
|
| 795 |
+
"version": "4.63.3",
|
| 796 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz",
|
| 797 |
+
"integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==",
|
| 798 |
+
"cpu": [
|
| 799 |
+
"arm64"
|
| 800 |
+
],
|
| 801 |
+
"dev": true,
|
| 802 |
+
"license": "MIT",
|
| 803 |
+
"optional": true,
|
| 804 |
+
"os": [
|
| 805 |
+
"darwin"
|
| 806 |
+
]
|
| 807 |
+
},
|
| 808 |
+
"node_modules/@rollup/rollup-darwin-x64": {
|
| 809 |
+
"version": "4.63.3",
|
| 810 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz",
|
| 811 |
+
"integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==",
|
| 812 |
+
"cpu": [
|
| 813 |
+
"x64"
|
| 814 |
+
],
|
| 815 |
+
"dev": true,
|
| 816 |
+
"license": "MIT",
|
| 817 |
+
"optional": true,
|
| 818 |
+
"os": [
|
| 819 |
+
"darwin"
|
| 820 |
+
]
|
| 821 |
+
},
|
| 822 |
+
"node_modules/@rollup/rollup-freebsd-arm64": {
|
| 823 |
+
"version": "4.63.3",
|
| 824 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz",
|
| 825 |
+
"integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==",
|
| 826 |
+
"cpu": [
|
| 827 |
+
"arm64"
|
| 828 |
+
],
|
| 829 |
+
"dev": true,
|
| 830 |
+
"license": "MIT",
|
| 831 |
+
"optional": true,
|
| 832 |
+
"os": [
|
| 833 |
+
"freebsd"
|
| 834 |
+
]
|
| 835 |
+
},
|
| 836 |
+
"node_modules/@rollup/rollup-freebsd-x64": {
|
| 837 |
+
"version": "4.63.3",
|
| 838 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz",
|
| 839 |
+
"integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==",
|
| 840 |
+
"cpu": [
|
| 841 |
+
"x64"
|
| 842 |
+
],
|
| 843 |
+
"dev": true,
|
| 844 |
+
"license": "MIT",
|
| 845 |
+
"optional": true,
|
| 846 |
+
"os": [
|
| 847 |
+
"freebsd"
|
| 848 |
+
]
|
| 849 |
+
},
|
| 850 |
+
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
| 851 |
+
"version": "4.63.3",
|
| 852 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz",
|
| 853 |
+
"integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==",
|
| 854 |
+
"cpu": [
|
| 855 |
+
"arm"
|
| 856 |
+
],
|
| 857 |
+
"dev": true,
|
| 858 |
+
"license": "MIT",
|
| 859 |
+
"optional": true,
|
| 860 |
+
"os": [
|
| 861 |
+
"linux"
|
| 862 |
+
]
|
| 863 |
+
},
|
| 864 |
+
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
| 865 |
+
"version": "4.63.3",
|
| 866 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz",
|
| 867 |
+
"integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==",
|
| 868 |
+
"cpu": [
|
| 869 |
+
"arm"
|
| 870 |
+
],
|
| 871 |
+
"dev": true,
|
| 872 |
+
"license": "MIT",
|
| 873 |
+
"optional": true,
|
| 874 |
+
"os": [
|
| 875 |
+
"linux"
|
| 876 |
+
]
|
| 877 |
+
},
|
| 878 |
+
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
| 879 |
+
"version": "4.63.3",
|
| 880 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz",
|
| 881 |
+
"integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==",
|
| 882 |
+
"cpu": [
|
| 883 |
+
"arm64"
|
| 884 |
+
],
|
| 885 |
+
"dev": true,
|
| 886 |
+
"license": "MIT",
|
| 887 |
+
"optional": true,
|
| 888 |
+
"os": [
|
| 889 |
+
"linux"
|
| 890 |
+
]
|
| 891 |
+
},
|
| 892 |
+
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
| 893 |
+
"version": "4.63.3",
|
| 894 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz",
|
| 895 |
+
"integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==",
|
| 896 |
+
"cpu": [
|
| 897 |
+
"arm64"
|
| 898 |
+
],
|
| 899 |
+
"dev": true,
|
| 900 |
+
"license": "MIT",
|
| 901 |
+
"optional": true,
|
| 902 |
+
"os": [
|
| 903 |
+
"linux"
|
| 904 |
+
]
|
| 905 |
+
},
|
| 906 |
+
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
| 907 |
+
"version": "4.63.3",
|
| 908 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz",
|
| 909 |
+
"integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==",
|
| 910 |
+
"cpu": [
|
| 911 |
+
"loong64"
|
| 912 |
+
],
|
| 913 |
+
"dev": true,
|
| 914 |
+
"license": "MIT",
|
| 915 |
+
"optional": true,
|
| 916 |
+
"os": [
|
| 917 |
+
"linux"
|
| 918 |
+
]
|
| 919 |
+
},
|
| 920 |
+
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
| 921 |
+
"version": "4.63.3",
|
| 922 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz",
|
| 923 |
+
"integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==",
|
| 924 |
+
"cpu": [
|
| 925 |
+
"loong64"
|
| 926 |
+
],
|
| 927 |
+
"dev": true,
|
| 928 |
+
"license": "MIT",
|
| 929 |
+
"optional": true,
|
| 930 |
+
"os": [
|
| 931 |
+
"linux"
|
| 932 |
+
]
|
| 933 |
+
},
|
| 934 |
+
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
| 935 |
+
"version": "4.63.3",
|
| 936 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz",
|
| 937 |
+
"integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==",
|
| 938 |
+
"cpu": [
|
| 939 |
+
"ppc64"
|
| 940 |
+
],
|
| 941 |
+
"dev": true,
|
| 942 |
+
"license": "MIT",
|
| 943 |
+
"optional": true,
|
| 944 |
+
"os": [
|
| 945 |
+
"linux"
|
| 946 |
+
]
|
| 947 |
+
},
|
| 948 |
+
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
| 949 |
+
"version": "4.63.3",
|
| 950 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz",
|
| 951 |
+
"integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==",
|
| 952 |
+
"cpu": [
|
| 953 |
+
"ppc64"
|
| 954 |
+
],
|
| 955 |
+
"dev": true,
|
| 956 |
+
"license": "MIT",
|
| 957 |
+
"optional": true,
|
| 958 |
+
"os": [
|
| 959 |
+
"linux"
|
| 960 |
+
]
|
| 961 |
+
},
|
| 962 |
+
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
| 963 |
+
"version": "4.63.3",
|
| 964 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz",
|
| 965 |
+
"integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==",
|
| 966 |
+
"cpu": [
|
| 967 |
+
"riscv64"
|
| 968 |
+
],
|
| 969 |
+
"dev": true,
|
| 970 |
+
"license": "MIT",
|
| 971 |
+
"optional": true,
|
| 972 |
+
"os": [
|
| 973 |
+
"linux"
|
| 974 |
+
]
|
| 975 |
+
},
|
| 976 |
+
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
| 977 |
+
"version": "4.63.3",
|
| 978 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz",
|
| 979 |
+
"integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==",
|
| 980 |
+
"cpu": [
|
| 981 |
+
"riscv64"
|
| 982 |
+
],
|
| 983 |
+
"dev": true,
|
| 984 |
+
"license": "MIT",
|
| 985 |
+
"optional": true,
|
| 986 |
+
"os": [
|
| 987 |
+
"linux"
|
| 988 |
+
]
|
| 989 |
+
},
|
| 990 |
+
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
| 991 |
+
"version": "4.63.3",
|
| 992 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz",
|
| 993 |
+
"integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==",
|
| 994 |
+
"cpu": [
|
| 995 |
+
"s390x"
|
| 996 |
+
],
|
| 997 |
+
"dev": true,
|
| 998 |
+
"license": "MIT",
|
| 999 |
+
"optional": true,
|
| 1000 |
+
"os": [
|
| 1001 |
+
"linux"
|
| 1002 |
+
]
|
| 1003 |
+
},
|
| 1004 |
+
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
| 1005 |
+
"version": "4.63.3",
|
| 1006 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz",
|
| 1007 |
+
"integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==",
|
| 1008 |
+
"cpu": [
|
| 1009 |
+
"x64"
|
| 1010 |
+
],
|
| 1011 |
+
"dev": true,
|
| 1012 |
+
"license": "MIT",
|
| 1013 |
+
"optional": true,
|
| 1014 |
+
"os": [
|
| 1015 |
+
"linux"
|
| 1016 |
+
]
|
| 1017 |
+
},
|
| 1018 |
+
"node_modules/@rollup/rollup-linux-x64-musl": {
|
| 1019 |
+
"version": "4.63.3",
|
| 1020 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz",
|
| 1021 |
+
"integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==",
|
| 1022 |
+
"cpu": [
|
| 1023 |
+
"x64"
|
| 1024 |
+
],
|
| 1025 |
+
"dev": true,
|
| 1026 |
+
"license": "MIT",
|
| 1027 |
+
"optional": true,
|
| 1028 |
+
"os": [
|
| 1029 |
+
"linux"
|
| 1030 |
+
]
|
| 1031 |
+
},
|
| 1032 |
+
"node_modules/@rollup/rollup-openbsd-x64": {
|
| 1033 |
+
"version": "4.63.3",
|
| 1034 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz",
|
| 1035 |
+
"integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==",
|
| 1036 |
+
"cpu": [
|
| 1037 |
+
"x64"
|
| 1038 |
+
],
|
| 1039 |
+
"dev": true,
|
| 1040 |
+
"license": "MIT",
|
| 1041 |
+
"optional": true,
|
| 1042 |
+
"os": [
|
| 1043 |
+
"openbsd"
|
| 1044 |
+
]
|
| 1045 |
+
},
|
| 1046 |
+
"node_modules/@rollup/rollup-openharmony-arm64": {
|
| 1047 |
+
"version": "4.63.3",
|
| 1048 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz",
|
| 1049 |
+
"integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==",
|
| 1050 |
+
"cpu": [
|
| 1051 |
+
"arm64"
|
| 1052 |
+
],
|
| 1053 |
+
"dev": true,
|
| 1054 |
+
"license": "MIT",
|
| 1055 |
+
"optional": true,
|
| 1056 |
+
"os": [
|
| 1057 |
+
"openharmony"
|
| 1058 |
+
]
|
| 1059 |
+
},
|
| 1060 |
+
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
| 1061 |
+
"version": "4.63.3",
|
| 1062 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz",
|
| 1063 |
+
"integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==",
|
| 1064 |
+
"cpu": [
|
| 1065 |
+
"arm64"
|
| 1066 |
+
],
|
| 1067 |
+
"dev": true,
|
| 1068 |
+
"license": "MIT",
|
| 1069 |
+
"optional": true,
|
| 1070 |
+
"os": [
|
| 1071 |
+
"win32"
|
| 1072 |
+
]
|
| 1073 |
+
},
|
| 1074 |
+
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
| 1075 |
+
"version": "4.63.3",
|
| 1076 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz",
|
| 1077 |
+
"integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==",
|
| 1078 |
+
"cpu": [
|
| 1079 |
+
"ia32"
|
| 1080 |
+
],
|
| 1081 |
+
"dev": true,
|
| 1082 |
+
"license": "MIT",
|
| 1083 |
+
"optional": true,
|
| 1084 |
+
"os": [
|
| 1085 |
+
"win32"
|
| 1086 |
+
]
|
| 1087 |
+
},
|
| 1088 |
+
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
| 1089 |
+
"version": "4.63.3",
|
| 1090 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz",
|
| 1091 |
+
"integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==",
|
| 1092 |
+
"cpu": [
|
| 1093 |
+
"x64"
|
| 1094 |
+
],
|
| 1095 |
+
"dev": true,
|
| 1096 |
+
"license": "MIT",
|
| 1097 |
+
"optional": true,
|
| 1098 |
+
"os": [
|
| 1099 |
+
"win32"
|
| 1100 |
+
]
|
| 1101 |
+
},
|
| 1102 |
+
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
| 1103 |
+
"version": "4.63.3",
|
| 1104 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz",
|
| 1105 |
+
"integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==",
|
| 1106 |
+
"cpu": [
|
| 1107 |
+
"x64"
|
| 1108 |
+
],
|
| 1109 |
+
"dev": true,
|
| 1110 |
+
"license": "MIT",
|
| 1111 |
+
"optional": true,
|
| 1112 |
+
"os": [
|
| 1113 |
+
"win32"
|
| 1114 |
+
]
|
| 1115 |
+
},
|
| 1116 |
+
"node_modules/@types/babel__core": {
|
| 1117 |
+
"version": "7.20.5",
|
| 1118 |
+
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
| 1119 |
+
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
|
| 1120 |
+
"dev": true,
|
| 1121 |
+
"license": "MIT",
|
| 1122 |
+
"dependencies": {
|
| 1123 |
+
"@babel/parser": "^7.20.7",
|
| 1124 |
+
"@babel/types": "^7.20.7",
|
| 1125 |
+
"@types/babel__generator": "*",
|
| 1126 |
+
"@types/babel__template": "*",
|
| 1127 |
+
"@types/babel__traverse": "*"
|
| 1128 |
+
}
|
| 1129 |
+
},
|
| 1130 |
+
"node_modules/@types/babel__generator": {
|
| 1131 |
+
"version": "7.27.0",
|
| 1132 |
+
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
|
| 1133 |
+
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
|
| 1134 |
+
"dev": true,
|
| 1135 |
+
"license": "MIT",
|
| 1136 |
+
"dependencies": {
|
| 1137 |
+
"@babel/types": "^7.0.0"
|
| 1138 |
+
}
|
| 1139 |
+
},
|
| 1140 |
+
"node_modules/@types/babel__template": {
|
| 1141 |
+
"version": "7.4.4",
|
| 1142 |
+
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
|
| 1143 |
+
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
|
| 1144 |
+
"dev": true,
|
| 1145 |
+
"license": "MIT",
|
| 1146 |
+
"dependencies": {
|
| 1147 |
+
"@babel/parser": "^7.1.0",
|
| 1148 |
+
"@babel/types": "^7.0.0"
|
| 1149 |
+
}
|
| 1150 |
+
},
|
| 1151 |
+
"node_modules/@types/babel__traverse": {
|
| 1152 |
+
"version": "7.28.0",
|
| 1153 |
+
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
|
| 1154 |
+
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
|
| 1155 |
+
"dev": true,
|
| 1156 |
+
"license": "MIT",
|
| 1157 |
+
"dependencies": {
|
| 1158 |
+
"@babel/types": "^7.28.2"
|
| 1159 |
+
}
|
| 1160 |
+
},
|
| 1161 |
+
"node_modules/@types/estree": {
|
| 1162 |
+
"version": "1.0.9",
|
| 1163 |
+
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
| 1164 |
+
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
| 1165 |
+
"dev": true,
|
| 1166 |
+
"license": "MIT"
|
| 1167 |
+
},
|
| 1168 |
+
"node_modules/@vitejs/plugin-react": {
|
| 1169 |
+
"version": "4.7.0",
|
| 1170 |
+
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
| 1171 |
+
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
| 1172 |
+
"dev": true,
|
| 1173 |
+
"license": "MIT",
|
| 1174 |
+
"dependencies": {
|
| 1175 |
+
"@babel/core": "^7.28.0",
|
| 1176 |
+
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
| 1177 |
+
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
| 1178 |
+
"@rolldown/pluginutils": "1.0.0-beta.27",
|
| 1179 |
+
"@types/babel__core": "^7.20.5",
|
| 1180 |
+
"react-refresh": "^0.17.0"
|
| 1181 |
+
},
|
| 1182 |
+
"engines": {
|
| 1183 |
+
"node": "^14.18.0 || >=16.0.0"
|
| 1184 |
+
},
|
| 1185 |
+
"peerDependencies": {
|
| 1186 |
+
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
| 1187 |
+
}
|
| 1188 |
+
},
|
| 1189 |
+
"node_modules/baseline-browser-mapping": {
|
| 1190 |
+
"version": "2.11.25",
|
| 1191 |
+
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz",
|
| 1192 |
+
"integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==",
|
| 1193 |
+
"dev": true,
|
| 1194 |
+
"license": "Apache-2.0",
|
| 1195 |
+
"bin": {
|
| 1196 |
+
"baseline-browser-mapping": "dist/cli.cjs"
|
| 1197 |
+
},
|
| 1198 |
+
"engines": {
|
| 1199 |
+
"node": ">=6.0.0"
|
| 1200 |
+
}
|
| 1201 |
+
},
|
| 1202 |
+
"node_modules/browserslist": {
|
| 1203 |
+
"version": "4.29.0",
|
| 1204 |
+
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz",
|
| 1205 |
+
"integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==",
|
| 1206 |
+
"dev": true,
|
| 1207 |
+
"funding": [
|
| 1208 |
+
{
|
| 1209 |
+
"type": "opencollective",
|
| 1210 |
+
"url": "https://opencollective.com/browserslist"
|
| 1211 |
+
},
|
| 1212 |
+
{
|
| 1213 |
+
"type": "tidelift",
|
| 1214 |
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
| 1215 |
+
},
|
| 1216 |
+
{
|
| 1217 |
+
"type": "github",
|
| 1218 |
+
"url": "https://github.com/sponsors/ai"
|
| 1219 |
+
}
|
| 1220 |
+
],
|
| 1221 |
+
"license": "MIT",
|
| 1222 |
+
"dependencies": {
|
| 1223 |
+
"baseline-browser-mapping": "^2.11.23",
|
| 1224 |
+
"caniuse-lite": "^1.0.30001810",
|
| 1225 |
+
"electron-to-chromium": "^1.5.427",
|
| 1226 |
+
"node-releases": "^2.0.55",
|
| 1227 |
+
"update-browserslist-db": "^1.3.3"
|
| 1228 |
+
},
|
| 1229 |
+
"bin": {
|
| 1230 |
+
"browserslist": "cli.js"
|
| 1231 |
+
},
|
| 1232 |
+
"engines": {
|
| 1233 |
+
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
| 1234 |
+
}
|
| 1235 |
+
},
|
| 1236 |
+
"node_modules/caniuse-lite": {
|
| 1237 |
+
"version": "1.0.30001810",
|
| 1238 |
+
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
|
| 1239 |
+
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
|
| 1240 |
+
"dev": true,
|
| 1241 |
+
"funding": [
|
| 1242 |
+
{
|
| 1243 |
+
"type": "opencollective",
|
| 1244 |
+
"url": "https://opencollective.com/browserslist"
|
| 1245 |
+
},
|
| 1246 |
+
{
|
| 1247 |
+
"type": "tidelift",
|
| 1248 |
+
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
| 1249 |
+
},
|
| 1250 |
+
{
|
| 1251 |
+
"type": "github",
|
| 1252 |
+
"url": "https://github.com/sponsors/ai"
|
| 1253 |
+
}
|
| 1254 |
+
],
|
| 1255 |
+
"license": "CC-BY-4.0"
|
| 1256 |
+
},
|
| 1257 |
+
"node_modules/convert-source-map": {
|
| 1258 |
+
"version": "2.0.0",
|
| 1259 |
+
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
| 1260 |
+
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
| 1261 |
+
"dev": true,
|
| 1262 |
+
"license": "MIT"
|
| 1263 |
+
},
|
| 1264 |
+
"node_modules/debug": {
|
| 1265 |
+
"version": "4.4.3",
|
| 1266 |
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
| 1267 |
+
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
| 1268 |
+
"dev": true,
|
| 1269 |
+
"license": "MIT",
|
| 1270 |
+
"dependencies": {
|
| 1271 |
+
"ms": "^2.1.3"
|
| 1272 |
+
},
|
| 1273 |
+
"engines": {
|
| 1274 |
+
"node": ">=6.0"
|
| 1275 |
+
},
|
| 1276 |
+
"peerDependenciesMeta": {
|
| 1277 |
+
"supports-color": {
|
| 1278 |
+
"optional": true
|
| 1279 |
+
}
|
| 1280 |
+
}
|
| 1281 |
+
},
|
| 1282 |
+
"node_modules/electron-to-chromium": {
|
| 1283 |
+
"version": "1.5.430",
|
| 1284 |
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz",
|
| 1285 |
+
"integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==",
|
| 1286 |
+
"dev": true,
|
| 1287 |
+
"license": "ISC"
|
| 1288 |
+
},
|
| 1289 |
+
"node_modules/esbuild": {
|
| 1290 |
+
"version": "0.21.5",
|
| 1291 |
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
| 1292 |
+
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
| 1293 |
+
"dev": true,
|
| 1294 |
+
"hasInstallScript": true,
|
| 1295 |
+
"license": "MIT",
|
| 1296 |
+
"bin": {
|
| 1297 |
+
"esbuild": "bin/esbuild"
|
| 1298 |
+
},
|
| 1299 |
+
"engines": {
|
| 1300 |
+
"node": ">=12"
|
| 1301 |
+
},
|
| 1302 |
+
"optionalDependencies": {
|
| 1303 |
+
"@esbuild/aix-ppc64": "0.21.5",
|
| 1304 |
+
"@esbuild/android-arm": "0.21.5",
|
| 1305 |
+
"@esbuild/android-arm64": "0.21.5",
|
| 1306 |
+
"@esbuild/android-x64": "0.21.5",
|
| 1307 |
+
"@esbuild/darwin-arm64": "0.21.5",
|
| 1308 |
+
"@esbuild/darwin-x64": "0.21.5",
|
| 1309 |
+
"@esbuild/freebsd-arm64": "0.21.5",
|
| 1310 |
+
"@esbuild/freebsd-x64": "0.21.5",
|
| 1311 |
+
"@esbuild/linux-arm": "0.21.5",
|
| 1312 |
+
"@esbuild/linux-arm64": "0.21.5",
|
| 1313 |
+
"@esbuild/linux-ia32": "0.21.5",
|
| 1314 |
+
"@esbuild/linux-loong64": "0.21.5",
|
| 1315 |
+
"@esbuild/linux-mips64el": "0.21.5",
|
| 1316 |
+
"@esbuild/linux-ppc64": "0.21.5",
|
| 1317 |
+
"@esbuild/linux-riscv64": "0.21.5",
|
| 1318 |
+
"@esbuild/linux-s390x": "0.21.5",
|
| 1319 |
+
"@esbuild/linux-x64": "0.21.5",
|
| 1320 |
+
"@esbuild/netbsd-x64": "0.21.5",
|
| 1321 |
+
"@esbuild/openbsd-x64": "0.21.5",
|
| 1322 |
+
"@esbuild/sunos-x64": "0.21.5",
|
| 1323 |
+
"@esbuild/win32-arm64": "0.21.5",
|
| 1324 |
+
"@esbuild/win32-ia32": "0.21.5",
|
| 1325 |
+
"@esbuild/win32-x64": "0.21.5"
|
| 1326 |
+
}
|
| 1327 |
+
},
|
| 1328 |
+
"node_modules/escalade": {
|
| 1329 |
+
"version": "3.2.0",
|
| 1330 |
+
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
| 1331 |
+
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
| 1332 |
+
"dev": true,
|
| 1333 |
+
"license": "MIT",
|
| 1334 |
+
"engines": {
|
| 1335 |
+
"node": ">=6"
|
| 1336 |
+
}
|
| 1337 |
+
},
|
| 1338 |
+
"node_modules/fsevents": {
|
| 1339 |
+
"version": "2.3.3",
|
| 1340 |
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
| 1341 |
+
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
| 1342 |
+
"dev": true,
|
| 1343 |
+
"hasInstallScript": true,
|
| 1344 |
+
"license": "MIT",
|
| 1345 |
+
"optional": true,
|
| 1346 |
+
"os": [
|
| 1347 |
+
"darwin"
|
| 1348 |
+
],
|
| 1349 |
+
"engines": {
|
| 1350 |
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
| 1351 |
+
}
|
| 1352 |
+
},
|
| 1353 |
+
"node_modules/gensync": {
|
| 1354 |
+
"version": "1.0.0-beta.2",
|
| 1355 |
+
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
| 1356 |
+
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
| 1357 |
+
"dev": true,
|
| 1358 |
+
"license": "MIT",
|
| 1359 |
+
"engines": {
|
| 1360 |
+
"node": ">=6.9.0"
|
| 1361 |
+
}
|
| 1362 |
+
},
|
| 1363 |
+
"node_modules/js-tokens": {
|
| 1364 |
+
"version": "4.0.0",
|
| 1365 |
+
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
| 1366 |
+
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
| 1367 |
+
"license": "MIT"
|
| 1368 |
+
},
|
| 1369 |
+
"node_modules/jsesc": {
|
| 1370 |
+
"version": "3.1.0",
|
| 1371 |
+
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
| 1372 |
+
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
| 1373 |
+
"dev": true,
|
| 1374 |
+
"license": "MIT",
|
| 1375 |
+
"bin": {
|
| 1376 |
+
"jsesc": "bin/jsesc"
|
| 1377 |
+
},
|
| 1378 |
+
"engines": {
|
| 1379 |
+
"node": ">=6"
|
| 1380 |
+
}
|
| 1381 |
+
},
|
| 1382 |
+
"node_modules/json5": {
|
| 1383 |
+
"version": "2.2.3",
|
| 1384 |
+
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
| 1385 |
+
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
| 1386 |
+
"dev": true,
|
| 1387 |
+
"license": "MIT",
|
| 1388 |
+
"bin": {
|
| 1389 |
+
"json5": "lib/cli.js"
|
| 1390 |
+
},
|
| 1391 |
+
"engines": {
|
| 1392 |
+
"node": ">=6"
|
| 1393 |
+
}
|
| 1394 |
+
},
|
| 1395 |
+
"node_modules/loose-envify": {
|
| 1396 |
+
"version": "1.4.0",
|
| 1397 |
+
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
| 1398 |
+
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
| 1399 |
+
"license": "MIT",
|
| 1400 |
+
"dependencies": {
|
| 1401 |
+
"js-tokens": "^3.0.0 || ^4.0.0"
|
| 1402 |
+
},
|
| 1403 |
+
"bin": {
|
| 1404 |
+
"loose-envify": "cli.js"
|
| 1405 |
+
}
|
| 1406 |
+
},
|
| 1407 |
+
"node_modules/lru-cache": {
|
| 1408 |
+
"version": "5.1.1",
|
| 1409 |
+
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
| 1410 |
+
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
| 1411 |
+
"dev": true,
|
| 1412 |
+
"license": "ISC",
|
| 1413 |
+
"dependencies": {
|
| 1414 |
+
"yallist": "^3.0.2"
|
| 1415 |
+
}
|
| 1416 |
+
},
|
| 1417 |
+
"node_modules/ms": {
|
| 1418 |
+
"version": "2.1.3",
|
| 1419 |
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
| 1420 |
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
| 1421 |
+
"dev": true,
|
| 1422 |
+
"license": "MIT"
|
| 1423 |
+
},
|
| 1424 |
+
"node_modules/nanoid": {
|
| 1425 |
+
"version": "3.3.19",
|
| 1426 |
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
| 1427 |
+
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
| 1428 |
+
"dev": true,
|
| 1429 |
+
"funding": [
|
| 1430 |
+
{
|
| 1431 |
+
"type": "github",
|
| 1432 |
+
"url": "https://github.com/sponsors/ai"
|
| 1433 |
+
}
|
| 1434 |
+
],
|
| 1435 |
+
"license": "MIT",
|
| 1436 |
+
"bin": {
|
| 1437 |
+
"nanoid": "bin/nanoid.cjs"
|
| 1438 |
+
},
|
| 1439 |
+
"engines": {
|
| 1440 |
+
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
| 1441 |
+
}
|
| 1442 |
+
},
|
| 1443 |
+
"node_modules/node-releases": {
|
| 1444 |
+
"version": "2.0.56",
|
| 1445 |
+
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz",
|
| 1446 |
+
"integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==",
|
| 1447 |
+
"dev": true,
|
| 1448 |
+
"license": "MIT",
|
| 1449 |
+
"engines": {
|
| 1450 |
+
"node": ">=18"
|
| 1451 |
+
}
|
| 1452 |
+
},
|
| 1453 |
+
"node_modules/picocolors": {
|
| 1454 |
+
"version": "1.1.1",
|
| 1455 |
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
| 1456 |
+
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
| 1457 |
+
"dev": true,
|
| 1458 |
+
"license": "ISC"
|
| 1459 |
+
},
|
| 1460 |
+
"node_modules/postcss": {
|
| 1461 |
+
"version": "8.5.28",
|
| 1462 |
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
| 1463 |
+
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
| 1464 |
+
"dev": true,
|
| 1465 |
+
"funding": [
|
| 1466 |
+
{
|
| 1467 |
+
"type": "opencollective",
|
| 1468 |
+
"url": "https://opencollective.com/postcss/"
|
| 1469 |
+
},
|
| 1470 |
+
{
|
| 1471 |
+
"type": "tidelift",
|
| 1472 |
+
"url": "https://tidelift.com/funding/github/npm/postcss"
|
| 1473 |
+
},
|
| 1474 |
+
{
|
| 1475 |
+
"type": "github",
|
| 1476 |
+
"url": "https://github.com/sponsors/ai"
|
| 1477 |
+
}
|
| 1478 |
+
],
|
| 1479 |
+
"license": "MIT",
|
| 1480 |
+
"dependencies": {
|
| 1481 |
+
"nanoid": "^3.3.18",
|
| 1482 |
+
"picocolors": "^1.1.1",
|
| 1483 |
+
"source-map-js": "^1.2.1"
|
| 1484 |
+
},
|
| 1485 |
+
"engines": {
|
| 1486 |
+
"node": "^10 || ^12 || >=14"
|
| 1487 |
+
}
|
| 1488 |
+
},
|
| 1489 |
+
"node_modules/react": {
|
| 1490 |
+
"version": "18.3.1",
|
| 1491 |
+
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
| 1492 |
+
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
| 1493 |
+
"license": "MIT",
|
| 1494 |
+
"dependencies": {
|
| 1495 |
+
"loose-envify": "^1.1.0"
|
| 1496 |
+
},
|
| 1497 |
+
"engines": {
|
| 1498 |
+
"node": ">=0.10.0"
|
| 1499 |
+
}
|
| 1500 |
+
},
|
| 1501 |
+
"node_modules/react-dom": {
|
| 1502 |
+
"version": "18.3.1",
|
| 1503 |
+
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
| 1504 |
+
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
| 1505 |
+
"license": "MIT",
|
| 1506 |
+
"dependencies": {
|
| 1507 |
+
"loose-envify": "^1.1.0",
|
| 1508 |
+
"scheduler": "^0.23.2"
|
| 1509 |
+
},
|
| 1510 |
+
"peerDependencies": {
|
| 1511 |
+
"react": "^18.3.1"
|
| 1512 |
+
}
|
| 1513 |
+
},
|
| 1514 |
+
"node_modules/react-refresh": {
|
| 1515 |
+
"version": "0.17.0",
|
| 1516 |
+
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
| 1517 |
+
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
| 1518 |
+
"dev": true,
|
| 1519 |
+
"license": "MIT",
|
| 1520 |
+
"engines": {
|
| 1521 |
+
"node": ">=0.10.0"
|
| 1522 |
+
}
|
| 1523 |
+
},
|
| 1524 |
+
"node_modules/rollup": {
|
| 1525 |
+
"version": "4.63.3",
|
| 1526 |
+
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz",
|
| 1527 |
+
"integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==",
|
| 1528 |
+
"dev": true,
|
| 1529 |
+
"license": "MIT",
|
| 1530 |
+
"dependencies": {
|
| 1531 |
+
"@types/estree": "1.0.9"
|
| 1532 |
+
},
|
| 1533 |
+
"bin": {
|
| 1534 |
+
"rollup": "dist/bin/rollup"
|
| 1535 |
+
},
|
| 1536 |
+
"engines": {
|
| 1537 |
+
"node": ">=18.0.0",
|
| 1538 |
+
"npm": ">=8.0.0"
|
| 1539 |
+
},
|
| 1540 |
+
"optionalDependencies": {
|
| 1541 |
+
"@napi-rs/lzma-linux-x64-gnu": "1.5.1",
|
| 1542 |
+
"@rollup/rollup-android-arm-eabi": "4.63.3",
|
| 1543 |
+
"@rollup/rollup-android-arm64": "4.63.3",
|
| 1544 |
+
"@rollup/rollup-darwin-arm64": "4.63.3",
|
| 1545 |
+
"@rollup/rollup-darwin-x64": "4.63.3",
|
| 1546 |
+
"@rollup/rollup-freebsd-arm64": "4.63.3",
|
| 1547 |
+
"@rollup/rollup-freebsd-x64": "4.63.3",
|
| 1548 |
+
"@rollup/rollup-linux-arm-gnueabihf": "4.63.3",
|
| 1549 |
+
"@rollup/rollup-linux-arm-musleabihf": "4.63.3",
|
| 1550 |
+
"@rollup/rollup-linux-arm64-gnu": "4.63.3",
|
| 1551 |
+
"@rollup/rollup-linux-arm64-musl": "4.63.3",
|
| 1552 |
+
"@rollup/rollup-linux-loong64-gnu": "4.63.3",
|
| 1553 |
+
"@rollup/rollup-linux-loong64-musl": "4.63.3",
|
| 1554 |
+
"@rollup/rollup-linux-ppc64-gnu": "4.63.3",
|
| 1555 |
+
"@rollup/rollup-linux-ppc64-musl": "4.63.3",
|
| 1556 |
+
"@rollup/rollup-linux-riscv64-gnu": "4.63.3",
|
| 1557 |
+
"@rollup/rollup-linux-riscv64-musl": "4.63.3",
|
| 1558 |
+
"@rollup/rollup-linux-s390x-gnu": "4.63.3",
|
| 1559 |
+
"@rollup/rollup-linux-x64-gnu": "4.63.3",
|
| 1560 |
+
"@rollup/rollup-linux-x64-musl": "4.63.3",
|
| 1561 |
+
"@rollup/rollup-openbsd-x64": "4.63.3",
|
| 1562 |
+
"@rollup/rollup-openharmony-arm64": "4.63.3",
|
| 1563 |
+
"@rollup/rollup-win32-arm64-msvc": "4.63.3",
|
| 1564 |
+
"@rollup/rollup-win32-ia32-msvc": "4.63.3",
|
| 1565 |
+
"@rollup/rollup-win32-x64-gnu": "4.63.3",
|
| 1566 |
+
"@rollup/rollup-win32-x64-msvc": "4.63.3",
|
| 1567 |
+
"fsevents": "~2.3.2"
|
| 1568 |
+
}
|
| 1569 |
+
},
|
| 1570 |
+
"node_modules/scheduler": {
|
| 1571 |
+
"version": "0.23.2",
|
| 1572 |
+
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
| 1573 |
+
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
| 1574 |
+
"license": "MIT",
|
| 1575 |
+
"dependencies": {
|
| 1576 |
+
"loose-envify": "^1.1.0"
|
| 1577 |
+
}
|
| 1578 |
+
},
|
| 1579 |
+
"node_modules/semver": {
|
| 1580 |
+
"version": "6.3.1",
|
| 1581 |
+
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
| 1582 |
+
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
| 1583 |
+
"dev": true,
|
| 1584 |
+
"license": "ISC",
|
| 1585 |
+
"bin": {
|
| 1586 |
+
"semver": "bin/semver.js"
|
| 1587 |
+
}
|
| 1588 |
+
},
|
| 1589 |
+
"node_modules/source-map-js": {
|
| 1590 |
+
"version": "1.2.1",
|
| 1591 |
+
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
| 1592 |
+
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
| 1593 |
+
"dev": true,
|
| 1594 |
+
"license": "BSD-3-Clause",
|
| 1595 |
+
"engines": {
|
| 1596 |
+
"node": ">=0.10.0"
|
| 1597 |
+
}
|
| 1598 |
+
},
|
| 1599 |
+
"node_modules/update-browserslist-db": {
|
| 1600 |
+
"version": "1.3.3",
|
| 1601 |
+
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
|
| 1602 |
+
"integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
|
| 1603 |
+
"dev": true,
|
| 1604 |
+
"funding": [
|
| 1605 |
+
{
|
| 1606 |
+
"type": "opencollective",
|
| 1607 |
+
"url": "https://opencollective.com/browserslist"
|
| 1608 |
+
},
|
| 1609 |
+
{
|
| 1610 |
+
"type": "tidelift",
|
| 1611 |
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
| 1612 |
+
},
|
| 1613 |
+
{
|
| 1614 |
+
"type": "github",
|
| 1615 |
+
"url": "https://github.com/sponsors/ai"
|
| 1616 |
+
}
|
| 1617 |
+
],
|
| 1618 |
+
"license": "MIT",
|
| 1619 |
+
"dependencies": {
|
| 1620 |
+
"escalade": "^3.2.0",
|
| 1621 |
+
"picocolors": "^1.1.1"
|
| 1622 |
+
},
|
| 1623 |
+
"bin": {
|
| 1624 |
+
"update-browserslist-db": "cli.js"
|
| 1625 |
+
},
|
| 1626 |
+
"peerDependencies": {
|
| 1627 |
+
"browserslist": ">= 4.21.0"
|
| 1628 |
+
}
|
| 1629 |
+
},
|
| 1630 |
+
"node_modules/vite": {
|
| 1631 |
+
"version": "5.4.21",
|
| 1632 |
+
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
| 1633 |
+
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
| 1634 |
+
"dev": true,
|
| 1635 |
+
"license": "MIT",
|
| 1636 |
+
"dependencies": {
|
| 1637 |
+
"esbuild": "^0.21.3",
|
| 1638 |
+
"postcss": "^8.4.43",
|
| 1639 |
+
"rollup": "^4.20.0"
|
| 1640 |
+
},
|
| 1641 |
+
"bin": {
|
| 1642 |
+
"vite": "bin/vite.js"
|
| 1643 |
+
},
|
| 1644 |
+
"engines": {
|
| 1645 |
+
"node": "^18.0.0 || >=20.0.0"
|
| 1646 |
+
},
|
| 1647 |
+
"funding": {
|
| 1648 |
+
"url": "https://github.com/vitejs/vite?sponsor=1"
|
| 1649 |
+
},
|
| 1650 |
+
"optionalDependencies": {
|
| 1651 |
+
"fsevents": "~2.3.3"
|
| 1652 |
+
},
|
| 1653 |
+
"peerDependencies": {
|
| 1654 |
+
"@types/node": "^18.0.0 || >=20.0.0",
|
| 1655 |
+
"less": "*",
|
| 1656 |
+
"lightningcss": "^1.21.0",
|
| 1657 |
+
"sass": "*",
|
| 1658 |
+
"sass-embedded": "*",
|
| 1659 |
+
"stylus": "*",
|
| 1660 |
+
"sugarss": "*",
|
| 1661 |
+
"terser": "^5.4.0"
|
| 1662 |
+
},
|
| 1663 |
+
"peerDependenciesMeta": {
|
| 1664 |
+
"@types/node": {
|
| 1665 |
+
"optional": true
|
| 1666 |
+
},
|
| 1667 |
+
"less": {
|
| 1668 |
+
"optional": true
|
| 1669 |
+
},
|
| 1670 |
+
"lightningcss": {
|
| 1671 |
+
"optional": true
|
| 1672 |
+
},
|
| 1673 |
+
"sass": {
|
| 1674 |
+
"optional": true
|
| 1675 |
+
},
|
| 1676 |
+
"sass-embedded": {
|
| 1677 |
+
"optional": true
|
| 1678 |
+
},
|
| 1679 |
+
"stylus": {
|
| 1680 |
+
"optional": true
|
| 1681 |
+
},
|
| 1682 |
+
"sugarss": {
|
| 1683 |
+
"optional": true
|
| 1684 |
+
},
|
| 1685 |
+
"terser": {
|
| 1686 |
+
"optional": true
|
| 1687 |
+
}
|
| 1688 |
+
}
|
| 1689 |
+
},
|
| 1690 |
+
"node_modules/yallist": {
|
| 1691 |
+
"version": "3.1.1",
|
| 1692 |
+
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
| 1693 |
+
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
| 1694 |
+
"dev": true,
|
| 1695 |
+
"license": "ISC"
|
| 1696 |
+
}
|
| 1697 |
+
}
|
| 1698 |
+
}
|
frontend/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "chesstb-explorer-frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"react": "^18.3.1",
|
| 13 |
+
"react-dom": "^18.3.1"
|
| 14 |
+
},
|
| 15 |
+
"devDependencies": {
|
| 16 |
+
"@vitejs/plugin-react": "^4.3.4",
|
| 17 |
+
"vite": "^5.4.11"
|
| 18 |
+
}
|
| 19 |
+
}
|
frontend/src/App.jsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useExplorer } from './hooks/useExplorer.js';
|
| 2 |
+
import Header from './components/Header.jsx';
|
| 3 |
+
import FenBar from './components/FenBar.jsx';
|
| 4 |
+
import BoardArea from './components/BoardArea.jsx';
|
| 5 |
+
import ResultsPanel from './components/ResultsPanel.jsx';
|
| 6 |
+
import PgnPanel from './components/PgnPanel.jsx';
|
| 7 |
+
import PgnDialog from './components/PgnDialog.jsx';
|
| 8 |
+
import SettingsPanel from './components/SettingsPanel.jsx';
|
| 9 |
+
import Toasts from './components/Toasts.jsx';
|
| 10 |
+
import PromotionDialog from './components/PromotionDialog.jsx';
|
| 11 |
+
import ScreenNav from './components/ScreenNav.jsx';
|
| 12 |
+
|
| 13 |
+
export default function App({ sourceRepositoryUrl }) {
|
| 14 |
+
const explorer = useExplorer();
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<>
|
| 18 |
+
<Header sourceRepositoryUrl={sourceRepositoryUrl} setSettingsOpen={explorer.setSettingsOpen} />
|
| 19 |
+
|
| 20 |
+
<main className="content" role="main">
|
| 21 |
+
<div className="left-col">
|
| 22 |
+
<FenBar explorer={explorer} />
|
| 23 |
+
<BoardArea explorer={explorer} />
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<div className="right-col">
|
| 27 |
+
<ResultsPanel explorer={explorer} />
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<div className="pgn-col">
|
| 31 |
+
<PgnPanel explorer={explorer} />
|
| 32 |
+
</div>
|
| 33 |
+
</main>
|
| 34 |
+
|
| 35 |
+
<ScreenNav />
|
| 36 |
+
|
| 37 |
+
<PromotionDialog />
|
| 38 |
+
<PgnDialog explorer={explorer} />
|
| 39 |
+
<SettingsPanel explorer={explorer} />
|
| 40 |
+
<Toasts toasts={explorer.toasts} />
|
| 41 |
+
</>
|
| 42 |
+
);
|
| 43 |
+
}
|
frontend/src/assets/fonts/Inter-Medium.woff2
ADDED
|
Binary file (24.3 kB). View file
|
|
|
frontend/src/assets/fonts/Inter-Regular.woff2
ADDED
|
Binary file (23.7 kB). View file
|
|
|
frontend/src/assets/fonts/Inter-SemiBold.woff2
ADDED
|
Binary file (24.5 kB). View file
|
|
|
frontend/src/assets/fonts/JetBrainsMono-Medium.woff2
ADDED
|
Binary file (21.8 kB). View file
|
|
|
frontend/src/assets/fonts/JetBrainsMono-Regular.woff2
ADDED
|
Binary file (21.2 kB). View file
|
|
|
frontend/src/components/BoardArea.jsx
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Segmented from './ui/Segmented.jsx';
|
| 2 |
+
|
| 3 |
+
const SPARE_BLACK = ['bP', 'bN', 'bB', 'bR', 'bQ', 'bK'];
|
| 4 |
+
const SPARE_WHITE = ['wP', 'wN', 'wB', 'wR', 'wQ', 'wK'];
|
| 5 |
+
|
| 6 |
+
export default function BoardArea({ explorer }) {
|
| 7 |
+
const { fen, actions } = explorer;
|
| 8 |
+
const turn = fen.split(' ')[1] === 'b' ? 'b' : 'w';
|
| 9 |
+
|
| 10 |
+
return (
|
| 11 |
+
<div className="board-group">
|
| 12 |
+
<Segmented
|
| 13 |
+
className="turn-row" itemClassName="turn-btn" label="Side to move"
|
| 14 |
+
value={turn} onChange={actions.setTurn}
|
| 15 |
+
options={[
|
| 16 |
+
{ value: 'w', id: 'turn-white', title: 'White to move', content: <><span className="turn-pip turn-pip--w" aria-hidden="true"></span>White to move</> },
|
| 17 |
+
{ value: 'b', id: 'turn-black', title: 'Black to move', content: <><span className="turn-pip turn-pip--b" aria-hidden="true"></span>Black to move</> },
|
| 18 |
+
]}
|
| 19 |
+
/>
|
| 20 |
+
<div className="board-wrap" aria-label="Chess board">
|
| 21 |
+
<div id="board-wrap-inner" className="board-wrap-inner">
|
| 22 |
+
<div className="spare-tray spare-tray--black" data-color="b">
|
| 23 |
+
{SPARE_BLACK.map(code => (
|
| 24 |
+
<button key={code} className="spare-piece" data-piece={code} type="button">
|
| 25 |
+
<img src="" draggable="false" alt="" />
|
| 26 |
+
</button>
|
| 27 |
+
))}
|
| 28 |
+
</div>
|
| 29 |
+
<div id="board"></div>
|
| 30 |
+
<div className="spare-tray spare-tray--white" data-color="w">
|
| 31 |
+
{SPARE_WHITE.map(code => (
|
| 32 |
+
<button key={code} className="spare-piece" data-piece={code} type="button">
|
| 33 |
+
<img src="" draggable="false" alt="" />
|
| 34 |
+
</button>
|
| 35 |
+
))}
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
);
|
| 41 |
+
}
|
frontend/src/components/FenBar.jsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Button from './ui/Button.jsx';
|
| 2 |
+
import Icon from './ui/Icon.jsx';
|
| 3 |
+
|
| 4 |
+
export default function FenBar({ explorer }) {
|
| 5 |
+
const { fenInputRef, actions, ready, fen, locked, lockAutoplayDisabled, canBack, canForward, fenError, tb } = explorer;
|
| 6 |
+
const errorMessage = fenError ?? tb.error?.message ?? '';
|
| 7 |
+
const isCoverageError = !fenError && !!tb.error?.isCoverage;
|
| 8 |
+
|
| 9 |
+
function onFenKeyDown(e) {
|
| 10 |
+
if (e.key === 'Enter') { e.preventDefault(); actions.applyFen(e.currentTarget.value.trim()); }
|
| 11 |
+
}
|
| 12 |
+
function onApplyClick() {
|
| 13 |
+
const v = fenInputRef.current?.value.trim();
|
| 14 |
+
if (v) actions.applyFen(v);
|
| 15 |
+
}
|
| 16 |
+
function onLockClick(e) {
|
| 17 |
+
if (e.currentTarget.getAttribute('aria-disabled') === 'true') return;
|
| 18 |
+
actions.setLockedAction(!locked);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<>
|
| 23 |
+
<div className="fen-block">
|
| 24 |
+
<div className="fen-input-wrap">
|
| 25 |
+
<span className="fen-input-wrap__label" aria-hidden="true">FEN</span>
|
| 26 |
+
<input
|
| 27 |
+
id="fen-input" className="fen-input" type="text"
|
| 28 |
+
ref={fenInputRef} defaultValue={fen}
|
| 29 |
+
placeholder="Paste FEN here…" disabled={!ready}
|
| 30 |
+
autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck="false"
|
| 31 |
+
aria-label="FEN position string"
|
| 32 |
+
onKeyDown={onFenKeyDown}
|
| 33 |
+
/>
|
| 34 |
+
<button id="lock-btn" type="button"
|
| 35 |
+
className={`btn btn--icon-only btn--lock${locked ? ' is-locked' : ''}${lockAutoplayDisabled ? ' is-autoplay-disabled' : ''}`}
|
| 36 |
+
aria-pressed={locked} aria-disabled={lockAutoplayDisabled}
|
| 37 |
+
aria-label="Lock board to legal moves only"
|
| 38 |
+
title={lockAutoplayDisabled ? 'Stop auto-play to change Lock' : (locked ? 'Unlock board' : 'Lock board (legal moves only)')}
|
| 39 |
+
onClick={onLockClick}>
|
| 40 |
+
<Icon name="lockOpen" className="btn__icon lock-icon lock-icon--unlocked" />
|
| 41 |
+
<Icon name="lockClosed" className="btn__icon lock-icon lock-icon--locked" />
|
| 42 |
+
</button>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<div className="fen-actions" role="toolbar" aria-label="Board actions">
|
| 46 |
+
<Button id="apply-btn" variant="primary" grow icon="check" title="Apply FEN (Enter)" disabled={!ready} onClick={onApplyClick}>Apply</Button>
|
| 47 |
+
<Button id="flip-btn" grow icon="flip" title="Flip board (F)" onClick={() => actions.flip()}>Flip</Button>
|
| 48 |
+
<Button id="clear-btn" grow icon="trash" title="Clear board (C)" onClick={() => actions.clearBoard()}>Clear</Button>
|
| 49 |
+
<Button id="copy-btn" grow icon="copy" title="Copy FEN to clipboard" aria-label="Copy FEN to clipboard" onClick={() => actions.copyFen()}>Copy</Button>
|
| 50 |
+
<Button id="back-btn" iconOnly icon="arrowLeft" title="Undo (←)" aria-label="Undo" disabled={!canBack} onClick={() => actions.goBack()} />
|
| 51 |
+
<Button id="forward-btn" iconOnly icon="arrowRight" title="Redo (→)" aria-label="Redo" disabled={!canForward} onClick={() => actions.goForward()} />
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div id="error-line" className={`error-line${isCoverageError ? ' is-coverage-error' : ''}`} role="alert" aria-live="polite">
|
| 56 |
+
{errorMessage}
|
| 57 |
+
</div>
|
| 58 |
+
</>
|
| 59 |
+
);
|
| 60 |
+
}
|
frontend/src/components/Header.jsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Icon from './ui/Icon.jsx';
|
| 2 |
+
|
| 3 |
+
function Logo() {
|
| 4 |
+
return (
|
| 5 |
+
<svg className="site-header__logo" viewBox="0 0 24 24" aria-hidden="true">
|
| 6 |
+
<circle cx="12" cy="12" r="12" fill="var(--gold)" />
|
| 7 |
+
<g fill="var(--bg)">
|
| 8 |
+
<circle cx="12" cy="8.3" r="2.6" />
|
| 9 |
+
<path d="M10.4 11.2h3.2c.2 2 .9 3.3 2.2 5.4H8.2c1.3-2.1 2-3.4 2.2-5.4z" />
|
| 10 |
+
<rect x="7.2" y="16.6" width="9.6" height="1.9" rx="0.95" />
|
| 11 |
+
</g>
|
| 12 |
+
</svg>
|
| 13 |
+
);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export default function Header({ sourceRepositoryUrl, setSettingsOpen }) {
|
| 17 |
+
return (
|
| 18 |
+
<header className="site-header" role="banner">
|
| 19 |
+
<Logo />
|
| 20 |
+
|
| 21 |
+
<div className="site-header__brand">
|
| 22 |
+
<a href="https://noobpwnftw.github.io/chesstb/" className="site-header__title"
|
| 23 |
+
title="Project home — code and downloads">ChessTB</a>
|
| 24 |
+
<span className="site-header__sep" aria-hidden="true">·</span>
|
| 25 |
+
<span className="site-header__sub">Explorer</span>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<nav className="site-header__nav" aria-label="Site links">
|
| 29 |
+
{sourceRepositoryUrl && (
|
| 30 |
+
<a href={sourceRepositoryUrl} className="site-header__link site-header__link--icon"
|
| 31 |
+
title="Source repository" aria-label="Source repository" target="_blank" rel="noopener noreferrer">
|
| 32 |
+
<Icon name="repository" />
|
| 33 |
+
</a>
|
| 34 |
+
)}
|
| 35 |
+
<a href="/admin" className="site-header__link site-header__link--icon"
|
| 36 |
+
title="Cache dashboard" aria-label="Admin cache dashboard">
|
| 37 |
+
<Icon name="gauge" />
|
| 38 |
+
</a>
|
| 39 |
+
<a href="/openapi.yaml" className="site-header__link site-header__link--icon"
|
| 40 |
+
title="API specification" aria-label="API specification" target="_blank" rel="noopener noreferrer">
|
| 41 |
+
<Icon name="code" />
|
| 42 |
+
</a>
|
| 43 |
+
<button id="settings-btn" className="site-header__link site-header__link--icon"
|
| 44 |
+
title="Board settings" aria-label="Open board settings"
|
| 45 |
+
onClick={(e) => { e.stopPropagation(); setSettingsOpen(o => !o); }}>
|
| 46 |
+
<Icon name="settings" />
|
| 47 |
+
</button>
|
| 48 |
+
</nav>
|
| 49 |
+
</header>
|
| 50 |
+
);
|
| 51 |
+
}
|
frontend/src/components/PgnDialog.jsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from 'react';
|
| 2 |
+
import Button from './ui/Button.jsx';
|
| 3 |
+
|
| 4 |
+
export default function PgnDialog({ explorer }) {
|
| 5 |
+
const { pgnDialogOpen, pgnError, actions } = explorer;
|
| 6 |
+
const textareaRef = useRef(null);
|
| 7 |
+
|
| 8 |
+
useEffect(() => {
|
| 9 |
+
if (pgnDialogOpen) textareaRef.current?.focus();
|
| 10 |
+
}, [pgnDialogOpen]);
|
| 11 |
+
|
| 12 |
+
useEffect(() => {
|
| 13 |
+
if (!pgnDialogOpen) return;
|
| 14 |
+
function onKeydown(e) {
|
| 15 |
+
if (e.key === 'Escape') { e.preventDefault(); actions.closePgnDialog(); }
|
| 16 |
+
}
|
| 17 |
+
document.addEventListener('keydown', onKeydown);
|
| 18 |
+
return () => document.removeEventListener('keydown', onKeydown);
|
| 19 |
+
}, [pgnDialogOpen, actions]);
|
| 20 |
+
|
| 21 |
+
function onBackdropClick(e) {
|
| 22 |
+
if (e.target.id === 'pgn-dialog') actions.closePgnDialog();
|
| 23 |
+
}
|
| 24 |
+
function onParseClick() {
|
| 25 |
+
actions.parsePgnAction(textareaRef.current?.value || '');
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<div id="pgn-dialog" className={`pgn-dialog${pgnDialogOpen ? ' is-open' : ''}`}
|
| 30 |
+
role="dialog" aria-modal="true" aria-label="Import PGN" onClick={onBackdropClick}>
|
| 31 |
+
<div className="pgn-dialog__inner">
|
| 32 |
+
<div className="pgn-dialog__header">
|
| 33 |
+
<h2 className="pgn-dialog__title">Import PGN</h2>
|
| 34 |
+
<button id="pgn-close-btn" className="pgn-dialog__close" aria-label="Close" onClick={() => actions.closePgnDialog()}>✕</button>
|
| 35 |
+
</div>
|
| 36 |
+
<textarea id="pgn-input" className="pgn-dialog__textarea" ref={textareaRef}
|
| 37 |
+
placeholder="Paste PGN here… e.g. 1. e4 e5 2. Nf3 Nc6 3. Bc4"
|
| 38 |
+
rows={7} spellCheck="false" />
|
| 39 |
+
<div id="pgn-error" className="pgn-dialog__error" role="alert" aria-live="polite">{pgnError}</div>
|
| 40 |
+
<div className="pgn-dialog__actions">
|
| 41 |
+
<Button id="pgn-parse-btn" onClick={onParseClick}>Import Game</Button>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
);
|
| 46 |
+
}
|
frontend/src/components/PgnPanel.jsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from 'react';
|
| 2 |
+
import { parseFenMeta } from '../engine/pgnFormat.js';
|
| 3 |
+
import { buildMoveListItems, buildVariation } from '../engine/moveLine.js';
|
| 4 |
+
import { scrollIntoViewWithin } from '../engine/scroll.js';
|
| 5 |
+
import Button from './ui/Button.jsx';
|
| 6 |
+
import Panel from './ui/Panel.jsx';
|
| 7 |
+
|
| 8 |
+
function MoveButton({ node, isCurrent, chip, onGoTo }) {
|
| 9 |
+
const cls = `move-line__move${chip ? ' move-line__move--chip' : ''}${isCurrent ? ' is-current' : ''}`;
|
| 10 |
+
return <button className={cls} onClick={onGoTo ? () => onGoTo(node.id) : undefined}>{node.san}</button>;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function renderItems(items, nodes, rootChildren, currentId, onGoTo, keyPrefix) {
|
| 14 |
+
return items.flatMap((item, idx) => {
|
| 15 |
+
const key = `${keyPrefix}-${idx}`;
|
| 16 |
+
if (item.kind === 'row') {
|
| 17 |
+
const whiteEmpty = !item.white || item.white.placeholder;
|
| 18 |
+
return [
|
| 19 |
+
<div className="move-line__row" key={key}>
|
| 20 |
+
<span className="move-line__num">{item.num}.</span>
|
| 21 |
+
<span
|
| 22 |
+
className={`move-line__cell move-line__cell--white${whiteEmpty ? ' move-line__cell--empty' : ''}${item.white?.placeholder ? ' move-line__cell--placeholder' : ''}${item.white?.isCurrent ? ' is-current' : ''}`}
|
| 23 |
+
onClick={item.white && !item.white.placeholder ? () => onGoTo(item.white.node.id) : undefined}
|
| 24 |
+
>
|
| 25 |
+
{item.white?.placeholder ? '...' : null}
|
| 26 |
+
{item.white && !item.white.placeholder && <MoveButton node={item.white.node} isCurrent={item.white.isCurrent} />}
|
| 27 |
+
</span>
|
| 28 |
+
<span
|
| 29 |
+
className={`move-line__cell move-line__cell--black${!item.black ? ' move-line__cell--empty' : ''}${item.black?.isCurrent ? ' is-current' : ''}`}
|
| 30 |
+
onClick={item.black ? () => onGoTo(item.black.node.id) : undefined}
|
| 31 |
+
>
|
| 32 |
+
{item.black && <MoveButton node={item.black.node} isCurrent={item.black.isCurrent} />}
|
| 33 |
+
</span>
|
| 34 |
+
</div>,
|
| 35 |
+
];
|
| 36 |
+
}
|
| 37 |
+
if (item.kind === 'para') {
|
| 38 |
+
return [
|
| 39 |
+
<div className="move-line__variation" key={key} style={{ paddingLeft: `${1.5 * item.depth}rem` }}>
|
| 40 |
+
{item.chips.map((chip, ci) => chip.kind === 'num'
|
| 41 |
+
? <span className="move-line__num move-line__num--inline" key={ci}>{chip.text}</span>
|
| 42 |
+
: <MoveButton key={ci} node={chip.node} isCurrent={chip.isCurrent} chip onGoTo={onGoTo} />)}
|
| 43 |
+
</div>,
|
| 44 |
+
];
|
| 45 |
+
}
|
| 46 |
+
// 'variation' marker - expand lazily so nested forks don't need to
|
| 47 |
+
// be walked until they're actually reached.
|
| 48 |
+
const varItems = buildVariation(nodes, rootChildren, item.nodeIds, item.whiteToMove, item.moveNum, currentId, item.depth);
|
| 49 |
+
return renderItems(varItems, nodes, rootChildren, currentId, onGoTo, `${key}-v`);
|
| 50 |
+
});
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export default function PgnPanel({ explorer }) {
|
| 54 |
+
const { moveHistory, startFen, historyInitialized, actions } = explorer;
|
| 55 |
+
const { nodes, rootChildren, currentId } = moveHistory;
|
| 56 |
+
const scrollRef = useRef(null);
|
| 57 |
+
|
| 58 |
+
useEffect(() => {
|
| 59 |
+
const container = scrollRef.current;
|
| 60 |
+
const current = container?.querySelector('.is-current');
|
| 61 |
+
scrollIntoViewWithin(current, container);
|
| 62 |
+
}, [currentId, nodes]);
|
| 63 |
+
|
| 64 |
+
let content = null;
|
| 65 |
+
if (!historyInitialized) {
|
| 66 |
+
content = null; // no render until Board reports its first move-tree change
|
| 67 |
+
} else if (!rootChildren || rootChildren.length === 0) {
|
| 68 |
+
content = <span className="move-line__empty">No moves yet</span>;
|
| 69 |
+
} else {
|
| 70 |
+
const { turn, fullmove } = parseFenMeta(startFen);
|
| 71 |
+
const items = buildMoveListItems(nodes, rootChildren, currentId, turn, fullmove);
|
| 72 |
+
content = renderItems(items, nodes, rootChildren, currentId, actions.goToNode, 'm');
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
return (
|
| 76 |
+
<Panel
|
| 77 |
+
className="pgn-panel" label="Game PGN" title="PGN"
|
| 78 |
+
end={
|
| 79 |
+
<>
|
| 80 |
+
<Button id="pgn-btn" compact icon="upload" title="Import PGN game" onClick={() => actions.openPgnDialog()}>Import</Button>
|
| 81 |
+
<Button id="pgn-export-btn" compact icon="download" title="Download game as PGN" onClick={() => actions.exportPgn()}>Export</Button>
|
| 82 |
+
</>
|
| 83 |
+
}
|
| 84 |
+
>
|
| 85 |
+
<div className="pgn-moves-scroll" role="region" aria-label="Move history" tabIndex={0} ref={scrollRef}>
|
| 86 |
+
<div id="move-line" className="move-line" role="list" aria-label="Played moves">
|
| 87 |
+
{content}
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</Panel>
|
| 91 |
+
);
|
| 92 |
+
}
|
frontend/src/components/PromotionDialog.jsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function PromotionDialog() {
|
| 2 |
+
return (
|
| 3 |
+
<div id="promotion-dialog" className="promotion-dialog" role="dialog" aria-modal="true" aria-label="Choose promotion piece">
|
| 4 |
+
<div className="promotion-tray">
|
| 5 |
+
<span className="promotion-tray__title">Promote pawn to:</span>
|
| 6 |
+
<div className="promotion-tray__pieces">
|
| 7 |
+
<button className="promotion-piece-btn" data-piece="q" aria-label="Queen">
|
| 8 |
+
<img src="" alt="Queen" draggable="false" />
|
| 9 |
+
</button>
|
| 10 |
+
<button className="promotion-piece-btn" data-piece="r" aria-label="Rook">
|
| 11 |
+
<img src="" alt="Rook" draggable="false" />
|
| 12 |
+
</button>
|
| 13 |
+
<button className="promotion-piece-btn" data-piece="b" aria-label="Bishop">
|
| 14 |
+
<img src="" alt="Bishop" draggable="false" />
|
| 15 |
+
</button>
|
| 16 |
+
<button className="promotion-piece-btn" data-piece="n" aria-label="Knight">
|
| 17 |
+
<img src="" alt="Knight" draggable="false" />
|
| 18 |
+
</button>
|
| 19 |
+
</div>
|
| 20 |
+
</div>
|
| 21 |
+
</div>
|
| 22 |
+
);
|
| 23 |
+
}
|
frontend/src/components/ResultsPanel.jsx
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from 'react';
|
| 2 |
+
import { Tablebase } from '../engine/tablebase.js';
|
| 3 |
+
import { initMovesGutterSync } from '../engine/scroll.js';
|
| 4 |
+
import Button from './ui/Button.jsx';
|
| 5 |
+
import Panel from './ui/Panel.jsx';
|
| 6 |
+
import Segmented from './ui/Segmented.jsx';
|
| 7 |
+
|
| 8 |
+
const METRICS = ['dtz', 'dtc', 'dtm', 'dtm50'];
|
| 9 |
+
const METRIC_LABEL = { dtz: 'DTZ', dtc: 'DTC', dtm: 'DTM', dtm50: 'DTM50' };
|
| 10 |
+
const METRIC_OPTIONS = METRICS.map(m => ({ value: m, label: METRIC_LABEL[m] }));
|
| 11 |
+
|
| 12 |
+
const COLUMN_CLASSES = [
|
| 13 |
+
'col-rank',
|
| 14 |
+
'group-dtz col-move', 'group-dtz col-score',
|
| 15 |
+
'group-dtc col-move', 'group-dtc col-score',
|
| 16 |
+
'group-dtm col-move', 'group-dtm col-score',
|
| 17 |
+
'group-dtm50 col-move', 'group-dtm50 col-score',
|
| 18 |
+
];
|
| 19 |
+
|
| 20 |
+
function MetricColGroup() {
|
| 21 |
+
return (
|
| 22 |
+
<colgroup>
|
| 23 |
+
{COLUMN_CLASSES.map(cls => <col key={cls} className={cls} />)}
|
| 24 |
+
</colgroup>
|
| 25 |
+
);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function DotIcon({ prefix }) {
|
| 29 |
+
return (
|
| 30 |
+
<svg viewBox="0 0 16 16" aria-hidden="true" className={`${prefix}__icon`}>
|
| 31 |
+
<circle cx="8" cy="8" r="8" className={`${prefix}__bg`} />
|
| 32 |
+
<circle cx="8" cy="4.3" r="1.05" className={`${prefix}__mark`} />
|
| 33 |
+
<rect x="7.1" y="6.5" width="1.8" height="6.2" rx="0.9" className={`${prefix}__mark`} />
|
| 34 |
+
</svg>
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function Cell({ cell, idx }) {
|
| 39 |
+
const props = { key: idx, className: cell.className };
|
| 40 |
+
if (cell.title) props.title = cell.title;
|
| 41 |
+
if (cell.headers) props.headers = cell.headers;
|
| 42 |
+
if (cell.interactive) {
|
| 43 |
+
props['data-san'] = cell.dataSan;
|
| 44 |
+
props['data-child-fen'] = cell.dataChildFen || '';
|
| 45 |
+
props.tabIndex = 0;
|
| 46 |
+
props.role = 'button';
|
| 47 |
+
props['aria-label'] = cell.kind === 'score'
|
| 48 |
+
? `${cell.title}; Play ${cell.dataSan}` : `Play ${cell.dataSan}`;
|
| 49 |
+
} else if (cell.kind === 'score' && cell.title) {
|
| 50 |
+
props['aria-label'] = cell.title;
|
| 51 |
+
}
|
| 52 |
+
if (cell.outcome !== undefined) props['data-outcome'] = cell.outcome;
|
| 53 |
+
return (
|
| 54 |
+
<td {...props}>
|
| 55 |
+
{cell.text}
|
| 56 |
+
{cell.warning && <span className="warning-dot" title={cell.warning}><DotIcon prefix="warning-dot" /></span>}
|
| 57 |
+
{cell.info && <span className="info-dot" title={cell.info}><DotIcon prefix="info-dot" /></span>}
|
| 58 |
+
</td>
|
| 59 |
+
);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function AutoplayButton({ metric, explorer }) {
|
| 63 |
+
const { autoplayMetric, autoplayAvailability, actions } = explorer;
|
| 64 |
+
const info = autoplayAvailability[metric];
|
| 65 |
+
const isPlaying = autoplayMetric === metric;
|
| 66 |
+
return (
|
| 67 |
+
<button id={`autoplay-btn-${metric}`} className={`btn btn--col-autoplay${isPlaying ? ' is-playing' : ''}`}
|
| 68 |
+
disabled={info.disabled}
|
| 69 |
+
title={isPlaying ? `Stop ${METRIC_LABEL[metric]} auto-play` : info.title}
|
| 70 |
+
aria-label={`Auto-play best ${METRIC_LABEL[metric]} move`}
|
| 71 |
+
onClick={() => actions.toggleAutoPlay(metric)}>
|
| 72 |
+
<svg className="autoplay-icon autoplay-icon--play" viewBox="0 0 12 12" aria-hidden="true">
|
| 73 |
+
<path d="M3 2.1 L9.6 6 L3 9.9 Z" />
|
| 74 |
+
</svg>
|
| 75 |
+
<svg className="autoplay-icon autoplay-icon--stop" viewBox="0 0 12 12" aria-hidden="true">
|
| 76 |
+
<rect x="2.4" y="2.4" width="7.2" height="7.2" rx="1" />
|
| 77 |
+
</svg>
|
| 78 |
+
</button>
|
| 79 |
+
);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export default function ResultsPanel({ explorer }) {
|
| 83 |
+
const { tb, activeMetric, actions } = explorer;
|
| 84 |
+
const tbodyRef = useRef(null);
|
| 85 |
+
const cleanupRef = useRef(null);
|
| 86 |
+
|
| 87 |
+
useEffect(() => {
|
| 88 |
+
cleanupRef.current = Tablebase.attachTableEvents(tbodyRef.current);
|
| 89 |
+
return () => cleanupRef.current?.();
|
| 90 |
+
}, []);
|
| 91 |
+
|
| 92 |
+
useEffect(() => initMovesGutterSync(), []);
|
| 93 |
+
|
| 94 |
+
useEffect(() => {
|
| 95 |
+
Tablebase.notifyTableRendered();
|
| 96 |
+
}, [tb.rows, tb.rootRow]);
|
| 97 |
+
|
| 98 |
+
return (
|
| 99 |
+
<Panel
|
| 100 |
+
className={`results-panel${tb.pending ? ' is-pending' : ''}${tb.loading ? ' is-loading' : ''}`}
|
| 101 |
+
label="Tablebase results" title="Tablebase Result"
|
| 102 |
+
start={
|
| 103 |
+
<div className="probe-status" style={{ display: tb.progress.visible ? 'flex' : 'none' }}>
|
| 104 |
+
<span id="probe-progress-label" className="probe-progress-label" aria-live="polite">{tb.progress.label}</span>
|
| 105 |
+
<div id="probe-progress" className={`probe-progress${tb.progress.pct === null ? ' is-indeterminate' : ''}`} aria-hidden="true">
|
| 106 |
+
<div className="probe-progress__fill" style={{ width: tb.progress.pct === null ? undefined : `${tb.progress.pct}%` }}></div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
}
|
| 110 |
+
end={
|
| 111 |
+
<>
|
| 112 |
+
<span id="move-count" className="panel-header__count" aria-live="polite" aria-atomic="true">{tb.moveCount}</span>
|
| 113 |
+
<Button id="export-btn" compact iconOnly icon="download" title="Download move table as CSV" onClick={() => actions.exportCsv()}>CSV</Button>
|
| 114 |
+
</>
|
| 115 |
+
}
|
| 116 |
+
>
|
| 117 |
+
<Segmented
|
| 118 |
+
className="metric-tabs" itemClassName="metric-tab" variant="tabs" label="Tablebase metric"
|
| 119 |
+
options={METRIC_OPTIONS} value={activeMetric} onChange={actions.setActiveMetric}
|
| 120 |
+
/>
|
| 121 |
+
|
| 122 |
+
<div className="moves-view">
|
| 123 |
+
<div className="moves-head">
|
| 124 |
+
<table className="moves-table moves-table--head" data-active-metric={activeMetric}>
|
| 125 |
+
<MetricColGroup />
|
| 126 |
+
<thead>
|
| 127 |
+
<tr className="group-row">
|
| 128 |
+
<th></th>
|
| 129 |
+
{METRICS.map(m => (
|
| 130 |
+
<th key={m} id={`th-${m}-grp`} className={`group-${m}`} colSpan={2}>
|
| 131 |
+
<span className="group-row-inner">
|
| 132 |
+
<span className="group-label">{METRIC_LABEL[m]}</span>
|
| 133 |
+
<AutoplayButton metric={m} explorer={explorer} />
|
| 134 |
+
</span>
|
| 135 |
+
</th>
|
| 136 |
+
))}
|
| 137 |
+
</tr>
|
| 138 |
+
<tr className="col-row">
|
| 139 |
+
<th id="th-rank" className="col-rank" scope="col" aria-label="Rank">#</th>
|
| 140 |
+
<th id="th-dtz-move" className="group-dtz col-move" scope="col">Move</th>
|
| 141 |
+
<th id="th-dtz-score" className="group-dtz col-score" scope="col">Score</th>
|
| 142 |
+
<th id="th-dtc-move" className="group-dtc col-move" scope="col">Move</th>
|
| 143 |
+
<th id="th-dtc-score" className="group-dtc col-score" scope="col">Score/Order</th>
|
| 144 |
+
<th id="th-dtm-move" className="group-dtm col-move" scope="col">Move</th>
|
| 145 |
+
<th id="th-dtm-score" className="group-dtm col-score" scope="col">Score</th>
|
| 146 |
+
<th id="th-dtm50-move" className="group-dtm50 col-move" scope="col">Move</th>
|
| 147 |
+
<th id="th-dtm50-score" className="group-dtm50 col-score" scope="col">Score</th>
|
| 148 |
+
</tr>
|
| 149 |
+
<tr id="root-row" className={`root-row${tb.rootRow ? '' : ' is-hidden'}`}>
|
| 150 |
+
<td className="col-rank"></td>
|
| 151 |
+
{(tb.rootRow?.cells ?? []).map((cell, idx) => <Cell key={idx} cell={cell} idx={idx} />)}
|
| 152 |
+
</tr>
|
| 153 |
+
</thead>
|
| 154 |
+
</table>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
<div className="moves-body-scroll" role="region" aria-label="Move rankings" tabIndex={0}>
|
| 158 |
+
<table className="moves-table moves-table--body" data-active-metric={activeMetric} aria-label="Moves ranked by tablebase metric">
|
| 159 |
+
<caption className="sr-only">
|
| 160 |
+
When Show Root Row is enabled in Settings, the current position's own score is shown
|
| 161 |
+
first, followed by legal moves ranked by Distance to Zeroing, Distance to Conversion,
|
| 162 |
+
Distance to Mate, and Distance to Mate with the 50-move rule
|
| 163 |
+
</caption>
|
| 164 |
+
<MetricColGroup />
|
| 165 |
+
<tbody id="moves-tbody" ref={tbodyRef}>
|
| 166 |
+
{tb.rows.map(row => (
|
| 167 |
+
<tr key={row.rank}>
|
| 168 |
+
<td className="col-rank" headers="th-rank">{row.rank}</td>
|
| 169 |
+
{row.cells.map((cell, idx) => <Cell key={idx} cell={cell} idx={idx} />)}
|
| 170 |
+
</tr>
|
| 171 |
+
))}
|
| 172 |
+
</tbody>
|
| 173 |
+
</table>
|
| 174 |
+
|
| 175 |
+
<div id="moves-empty-state" className="moves-empty-state" style={{ display: tb.emptyState ? 'flex' : 'none' }} aria-live="polite">
|
| 176 |
+
<span className="moves-empty-state__icon" aria-hidden="true">♟</span>
|
| 177 |
+
<p>{tb.emptyMessage}</p>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
</Panel>
|
| 182 |
+
);
|
| 183 |
+
}
|
frontend/src/components/ScreenNav.jsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react';
|
| 2 |
+
|
| 3 |
+
const SCREENS = ['Board', 'Results', 'Moves'];
|
| 4 |
+
|
| 5 |
+
export default function ScreenNav() {
|
| 6 |
+
const [current, setCurrent] = useState(0);
|
| 7 |
+
|
| 8 |
+
useEffect(() => {
|
| 9 |
+
const content = document.querySelector('.content');
|
| 10 |
+
if (!content) return undefined;
|
| 11 |
+
const onScroll = () => setCurrent(Math.round(content.scrollLeft / content.clientWidth));
|
| 12 |
+
content.addEventListener('scroll', onScroll, { passive: true });
|
| 13 |
+
return () => content.removeEventListener('scroll', onScroll);
|
| 14 |
+
}, []);
|
| 15 |
+
|
| 16 |
+
function goTo(index) {
|
| 17 |
+
const content = document.querySelector('.content');
|
| 18 |
+
if (!content) return;
|
| 19 |
+
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
| 20 |
+
content.scrollTo({ left: index * content.clientWidth, behavior: reduced ? 'auto' : 'smooth' });
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<nav className="screen-nav" aria-label="Screens">
|
| 25 |
+
{SCREENS.map((label, i) => (
|
| 26 |
+
<button key={label} type="button" className="screen-nav__item"
|
| 27 |
+
aria-current={i === current ? 'page' : undefined} onClick={() => goTo(i)}>
|
| 28 |
+
{label}
|
| 29 |
+
</button>
|
| 30 |
+
))}
|
| 31 |
+
</nav>
|
| 32 |
+
);
|
| 33 |
+
}
|
frontend/src/components/SettingsPanel.jsx
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from 'react';
|
| 2 |
+
import { formatAutoPlayDelay } from '../hooks/useExplorer.js';
|
| 3 |
+
import Button from './ui/Button.jsx';
|
| 4 |
+
|
| 5 |
+
const SHORTCUTS = [['Enter', 'Apply'], ['F', 'Flip'], ['C', 'Clear'], ['←', 'Back'], ['→', 'Forward']];
|
| 6 |
+
|
| 7 |
+
const DELAY_MIN = 0;
|
| 8 |
+
const DELAY_MAX = 2500;
|
| 9 |
+
|
| 10 |
+
export default function SettingsPanel({ explorer }) {
|
| 11 |
+
const { settingsOpen, setSettingsOpen, showRootRow, variant, autoplayDelayMs, actions } = explorer;
|
| 12 |
+
const panelRef = useRef(null);
|
| 13 |
+
const chess960 = variant === 'chess960';
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
if (!settingsOpen) return;
|
| 17 |
+
function onDocClick(e) {
|
| 18 |
+
if (panelRef.current && !panelRef.current.contains(e.target) && !e.target.closest('#settings-btn')) {
|
| 19 |
+
setSettingsOpen(false);
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
function onKeydown(e) {
|
| 23 |
+
if (e.key === 'Escape') { e.preventDefault(); setSettingsOpen(false); }
|
| 24 |
+
}
|
| 25 |
+
document.addEventListener('click', onDocClick);
|
| 26 |
+
document.addEventListener('keydown', onKeydown);
|
| 27 |
+
return () => {
|
| 28 |
+
document.removeEventListener('click', onDocClick);
|
| 29 |
+
document.removeEventListener('keydown', onKeydown);
|
| 30 |
+
};
|
| 31 |
+
}, [settingsOpen, setSettingsOpen]);
|
| 32 |
+
|
| 33 |
+
const pct = ((autoplayDelayMs - DELAY_MIN) / (DELAY_MAX - DELAY_MIN)) * 100;
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div id="settings-panel" ref={panelRef} className={`settings-panel${settingsOpen ? ' is-open' : ''}`}
|
| 37 |
+
role="dialog" aria-modal="false" aria-label="Board settings">
|
| 38 |
+
<div className="settings-panel__header">
|
| 39 |
+
<span className="settings-panel__title">Board Settings</span>
|
| 40 |
+
<Button id="settings-close-btn" compact iconOnly icon="close" aria-label="Close settings"
|
| 41 |
+
onClick={() => setSettingsOpen(false)} />
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div className="settings-row settings-row--toggle">
|
| 45 |
+
<label className="settings-toggle" htmlFor="show-root-row-toggle">
|
| 46 |
+
<span className="settings-toggle__label">Show Root Row</span>
|
| 47 |
+
<span className="toggle-switch">
|
| 48 |
+
<input type="checkbox" id="show-root-row-toggle" className="toggle-switch__input" role="switch"
|
| 49 |
+
checked={showRootRow} onChange={(e) => actions.setShowRootRowAction(e.target.checked)} />
|
| 50 |
+
<span className="toggle-switch__track" aria-hidden="true"></span>
|
| 51 |
+
</span>
|
| 52 |
+
</label>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div className="settings-row settings-row--toggle">
|
| 56 |
+
<label className="settings-toggle" htmlFor="chess960-mode-toggle">
|
| 57 |
+
<span className="settings-toggle__label">Chess960 Mode</span>
|
| 58 |
+
<span className="toggle-switch">
|
| 59 |
+
<input type="checkbox" id="chess960-mode-toggle" className="toggle-switch__input" role="switch"
|
| 60 |
+
checked={chess960} onChange={(e) => actions.setChess960Action(e.target.checked)} />
|
| 61 |
+
<span className="toggle-switch__track" aria-hidden="true"></span>
|
| 62 |
+
</span>
|
| 63 |
+
</label>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<div className="settings-row settings-row--slider">
|
| 67 |
+
<span className="settings-slider__label">Autoplay Delay</span>
|
| 68 |
+
<div className="settings-slider__control">
|
| 69 |
+
<input type="range" id="autoplay-delay-slider" className="range-slider"
|
| 70 |
+
min={DELAY_MIN} max={DELAY_MAX} step={50} value={autoplayDelayMs}
|
| 71 |
+
style={{ '--range-fill': `${pct}%` }}
|
| 72 |
+
aria-label="Autoplay delay" aria-valuetext={formatAutoPlayDelay(autoplayDelayMs)}
|
| 73 |
+
onChange={(e) => actions.setAutoplayDelayAction(parseInt(e.target.value, 10))} />
|
| 74 |
+
<div className="range-slider__ticks" aria-hidden="true">
|
| 75 |
+
<span>0s</span><span>1.25s</span><span>2.5s</span>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
<span className="settings-slider__value" id="autoplay-delay-value">{formatAutoPlayDelay(autoplayDelayMs)}</span>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<div className="settings-row settings-row--shortcuts">
|
| 82 |
+
<span className="settings-shortcuts__title">Keyboard Shortcuts</span>
|
| 83 |
+
<ul className="settings-shortcuts__list">
|
| 84 |
+
{SHORTCUTS.map(([key, action]) => (
|
| 85 |
+
<li key={key}><kbd>{key}</kbd> {action}</li>
|
| 86 |
+
))}
|
| 87 |
+
</ul>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
);
|
| 91 |
+
}
|
frontend/src/components/Toasts.jsx
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function Toasts({ toasts }) {
|
| 2 |
+
return (
|
| 3 |
+
<div id="toast-container" className="toast-container" aria-live="polite" aria-atomic="false">
|
| 4 |
+
{toasts.map(t => (
|
| 5 |
+
<div key={t.id} className={`toast${t.leaving ? ' is-out' : ''}`}>{t.message}</div>
|
| 6 |
+
))}
|
| 7 |
+
</div>
|
| 8 |
+
);
|
| 9 |
+
}
|
frontend/src/components/ui/Button.jsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Icon from './Icon.jsx';
|
| 2 |
+
|
| 3 |
+
export default function Button({ variant, compact, grow, iconOnly, icon, className = '', children, ...rest }) {
|
| 4 |
+
const cls = [
|
| 5 |
+
'btn',
|
| 6 |
+
variant === 'primary' && 'btn--apply',
|
| 7 |
+
compact && 'btn--compact',
|
| 8 |
+
grow && 'btn--grow',
|
| 9 |
+
iconOnly && 'btn--icon-only',
|
| 10 |
+
className,
|
| 11 |
+
].filter(Boolean).join(' ');
|
| 12 |
+
return (
|
| 13 |
+
<button type="button" className={cls} {...rest}>
|
| 14 |
+
{icon && <Icon name={icon} className="btn__icon" />}
|
| 15 |
+
{children}
|
| 16 |
+
</button>
|
| 17 |
+
);
|
| 18 |
+
}
|
frontend/src/components/ui/Icon.jsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const STROKE_WIDTH = { check: 2.5, close: 2.5 };
|
| 2 |
+
|
| 3 |
+
const PATHS = {
|
| 4 |
+
repository: <><path d="M7 8.5v7m10-7V10a4 4 0 0 1-4 4H7" /><circle cx="7" cy="6" r="2.5" /><circle cx="7" cy="18" r="2.5" /><circle cx="17" cy="6" r="2.5" /></>,
|
| 5 |
+
check: <polyline points="20 6 9 17 4 12" />,
|
| 6 |
+
close: <><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></>,
|
| 7 |
+
flip: <><path d="M7 16V4m0 0L3 8m4-4 4 4" /><path d="M17 8v12m0 0 4-4m-4 4-4-4" /></>,
|
| 8 |
+
trash: <><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /></>,
|
| 9 |
+
copy: <><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>,
|
| 10 |
+
arrowLeft: <><path d="M19 12H5" /><polyline points="12 19 5 12 12 5" /></>,
|
| 11 |
+
arrowRight: <><path d="M5 12h14" /><polyline points="12 5 19 12 12 19" /></>,
|
| 12 |
+
download: <><path d="M12 3v12" /><polyline points="7 10 12 15 17 10" /><path d="M4 19h16" /></>,
|
| 13 |
+
upload: <><path d="M12 21V9" /><polyline points="7 14 12 9 17 14" /><path d="M4 5h16" /></>,
|
| 14 |
+
lockOpen: <><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0" /></>,
|
| 15 |
+
lockClosed: <><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>,
|
| 16 |
+
gauge: <><path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z" /><path d="M12 14 16 8" /><path d="M5 19a9 9 0 1 1 14 0" /></>,
|
| 17 |
+
code: <><polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" /></>,
|
| 18 |
+
settings: (
|
| 19 |
+
<>
|
| 20 |
+
<circle cx="12" cy="12" r="3" />
|
| 21 |
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
| 22 |
+
</>
|
| 23 |
+
),
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
const GITHUB_PATH = 'M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z';
|
| 27 |
+
|
| 28 |
+
export default function Icon({ name, className = '' }) {
|
| 29 |
+
const cls = `icon${className ? ` ${className}` : ''}`;
|
| 30 |
+
if (name === 'github') {
|
| 31 |
+
return (
|
| 32 |
+
<svg className={cls} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
| 33 |
+
<path fillRule="evenodd" clipRule="evenodd" d={GITHUB_PATH} />
|
| 34 |
+
</svg>
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
return (
|
| 38 |
+
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 39 |
+
strokeWidth={STROKE_WIDTH[name] ?? 2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
| 40 |
+
{PATHS[name]}
|
| 41 |
+
</svg>
|
| 42 |
+
);
|
| 43 |
+
}
|
frontend/src/components/ui/Panel.jsx
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function Panel({ label, title, start, end, className = '', children }) {
|
| 2 |
+
return (
|
| 3 |
+
<section className={`panel${className ? ` ${className}` : ''}`} aria-label={label}>
|
| 4 |
+
<div className="panel-header">
|
| 5 |
+
<div className="panel-header__start">
|
| 6 |
+
<span className="panel-header__title">{title}</span>
|
| 7 |
+
{start}
|
| 8 |
+
</div>
|
| 9 |
+
<div className="panel-header__end">{end}</div>
|
| 10 |
+
</div>
|
| 11 |
+
{children}
|
| 12 |
+
</section>
|
| 13 |
+
);
|
| 14 |
+
}
|
frontend/src/components/ui/Segmented.jsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useRef } from 'react';
|
| 2 |
+
|
| 3 |
+
export default function Segmented({ label, options, value, onChange, variant = 'radio', className = '', itemClassName = '' }) {
|
| 4 |
+
const groupRef = useRef(null);
|
| 5 |
+
const tabs = variant === 'tabs';
|
| 6 |
+
|
| 7 |
+
function onKeyDown(e) {
|
| 8 |
+
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
|
| 9 |
+
if (!step) return;
|
| 10 |
+
e.preventDefault();
|
| 11 |
+
e.stopPropagation();
|
| 12 |
+
const i = options.findIndex(o => o.value === value);
|
| 13 |
+
const next = options[(i + step + options.length) % options.length];
|
| 14 |
+
onChange(next.value);
|
| 15 |
+
groupRef.current?.querySelector(`[data-value="${next.value}"]`)?.focus();
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<div ref={groupRef} className={className} role={tabs ? 'tablist' : 'radiogroup'} aria-label={label} onKeyDown={onKeyDown}>
|
| 20 |
+
{options.map(o => {
|
| 21 |
+
const active = o.value === value;
|
| 22 |
+
return (
|
| 23 |
+
<button
|
| 24 |
+
key={o.value} id={o.id} type="button" data-value={o.value}
|
| 25 |
+
className={`${itemClassName}${active ? ' is-active' : ''}`}
|
| 26 |
+
role={tabs ? 'tab' : 'radio'}
|
| 27 |
+
{...(tabs ? { 'aria-selected': active } : { 'aria-checked': active })}
|
| 28 |
+
tabIndex={active ? 0 : -1}
|
| 29 |
+
title={o.title}
|
| 30 |
+
onClick={() => onChange(o.value)}
|
| 31 |
+
>
|
| 32 |
+
{o.content ?? o.label}
|
| 33 |
+
</button>
|
| 34 |
+
);
|
| 35 |
+
})}
|
| 36 |
+
</div>
|
| 37 |
+
);
|
| 38 |
+
}
|
frontend/src/engine/board.js
ADDED
|
@@ -0,0 +1,1282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* board.js: Chessground wrapper
|
| 3 |
+
*
|
| 4 |
+
* Wraps the vendor Chessground() instance and a chessops position, and owns:
|
| 5 |
+
* - A branching move tree (_nodes/_rootChildren) used by the PGN panel:
|
| 6 |
+
* playing a move after navigating back adds a variation instead of
|
| 7 |
+
* overwriting the continuation from that point. A plain
|
| 8 |
+
* undo/redo FEN stack (_history/_future) sits alongside it as a
|
| 9 |
+
* fallback for Back/Forward once navigation walks off the tree (e.g.
|
| 10 |
+
* past a board edit that started a fresh one).
|
| 11 |
+
* - Best-move arrows for DTZ/DTC/DTM/DTM50, drawn via chessground's
|
| 12 |
+
* native drawable.autoShapes.
|
| 13 |
+
* - Click-to-place editing: click a spare piece, then click a square to
|
| 14 |
+
* place it. Drag-from-tray is also supported (chessground's
|
| 15 |
+
* dragNewPiece()).
|
| 16 |
+
* - goToNode() - jump to any node in the current move tree (used by
|
| 17 |
+
* the PGN panel, including variation moves) without resetting the tree
|
| 18 |
+
* or re-triggering a probe for a position that's already been evaluated.
|
| 19 |
+
* - Board/piece theming: the piece image path is owned by theme.js
|
| 20 |
+
* (Theme.pieceThemeFn()). reconstruct() updates the piece
|
| 21 |
+
* CSS <link> and repopulates the spare-tray/promotion-dialog images;
|
| 22 |
+
* chessground is never destroyed and rebuilt for theming, since piece
|
| 23 |
+
* images are plain CSS.
|
| 24 |
+
* - The active rules variant (Standard / Chess960) and the FEN protocol
|
| 25 |
+
* boundary between them. chessops's own `Chess` class implements both
|
| 26 |
+
* rulesets' move generation/legality/SAN identically and has no
|
| 27 |
+
* variant concept of its own - castling legality, paths, and rook
|
| 28 |
+
* squares are computed the same way regardless of variant (see
|
| 29 |
+
* chessgroundDests() below for the one place chessops is told which
|
| 30 |
+
* variant is active, purely to choose which destinations chessground
|
| 31 |
+
* is offered). The application's Standard-vs-Chess960 FEN contract is
|
| 32 |
+
* stricter than chessops's generic one: chessops's FEN parser resolves
|
| 33 |
+
* a 'K'/'Q'/'k'/'q' castling letter to whichever rook sits on the
|
| 34 |
+
* correct side of the king wherever the king happens to be (needed for
|
| 35 |
+
* Chess960, where the king isn't always on the e-file) and also accepts
|
| 36 |
+
* Shredder-FEN/X-FEN rook-file letters unconditionally - neither of
|
| 37 |
+
* which is a legal Standard-chess castling encoding. _buildPosition()
|
| 38 |
+
* below enforces the application's Standard-mode castling contract
|
| 39 |
+
* (only 'K'/'Q'/'k'/'q', each requiring the king on its e1/e8 home
|
| 40 |
+
* square and the resolved rook on its exact a1/h1/a8/h8 home square)
|
| 41 |
+
* before trusting chessops's more permissive parse, so the frontend
|
| 42 |
+
* and the backend (see app.py's _validate_fen_parts and python-chess's
|
| 43 |
+
* own chess960=False castling validation) agree on whether a given FEN
|
| 44 |
+
* is valid for the active variant.
|
| 45 |
+
*
|
| 46 |
+
* Every position-changing action - setPosition(), clear(), playMove(),
|
| 47 |
+
* goBack()/goForward(), board edits (off-board drop, board-setup drag,
|
| 48 |
+
* click-to-place), setVariant() - records itself (in the tree or the
|
| 49 |
+
* undo/redo stack, whichever applies) and invokes the onPositionChange
|
| 50 |
+
* callback itself, so callers never need to remember to do either one
|
| 51 |
+
* manually.
|
| 52 |
+
*
|
| 53 |
+
* The chessops position (_position) is the single source of truth for
|
| 54 |
+
* position/legality - chessground is a dumb, controlled rendering surface.
|
| 55 |
+
* Every position-changing function ends with a call to _syncChessground(),
|
| 56 |
+
* which pushes _position's current FEN (plus turn colour, check state, and
|
| 57 |
+
* last-move squares) into chessground in one shot. The core mutation
|
| 58 |
+
* handler (_handleBoardMutation) is idempotent rather than tracking which
|
| 59 |
+
* of chessground's several event hooks handled a given user action,
|
| 60 |
+
* because chessground fires multiple, differently-scoped events for what
|
| 61 |
+
* is logically one move. For an ordinary relocation, events.move fires
|
| 62 |
+
* first, then events.change, then movable.events.after (see board.ts's
|
| 63 |
+
* baseMove()/userMove()). events.change carries no orig/dest, so if it won
|
| 64 |
+
* the idempotency race it would take the generic "board edit" branch and
|
| 65 |
+
* leave movable.events.after's orig/dest with nothing left to act on.
|
| 66 |
+
* events.move is therefore the primary handler: it fires before
|
| 67 |
+
* events.change and, unlike movable.events.after, carries orig/dest (plus
|
| 68 |
+
* capture info), so it wins the idempotency race with enough information
|
| 69 |
+
* to classify the move correctly. events.change and movable.events.after
|
| 70 |
+
* are wired as idempotent fallbacks, and events.change is the only hook
|
| 71 |
+
* that fires for an off-board deletion (drag.ts's deleteOnDropOff branch
|
| 72 |
+
* calls it directly, with no accompanying events.move/
|
| 73 |
+
* movable.events.after).
|
| 74 |
+
*/
|
| 75 |
+
|
| 76 |
+
import { Chessground } from '../vendor/chessground.min.js';
|
| 77 |
+
import { Theme } from './theme.js';
|
| 78 |
+
import * as Storage from './storage.js';
|
| 79 |
+
|
| 80 |
+
import { Chess, Castles, castlingSide } from '../vendor/chessops/chess.js';
|
| 81 |
+
import { parseFen, makeFen } from '../vendor/chessops/fen.js';
|
| 82 |
+
import { chessgroundDests } from '../vendor/chessops/compat.js';
|
| 83 |
+
import { parseSan, makeSan } from '../vendor/chessops/san.js';
|
| 84 |
+
import { parseSquare, makeSquare } from '../vendor/chessops/util.js';
|
| 85 |
+
|
| 86 |
+
// ── Variant ──────────────────────────────────────────────────────────────
|
| 87 |
+
// The application's rules-mode state. Distinct from chessops's own `rules`
|
| 88 |
+
// property (always 'chess' for both Standard and Chess960 - see the
|
| 89 |
+
// module doc comment above): this is application metadata that travels
|
| 90 |
+
// alongside the position, never derived from it.
|
| 91 |
+
const VARIANTS = Object.freeze({ STANDARD: 'standard', CHESS960: 'chess960' });
|
| 92 |
+
|
| 93 |
+
const Board = (() => {
|
| 94 |
+
|
| 95 |
+
let cg = null; // Chessground instance
|
| 96 |
+
let _position = null; // chessops Chess position - see module doc comment
|
| 97 |
+
let _variant = VARIANTS.STANDARD;
|
| 98 |
+
let _onPositionChange = null;
|
| 99 |
+
let _onMoveHistoryChange = null;
|
| 100 |
+
|
| 101 |
+
let _pendingPromotion = null;
|
| 102 |
+
|
| 103 |
+
// ── Lock mode ────────────────────────────────────────────────────────────
|
| 104 |
+
// When true, the board only accepts legal moves for the side to move;
|
| 105 |
+
// spare-piece placement and off-board deletion are disabled. Toggled via
|
| 106 |
+
// setLocked(). Moves played through the ranked-moves table, auto-play, or
|
| 107 |
+
// the PGN panel are unaffected, since those are already always legal.
|
| 108 |
+
let _locked = false;
|
| 109 |
+
|
| 110 |
+
// [orig, dest] of the most recently played/navigated-to move, or
|
| 111 |
+
// undefined. Fed into chessground's `lastMove` config on every sync.
|
| 112 |
+
// Tracked separately from the move tree because Back/Forward/goToNode
|
| 113 |
+
// land on a node that already has its own from/to squares recorded;
|
| 114 |
+
// this variable is what lets _syncChessground() highlight the right
|
| 115 |
+
// squares for those navigations too, not just for a freshly played move.
|
| 116 |
+
let _lastMoveKeys = undefined;
|
| 117 |
+
|
| 118 |
+
// ── Undo / redo stacks ────────────────────────────────────────────────────
|
| 119 |
+
// Plain chronological FEN log, used only as a Back/Forward fallback once
|
| 120 |
+
// navigation walks off the current move tree entirely (e.g. past a board
|
| 121 |
+
// edit - edits aren't part of the tree, see _resetMoveLine below). While
|
| 122 |
+
// the current position is inside the tree, goBack()/goForward() use the
|
| 123 |
+
// tree's parent/activeChild pointers instead (see below).
|
| 124 |
+
let _history = [];
|
| 125 |
+
let _future = [];
|
| 126 |
+
const _MAX_HISTORY = 100;
|
| 127 |
+
|
| 128 |
+
function _pushHistory(fen) {
|
| 129 |
+
_history.push(fen);
|
| 130 |
+
if (_history.length > _MAX_HISTORY) _history.shift();
|
| 131 |
+
_future = []; // any new position discards the redo stack
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
// ── Move tree ──────────────────────────────────────────────────────────────
|
| 135 |
+
// Only legal moves via playMove() (and the promotion/drag-move
|
| 136 |
+
// equivalents) are recorded here. setPosition() / clear() / board edits /
|
| 137 |
+
// setVariant() reset the tree.
|
| 138 |
+
//
|
| 139 |
+
// Each node is {id, san, fen, from, to, parentId, children, activeChild}.
|
| 140 |
+
// `id` is also the node's index in _nodes. parentId === ROOT (-1) means
|
| 141 |
+
// the node replies to the tree's start position (_startFen). `children`
|
| 142 |
+
// lists every move tried from that node - children[0] renders as the
|
| 143 |
+
// "main" continuation, children[1+] as parenthesised variations.
|
| 144 |
+
// `activeChild` is whichever child Forward should follow from that node
|
| 145 |
+
// (whichever branch was most recently played/clicked into) - not
|
| 146 |
+
// necessarily children[0]. `from`/`to` are the move's origin/destination
|
| 147 |
+
// squares exactly as played (for a Chess960 castle, the king's origin
|
| 148 |
+
// and whichever destination square chessground/chessops reported,
|
| 149 |
+
// never assumed to be a fixed c/g-file square), recorded alongside
|
| 150 |
+
// san/fen so lastMove highlighting works when navigating the tree, not
|
| 151 |
+
// just on a freshly-played move.
|
| 152 |
+
//
|
| 153 |
+
// The PGN panel and PGN export walk this tree recursively so alternate
|
| 154 |
+
// moves are preserved as variations instead of overwriting history.
|
| 155 |
+
const ROOT = -1;
|
| 156 |
+
|
| 157 |
+
let _nodes = []; // [{id, san, fen, from, to, parentId, children:[], activeChild}]
|
| 158 |
+
let _rootChildren = []; // ids of moves played directly from _startFen
|
| 159 |
+
let _rootActiveChild = null;
|
| 160 |
+
let _currentId = ROOT; // id of the node currently shown, or ROOT for _startFen itself
|
| 161 |
+
let _startFen = null; // FEN the current tree began from (for PGN formatting)
|
| 162 |
+
|
| 163 |
+
function _childrenOf(id) { return id === ROOT ? _rootChildren : _nodes[id].children; }
|
| 164 |
+
function _activeChildOf(id) { return id === ROOT ? _rootActiveChild : _nodes[id].activeChild; }
|
| 165 |
+
function _setActiveChild(id, childId) {
|
| 166 |
+
if (id === ROOT) _rootActiveChild = childId;
|
| 167 |
+
else _nodes[id].activeChild = childId;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
function _resetMoveLine() {
|
| 171 |
+
_nodes = [];
|
| 172 |
+
_rootChildren = [];
|
| 173 |
+
_rootActiveChild = null;
|
| 174 |
+
_currentId = ROOT;
|
| 175 |
+
_future = [];
|
| 176 |
+
_startFen = _position ? currentFen() : null;
|
| 177 |
+
if (_onMoveHistoryChange) _emitHistory();
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
function _emitHistory() {
|
| 181 |
+
if (_onMoveHistoryChange) {
|
| 182 |
+
_onMoveHistoryChange({
|
| 183 |
+
nodes: _nodes, rootChildren: _rootChildren,
|
| 184 |
+
rootActiveChild: _rootActiveChild, currentId: _currentId,
|
| 185 |
+
}, _startFen);
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// Records a played move as a child of the current node. If the current
|
| 190 |
+
// node already has a child with this exact SAN (the user replayed a move
|
| 191 |
+
// already tried from this point), that existing node is reused instead
|
| 192 |
+
// of creating a duplicate. Otherwise a new node is created and appended
|
| 193 |
+
// to the end of the children list, so an already-explored continuation
|
| 194 |
+
// stays the main line and the newly-played move is recorded as a
|
| 195 |
+
// parenthesised variation.
|
| 196 |
+
function _addMove(san, fen, from, to) {
|
| 197 |
+
_future = []; // starting a fresh line invalidates any pre-tree redo
|
| 198 |
+
const kids = _childrenOf(_currentId);
|
| 199 |
+
let childId = kids.find(id => _nodes[id].san === san);
|
| 200 |
+
if (childId === undefined) {
|
| 201 |
+
childId = _nodes.length;
|
| 202 |
+
_nodes.push({ id: childId, san, fen, from, to, parentId: _currentId, children: [], activeChild: null });
|
| 203 |
+
kids.push(childId);
|
| 204 |
+
}
|
| 205 |
+
// (else: reused an existing child - same SAN replayed from this node)
|
| 206 |
+
_setActiveChild(_currentId, childId);
|
| 207 |
+
_currentId = childId;
|
| 208 |
+
return childId;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// ── Piece theme ───────────────────────────────────────────────────────────
|
| 212 |
+
// Delegates to theme.js, which owns the active piece set. Kept as a local
|
| 213 |
+
// wrapper (rather than calling Theme.pieceThemeFn() inline everywhere)
|
| 214 |
+
// so callers below don't need to know theme.js exists. Resolved once on
|
| 215 |
+
// first use rather than re-fetched from Theme on every call.
|
| 216 |
+
let _pieceThemeFn = null;
|
| 217 |
+
function _pieceTheme(piece) {
|
| 218 |
+
if (!_pieceThemeFn) _pieceThemeFn = Theme.pieceThemeFn();
|
| 219 |
+
return _pieceThemeFn(piece);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
// ── chessops adapter ─────────────────────────────────────────────────────
|
| 223 |
+
// Small translation layer between this file's FEN-string/algebraic-key
|
| 224 |
+
// vocabulary and chessops's Setup/Position/numeric-square vocabulary.
|
| 225 |
+
// Contains no chess rules of its own - every legality, castling, and
|
| 226 |
+
// check/mate determination below is delegated to chessops.
|
| 227 |
+
|
| 228 |
+
function currentFen() { return makeFen(_position.toSetup()); }
|
| 229 |
+
function _turnChar(pos) { return pos.turn === 'white' ? 'w' : 'b'; }
|
| 230 |
+
|
| 231 |
+
function _fenErrorMessage(err) {
|
| 232 |
+
switch (err && err.message) {
|
| 233 |
+
case 'ERR_CASTLING':
|
| 234 |
+
return "Invalid castling availability: expected some combination of 'K', 'Q', 'k', 'q', rook-file letters 'a'-'h'/'A'-'H', or '-'.";
|
| 235 |
+
case 'ERR_TURN': return 'Invalid FEN: side to move must be "w" or "b".';
|
| 236 |
+
case 'ERR_EP_SQUARE': return 'Invalid FEN: en passant target square is not valid.';
|
| 237 |
+
case 'ERR_HALFMOVES': return 'Invalid FEN: halfmove clock is not valid.';
|
| 238 |
+
case 'ERR_FULLMOVES': return 'Invalid FEN: fullmove number is not valid.';
|
| 239 |
+
case 'ERR_BOARD': return 'Invalid FEN: board part is not valid.';
|
| 240 |
+
default: return 'Invalid FEN: position violates chess rules.';
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
function _positionErrorMessage(err) {
|
| 244 |
+
switch (err && err.message) {
|
| 245 |
+
case 'ERR_KINGS': return 'Position must have exactly one king per side.';
|
| 246 |
+
case 'ERR_OPPOSITE_CHECK': return 'Invalid position: the side not to move is already in check.';
|
| 247 |
+
case 'ERR_PAWNS_ON_BACKRANK': return 'Invalid position: a pawn is on the back rank.';
|
| 248 |
+
case 'ERR_EMPTY': return 'Position has no pieces.';
|
| 249 |
+
default: return 'Invalid FEN: position violates chess rules.';
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
// A castling-availability letter with no matching king/rook pair on the
|
| 254 |
+
// board (e.g. "K" with no white king and rook on their home squares) is
|
| 255 |
+
// syntactically valid but not a legal right - chessops's own parser
|
| 256 |
+
// silently drops it rather than rejecting the FEN (see parseCastlingFen
|
| 257 |
+
// in fen.js), so that has to be checked here by comparing what was
|
| 258 |
+
// requested against what the constructed position actually granted.
|
| 259 |
+
// This works the same way for Standard 'KQkq' and Chess960 file-letter
|
| 260 |
+
// rights, since both are resolved by chessops before this comparison;
|
| 261 |
+
// no Standard-only assumption is made about which squares are valid.
|
| 262 |
+
function _missingCastlingRights(setup, pos) {
|
| 263 |
+
const missing = [];
|
| 264 |
+
for (const square of setup.castlingRights) {
|
| 265 |
+
if (!pos.castles.castlingRights.has(square)) missing.push(makeSquare(square));
|
| 266 |
+
}
|
| 267 |
+
return missing;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
// Standard chess's castling letters mean something fixed: 'K'/'Q' is
|
| 271 |
+
// the White rook on h1/a1, with the White king on e1 - never "whichever
|
| 272 |
+
// rook is right/left of the king" (chessops's own, Chess960-capable,
|
| 273 |
+
// resolution rule; see the module doc comment). A right that chessops
|
| 274 |
+
// resolves to any other square is a Chess960-only reading of the same
|
| 275 |
+
// letter and must be rejected here, in Standard mode, even though
|
| 276 |
+
// chessops itself parsed it without complaint. Chess960 mode has no
|
| 277 |
+
// "home square" of its own - any resolved king/rook pair is
|
| 278 |
+
// acceptable there, which _missingCastlingRights above already covers.
|
| 279 |
+
const _HOME_SQUARES = {
|
| 280 |
+
white: { king: parseSquare('e1'), rook: { a: parseSquare('a1'), h: parseSquare('h1') } },
|
| 281 |
+
black: { king: parseSquare('e8'), rook: { a: parseSquare('a8'), h: parseSquare('h8') } },
|
| 282 |
+
};
|
| 283 |
+
function _nonOrthodoxCastlingRights(pos) {
|
| 284 |
+
const offending = [];
|
| 285 |
+
for (const color of ['white', 'black']) {
|
| 286 |
+
const kingSquare = pos.board.kingOf(color);
|
| 287 |
+
for (const side of ['a', 'h']) {
|
| 288 |
+
const rookSquare = pos.castles.rook[color][side];
|
| 289 |
+
if (rookSquare === undefined) continue;
|
| 290 |
+
if (kingSquare !== _HOME_SQUARES[color].king || rookSquare !== _HOME_SQUARES[color].rook[side]) {
|
| 291 |
+
offending.push(makeSquare(rookSquare));
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
return offending;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
// Standard mode accepts only 'K'/'Q'/'k'/'q' (each optional, in that
|
| 299 |
+
// order) or '-'; Chess960 additionally accepts Shredder-FEN/X-FEN
|
| 300 |
+
// rook-file letters. Mirrors app.py's _validate_fen_parts exactly:
|
| 301 |
+
// same accepted grammar, same rejected input, so a FEN the frontend
|
| 302 |
+
// accepts is never one the backend then rejects, or vice versa.
|
| 303 |
+
const _CASTLING_SYNTAX_STANDARD = /^K?Q?k?q?$/;
|
| 304 |
+
const _CASTLING_SYNTAX_CHESS960 = /^[A-Ha-hKQkq]{1,4}$/;
|
| 305 |
+
function _castlingSyntaxError(variant, field) {
|
| 306 |
+
if (field === '-') return null;
|
| 307 |
+
const pattern = variant === VARIANTS.CHESS960 ? _CASTLING_SYNTAX_CHESS960 : _CASTLING_SYNTAX_STANDARD;
|
| 308 |
+
if (pattern.test(field)) return null;
|
| 309 |
+
const expected = variant === VARIANTS.CHESS960
|
| 310 |
+
? "some combination of 'K', 'Q', 'k', 'q', rook-file letters 'a'-'h'/'A'-'H', or '-'"
|
| 311 |
+
: "some combination of 'K', 'Q', 'k', 'q', or '-'";
|
| 312 |
+
return `Invalid castling availability: '${field}' (expected ${expected}).`;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
// Parses and fully validates a FEN into a chessops position under the
|
| 316 |
+
// given variant (defaulting to the currently-active one), or returns a
|
| 317 |
+
// user-facing reason it was rejected. Rejects a syntactically-valid
|
| 318 |
+
// chessops parse that isn't actually a legal encoding for the
|
| 319 |
+
// requested variant (see the two checks above and the module doc
|
| 320 |
+
// comment) - the same FEN text can be accepted under one variant and
|
| 321 |
+
// rejected under the other.
|
| 322 |
+
function _buildPosition(fen, variant = _variant) {
|
| 323 |
+
const fields = fen.split(' ');
|
| 324 |
+
if (fields.length >= 3) {
|
| 325 |
+
const syntaxError = _castlingSyntaxError(variant, fields[2]);
|
| 326 |
+
if (syntaxError) return { ok: false, reason: syntaxError };
|
| 327 |
+
}
|
| 328 |
+
const parsed = parseFen(fen);
|
| 329 |
+
if (parsed.isErr) return { ok: false, reason: _fenErrorMessage(parsed.error) };
|
| 330 |
+
const setup = parsed.value;
|
| 331 |
+
const built = Chess.fromSetup(setup);
|
| 332 |
+
if (built.isErr) return { ok: false, reason: _positionErrorMessage(built.error) };
|
| 333 |
+
const pos = built.value;
|
| 334 |
+
const missing = _missingCastlingRights(setup, pos);
|
| 335 |
+
if (missing.length) {
|
| 336 |
+
return { ok: false, reason: `Invalid castling availability: no matching king and rook for ${missing.join(', ')}.` };
|
| 337 |
+
}
|
| 338 |
+
if (variant !== VARIANTS.CHESS960) {
|
| 339 |
+
const nonOrthodox = _nonOrthodoxCastlingRights(pos);
|
| 340 |
+
if (nonOrthodox.length) {
|
| 341 |
+
return {
|
| 342 |
+
ok: false,
|
| 343 |
+
reason: `Invalid castling availability: Standard chess requires the king and rook on their home squares (${nonOrthodox.join(', ')} is not one).`,
|
| 344 |
+
};
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
return { ok: true, pos };
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
function _pieceCodeOf(piece) {
|
| 351 |
+
if (!piece) return null;
|
| 352 |
+
const letter = piece.role === 'knight' ? 'N' : piece.role[0].toUpperCase();
|
| 353 |
+
return (piece.color === 'white' ? 'w' : 'b') + letter;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
// Total legal move count, counting each underpromotion choice as a
|
| 357 |
+
// separate move (matching the count a full legal-move generator would
|
| 358 |
+
// report) rather than one destination square per promoting pawn move.
|
| 359 |
+
// Includes Chess960 castling moves - they're just another destination
|
| 360 |
+
// chessops's own move generation supplies (see chessgroundDests below).
|
| 361 |
+
function _countLegalMoves(pos) {
|
| 362 |
+
const ctx = pos.ctx();
|
| 363 |
+
let count = 0;
|
| 364 |
+
for (const [from, squares] of pos.allDests(ctx)) {
|
| 365 |
+
const promotes = pos.board.pawn.has(from);
|
| 366 |
+
for (const to of squares) {
|
| 367 |
+
const isPromotion = promotes && (to < 8 || to >= 56);
|
| 368 |
+
count += isPromotion ? 4 : 1;
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
return count;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
// Builds chessground's `movable.dests` shape from the active position,
|
| 375 |
+
// using the variant-aware compatibility bridge. Standard mode exposes
|
| 376 |
+
// orthodox king-destination castling only; Chess960 exposes the
|
| 377 |
+
// king-to-rook destination.
|
| 378 |
+
function _legalDestsMap() {
|
| 379 |
+
if (!_position) return new Map();
|
| 380 |
+
const dests = chessgroundDests(_position, { chess960: _variant === VARIANTS.CHESS960 });
|
| 381 |
+
if (_variant === VARIANTS.CHESS960) return dests;
|
| 382 |
+
|
| 383 |
+
const filtered = new Map();
|
| 384 |
+
for (const [from, squares] of dests) {
|
| 385 |
+
const fromSquare = parseSquare(from);
|
| 386 |
+
const allowed = squares.filter(to => {
|
| 387 |
+
const toSquare = parseSquare(to);
|
| 388 |
+
const side = castlingSide(_position, { from: fromSquare, to: toSquare });
|
| 389 |
+
if (!side) return true;
|
| 390 |
+
return toSquare !== _position.castles.rook[_position.turn][side];
|
| 391 |
+
});
|
| 392 |
+
if (allowed.length) filtered.set(from, allowed);
|
| 393 |
+
}
|
| 394 |
+
return filtered;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
// ── Chessground sync ──────────────────────────────────────────────────────
|
| 398 |
+
// The single point where _position's state is pushed into chessground.
|
| 399 |
+
// Called unconditionally after every position-changing action - even a
|
| 400 |
+
// "legal move" one, since chessground's own baseMove() has no concept of
|
| 401 |
+
// en passant, promotion, or Chess960 castling and can leave its internal
|
| 402 |
+
// state briefly chess-wise-incorrect (e.g. not removing the captured
|
| 403 |
+
// pawn on an en passant capture, leaving a pawn on the back rank instead
|
| 404 |
+
// of the promoted piece, or leaving the rook wherever it was dragged
|
| 405 |
+
// from instead of its final castled square) until this call corrects
|
| 406 |
+
// it. Chessground's internal state is not authoritative;
|
| 407 |
+
// this call always overwrites it from _position.
|
| 408 |
+
function _syncChessground() {
|
| 409 |
+
if (!cg || !_position) return;
|
| 410 |
+
cg.set({
|
| 411 |
+
fen: currentFen(),
|
| 412 |
+
turnColor: _position.turn,
|
| 413 |
+
check: _position.isCheck() ? _position.turn : false,
|
| 414 |
+
lastMove: _lastMoveKeys,
|
| 415 |
+
// Locked: restrict dragging/click-move to the side to move's
|
| 416 |
+
// legal destinations. Unlocked: free editing.
|
| 417 |
+
movable: {
|
| 418 |
+
free: !_locked,
|
| 419 |
+
color: _locked ? _position.turn : 'both',
|
| 420 |
+
dests: _locked ? _legalDestsMap() : undefined,
|
| 421 |
+
},
|
| 422 |
+
// Off-board drop-to-delete is disabled while locked, same as the
|
| 423 |
+
// spare trays below.
|
| 424 |
+
draggable: { deleteOnDropOff: !_locked },
|
| 425 |
+
});
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
// ── Promotion ─────────────────────────────────────────────────────────────
|
| 429 |
+
// pieceCode is a two-letter code like 'wP' (colour + uppercase type),
|
| 430 |
+
// matching the shape the rest of this file (and theme.js) already uses.
|
| 431 |
+
function _isPromotionMove(target, pieceCode) {
|
| 432 |
+
if (!pieceCode || pieceCode[1] !== 'P') return false;
|
| 433 |
+
const color = pieceCode[0], rank = target[1];
|
| 434 |
+
return (color === 'w' && rank === '8') || (color === 'b' && rank === '1');
|
| 435 |
+
}
|
| 436 |
+
// Maps the promotion dialog's single-letter piece codes to chessops's
|
| 437 |
+
// full role names.
|
| 438 |
+
const ROLE_OF = { P: 'pawn', N: 'knight', B: 'bishop', R: 'rook', Q: 'queen', K: 'king' };
|
| 439 |
+
|
| 440 |
+
function _showPromotionDialog(source, target, color) {
|
| 441 |
+
_pendingPromotion = { source, target };
|
| 442 |
+
const dialog = document.getElementById('promotion-dialog');
|
| 443 |
+
['q','r','b','n'].forEach(p => {
|
| 444 |
+
const btn = dialog.querySelector(`.promotion-piece-btn[data-piece="${p}"]`);
|
| 445 |
+
const img = btn && btn.querySelector('img');
|
| 446 |
+
if (img) img.src = _pieceTheme(color + p.toUpperCase());
|
| 447 |
+
});
|
| 448 |
+
dialog.classList.add('is-visible');
|
| 449 |
+
dialog.querySelector('.promotion-piece-btn')?.focus();
|
| 450 |
+
}
|
| 451 |
+
function _hidePromotionDialog() {
|
| 452 |
+
document.getElementById('promotion-dialog').classList.remove('is-visible');
|
| 453 |
+
}
|
| 454 |
+
// Cancelling (Escape / backdrop click) needs to resync, since
|
| 455 |
+
// chessground has already visually relocated the pawn to the target
|
| 456 |
+
// square by the time the dialog is showing (its own drag/drop already
|
| 457 |
+
// moved the piece before this handler ever runs). This call reverts
|
| 458 |
+
// that visual relocation by pushing _position's still-unchanged state
|
| 459 |
+
// back into chessground.
|
| 460 |
+
function _cancelPromotion() {
|
| 461 |
+
_hidePromotionDialog();
|
| 462 |
+
_pendingPromotion = null;
|
| 463 |
+
_syncChessground();
|
| 464 |
+
}
|
| 465 |
+
function _onPromotionSelect(pieceLetter) {
|
| 466 |
+
_hidePromotionDialog();
|
| 467 |
+
if (!_pendingPromotion) return;
|
| 468 |
+
const { source, target } = _pendingPromotion;
|
| 469 |
+
_pendingPromotion = null;
|
| 470 |
+
const move = { from: parseSquare(source), to: parseSquare(target), promotion: ROLE_OF[pieceLetter.toUpperCase()] };
|
| 471 |
+
if (!_position.isLegal(move)) { _syncChessground(); return; } // revert the visual pawn-on-back-rank relocation
|
| 472 |
+
const san = makeSan(_position, move);
|
| 473 |
+
_position.play(move);
|
| 474 |
+
_lastMoveKeys = [source, target];
|
| 475 |
+
_addMove(san, currentFen(), source, target);
|
| 476 |
+
_emitHistory();
|
| 477 |
+
_syncChessground();
|
| 478 |
+
// immediate=true: a completed promotion is a single, definite move
|
| 479 |
+
// (see playMove()'s own comment on this parameter), not part of a
|
| 480 |
+
// rapid-fire stream of changes the debounce below it is meant to
|
| 481 |
+
// guard against.
|
| 482 |
+
if (_onPositionChange) _onPositionChange(currentFen(), true);
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
// ── Core sync algorithm ──────────────────────────────────────────────────
|
| 486 |
+
// Chessground fires multiple, differently-scoped callbacks for what a
|
| 487 |
+
// user experiences as a single action (events.move, events.change,
|
| 488 |
+
// movable.events.after, events.dropNewPiece), so rather than track
|
| 489 |
+
// which-callback-handled-what with suppression flags, this handler is
|
| 490 |
+
// idempotent: it compares chessground's current placement against
|
| 491 |
+
// _position's, and no-ops if they already match.
|
| 492 |
+
//
|
| 493 |
+
// events.move is wired as the informative hook (the one that passes
|
| 494 |
+
// origHint/destHint below) rather than movable.events.after, because of
|
| 495 |
+
// chessground's event-firing order for an ordinary relocation:
|
| 496 |
+
// events.move fires first (scheduled from within board.ts's baseMove()),
|
| 497 |
+
// then events.change (also scheduled by baseMove(), immediately after),
|
| 498 |
+
// then movable.events.after (scheduled later by userMove()) - all via
|
| 499 |
+
// setTimeout(fn,1), so same-delay-timer FIFO ordering makes this
|
| 500 |
+
// deterministic. events.change carries no orig/dest, so if it were the
|
| 501 |
+
// informative hook it would always take the generic "board edit" branch
|
| 502 |
+
// below first, leaving movable.events.after's real orig/dest with
|
| 503 |
+
// nothing left to act on once it fires - the idempotency guard would
|
| 504 |
+
// already see the position as synced. events.move fires before
|
| 505 |
+
// events.change and, unlike movable.events.after, carries orig/dest
|
| 506 |
+
// (plus capture info), so it reliably wins the idempotency race with
|
| 507 |
+
// enough information to classify the move correctly.
|
| 508 |
+
// events.change and movable.events.after are wired below as idempotent
|
| 509 |
+
// fallbacks/no-ops for a relocation; events.change is load-bearing for
|
| 510 |
+
// the one case with no orig/dest-carrying hook at all - an off-board
|
| 511 |
+
// deletion (drag.ts's deleteOnDropOff branch).
|
| 512 |
+
//
|
| 513 |
+
// Standard mode accepts only the orthodox king-destination castle;
|
| 514 |
+
// Chess960 accepts the king-to-rook representation.
|
| 515 |
+
function _handleBoardMutation(origHint, destHint) {
|
| 516 |
+
if (!cg || !_position) return;
|
| 517 |
+
const cgPlacement = cg.getFen(); // chessground's own current placement-only FEN
|
| 518 |
+
const ourPlacement = currentFen().split(' ')[0];
|
| 519 |
+
if (cgPlacement === ourPlacement) return; // already synced by another callback this tick
|
| 520 |
+
|
| 521 |
+
if (origHint && destHint) {
|
| 522 |
+
// _position hasn't moved yet at this point - only chessground
|
| 523 |
+
// has - so the piece that was at origHint is still readable
|
| 524 |
+
// from _position.
|
| 525 |
+
const pieceCode = _pieceCodeOf(_position.board.get(parseSquare(origHint)));
|
| 526 |
+
if (pieceCode && _isPromotionMove(destHint, pieceCode)) {
|
| 527 |
+
_showPromotionDialog(origHint, destHint, pieceCode[0]);
|
| 528 |
+
return; // resolved later by _onPromotionSelect()/_cancelPromotion()
|
| 529 |
+
}
|
| 530 |
+
const move = { from: parseSquare(origHint), to: parseSquare(destHint) };
|
| 531 |
+
if (_variant === VARIANTS.STANDARD) {
|
| 532 |
+
const side = castlingSide(_position, move);
|
| 533 |
+
if (side && move.to === _position.castles.rook[_position.turn][side]) {
|
| 534 |
+
_syncChessground();
|
| 535 |
+
return;
|
| 536 |
+
}
|
| 537 |
+
}
|
| 538 |
+
if (_position.isLegal(move)) {
|
| 539 |
+
const san = makeSan(_position, move);
|
| 540 |
+
_position.play(move);
|
| 541 |
+
_lastMoveKeys = [origHint, destHint];
|
| 542 |
+
_addMove(san, currentFen(), origHint, destHint);
|
| 543 |
+
_emitHistory();
|
| 544 |
+
_syncChessground();
|
| 545 |
+
// immediate=true: see the comment on the equivalent call in
|
| 546 |
+
// _onPromotionSelect() above - a completed drag-and-drop
|
| 547 |
+
// move is a single, definite move too.
|
| 548 |
+
if (_onPositionChange) _onPositionChange(currentFen(), true);
|
| 549 |
+
return;
|
| 550 |
+
}
|
| 551 |
+
// Not legal - fall through to the generic edit path below, which
|
| 552 |
+
// reads the placement chessground already committed rather than
|
| 553 |
+
// origHint/destHint.
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
// Board-edit path: spare-piece drop, off-board delete, or an
|
| 557 |
+
// illegal relocation. Castling rights and the en passant square are
|
| 558 |
+
// always dropped here because an edited placement's rights can no
|
| 559 |
+
// longer be assumed valid.
|
| 560 |
+
const prevFen = currentFen();
|
| 561 |
+
const nextFen = `${cgPlacement} ${_turnChar(_position)} - - 0 1`;
|
| 562 |
+
const built = _buildPosition(nextFen);
|
| 563 |
+
if (!built.ok) { _syncChessground(); return; } // invalid resulting position - snap back
|
| 564 |
+
_position = built.pos;
|
| 565 |
+
_pushHistory(prevFen);
|
| 566 |
+
_lastMoveKeys = undefined;
|
| 567 |
+
_resetMoveLine();
|
| 568 |
+
_syncChessground();
|
| 569 |
+
if (_onPositionChange) _onPositionChange(currentFen());
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
function _onCgSelect(key) {
|
| 573 |
+
if (!_clickPlacePiece) return; // not in placement mode - let chessground handle the click normally
|
| 574 |
+
cg.selectSquare(null); // clear any selection chessground itself may have started,
|
| 575 |
+
// so a piece "armed" for placement never gets relocated
|
| 576 |
+
// by chessground's own click-to-move instead
|
| 577 |
+
const role = ROLE_OF[_clickPlacePiece[1]];
|
| 578 |
+
const color = _clickPlacePiece[0] === 'w' ? 'white' : 'black';
|
| 579 |
+
const square = parseSquare(key);
|
| 580 |
+
if (role === 'king') {
|
| 581 |
+
const existing = _position.board.kingOf(color);
|
| 582 |
+
if (existing !== undefined && existing !== square) return; // rejected - a king of this colour already exists elsewhere
|
| 583 |
+
}
|
| 584 |
+
const prevFen = currentFen();
|
| 585 |
+
// Trial-mutate a clone rather than _position directly, so an
|
| 586 |
+
// illegal result (e.g. two kings adjacent, or leaving the side not
|
| 587 |
+
// to move in check) can be refused before anything commits - the
|
| 588 |
+
// same validate-then-commit gate _handleBoardMutation's edit path
|
| 589 |
+
// below already runs every other board edit through.
|
| 590 |
+
const trial = _position.clone();
|
| 591 |
+
trial.board.set(square, { role, color, promoted: false });
|
| 592 |
+
// Re-derive castling rights from the edited board rather than
|
| 593 |
+
// leaving stale rook/king square references behind (e.g. a right
|
| 594 |
+
// pointing at a square this edit just overwrote).
|
| 595 |
+
trial.castles = Castles.fromSetup({ board: trial.board, castlingRights: _position.castles.castlingRights });
|
| 596 |
+
const nextFen = makeFen(trial.toSetup());
|
| 597 |
+
if (nextFen === prevFen) return; // identity placement (e.g. re-placing the same piece already there) - nothing to record
|
| 598 |
+
const built = _buildPosition(nextFen);
|
| 599 |
+
if (!built.ok) { setClickPlacePiece(null); return; } // illegal result - refuse, board stays as it was
|
| 600 |
+
_position = built.pos;
|
| 601 |
+
_pushHistory(prevFen);
|
| 602 |
+
_lastMoveKeys = undefined;
|
| 603 |
+
_resetMoveLine();
|
| 604 |
+
_syncChessground();
|
| 605 |
+
if (_onPositionChange) _onPositionChange(currentFen());
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
// ── Best-move arrows (DTZ / DTC / DTM / DTM50) ────────────────────────────
|
| 609 |
+
// Native chessground feature (drawable.autoShapes) - square keys, not
|
| 610 |
+
// pixel coordinates, so chessground handles orientation/resizing itself.
|
| 611 |
+
// bestMoves: { dtz: entry|null, dtc: entry|null, dtm: entry|null,
|
| 612 |
+
// dtm50: entry|null } - the top-ranked move for each metric (any of
|
| 613 |
+
// which may be missing, e.g. for a terminal or not-yet-probed position).
|
| 614 |
+
function drawArrows(bestMoves) {
|
| 615 |
+
if (!cg) return;
|
| 616 |
+
if (!bestMoves) { cg.setAutoShapes([]); return; }
|
| 617 |
+
const shapes = [];
|
| 618 |
+
['dtz', 'dtc', 'dtm', 'dtm50'].forEach(metric => {
|
| 619 |
+
const entry = bestMoves[metric];
|
| 620 |
+
if (!entry) return;
|
| 621 |
+
const move = parseSan(_position, entry.san);
|
| 622 |
+
if (!move) return;
|
| 623 |
+
const side = _variant === VARIANTS.STANDARD ? castlingSide(_position, move) : undefined;
|
| 624 |
+
const dest = side
|
| 625 |
+
? `${side === 'a' ? 'c' : 'g'}${_position.turn === 'white' ? '1' : '8'}`
|
| 626 |
+
: makeSquare(move.to);
|
| 627 |
+
shapes.push({ orig: makeSquare(move.from), dest, brush: metric });
|
| 628 |
+
});
|
| 629 |
+
cg.setAutoShapes(shapes);
|
| 630 |
+
}
|
| 631 |
+
function clearArrows() {
|
| 632 |
+
if (!cg) return;
|
| 633 |
+
cg.setAutoShapes([]);
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
// ��─ Click-to-place + spare-piece trays ───────────────────────────────────
|
| 637 |
+
let _clickPlacePiece = null; // e.g. 'wQ', 'bK', or null
|
| 638 |
+
|
| 639 |
+
function _toCgPiece(code /* e.g. 'wQ' */) {
|
| 640 |
+
return { color: code[0] === 'w' ? 'white' : 'black', role: ROLE_OF[code[1]] };
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
function _populateTrayImages() {
|
| 644 |
+
document.querySelectorAll('.spare-piece').forEach(btn => {
|
| 645 |
+
const code = btn.dataset.piece;
|
| 646 |
+
const img = btn.querySelector('img');
|
| 647 |
+
if (img && code) img.src = _pieceTheme(code);
|
| 648 |
+
});
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
// Two interaction paths, both needed: click-to-place (click a tray
|
| 652 |
+
// piece, then click a destination square) and drag-from-tray (native
|
| 653 |
+
// HTML5 drag onto the board, handled via chessground's dragNewPiece()).
|
| 654 |
+
// The spare-piece trays are expected to support both at once, so
|
| 655 |
+
// neither wiring below can assume the other is absent.
|
| 656 |
+
function _initSpareTrays() {
|
| 657 |
+
document.querySelectorAll('.spare-piece').forEach(btn => {
|
| 658 |
+
const code = btn.dataset.piece;
|
| 659 |
+
|
| 660 |
+
// Path 1: click tray piece, then click a square - handled by
|
| 661 |
+
// this click listener (arms _clickPlacePiece) plus _onCgSelect()
|
| 662 |
+
// (does the actual placement), wired in init()'s Config.events.select.
|
| 663 |
+
// Disabled while locked (tray is also dimmed - see setLocked()).
|
| 664 |
+
btn.addEventListener('click', () => {
|
| 665 |
+
if (_locked) return;
|
| 666 |
+
setClickPlacePiece(_clickPlacePiece === code ? null : code);
|
| 667 |
+
});
|
| 668 |
+
|
| 669 |
+
// Path 2: drag a tray piece directly onto the board. `force:
|
| 670 |
+
// true` is required - cg.dragNewPiece() without it refuses to
|
| 671 |
+
// overwrite an occupied square, and dropping a spare piece onto
|
| 672 |
+
// an occupied square is expected to replace it. Disabled while locked.
|
| 673 |
+
btn.addEventListener('pointerdown', e => {
|
| 674 |
+
if (_locked) return;
|
| 675 |
+
if (e.button !== undefined && e.button !== 0) return; // primary pointer only
|
| 676 |
+
cg.dragNewPiece(_toCgPiece(code), e, /* force */ true);
|
| 677 |
+
});
|
| 678 |
+
});
|
| 679 |
+
|
| 680 |
+
// Escape clears click-to-place selection
|
| 681 |
+
document.addEventListener('keydown', e => {
|
| 682 |
+
if (e.key === 'Escape' && _clickPlacePiece) setClickPlacePiece(null);
|
| 683 |
+
});
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
function setClickPlacePiece(piece) {
|
| 687 |
+
_clickPlacePiece = piece;
|
| 688 |
+
// Visual feedback
|
| 689 |
+
document.querySelectorAll('.spare-piece.cp-selected').forEach(el => el.classList.remove('cp-selected'));
|
| 690 |
+
if (piece) {
|
| 691 |
+
document.querySelectorAll(`.spare-piece[data-piece="${piece}"]`).forEach(el => el.classList.add('cp-selected'));
|
| 692 |
+
}
|
| 693 |
+
// Update cursor on board
|
| 694 |
+
const boardEl = document.getElementById('board');
|
| 695 |
+
if (boardEl) {
|
| 696 |
+
boardEl.classList.toggle('cp-active', !!piece);
|
| 697 |
+
}
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
// ── Public API ────────────────────────────────────────────────────────────
|
| 701 |
+
|
| 702 |
+
function _restoredOrientation() {
|
| 703 |
+
return Storage.read('chesstb_orientation') === 'black' ? 'black' : 'white';
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
function _restoredLocked() {
|
| 707 |
+
return Storage.read('chesstb_locked') === 'true';
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
// variant, if given, must be VARIANTS.STANDARD or VARIANTS.CHESS960 and
|
| 711 |
+
// takes precedence over any caller-independent default - callers that
|
| 712 |
+
// already know the intended variant (a URL share-link, a restored saved
|
| 713 |
+
// game) pass it explicitly so the position below is constructed under
|
| 714 |
+
// the correct ruleset context from the very first render, per this
|
| 715 |
+
// file's variant-before-position invariant. Omitted or invalid values
|
| 716 |
+
// fall back to Standard.
|
| 717 |
+
function init(onPositionChange, initialFen, variant) {
|
| 718 |
+
_onPositionChange = onPositionChange;
|
| 719 |
+
_variant = (variant === VARIANTS.CHESS960) ? VARIANTS.CHESS960 : VARIANTS.STANDARD;
|
| 720 |
+
|
| 721 |
+
// Same validation setPosition() applies to every later position
|
| 722 |
+
// change - an invalid initialFen (bad king count, unusable castling
|
| 723 |
+
// rights, or anything else chessops rejects) falls back to the
|
| 724 |
+
// default reset position, and the reason is returned below so the
|
| 725 |
+
// caller can tell the user, instead of silently substituting a
|
| 726 |
+
// different position with no feedback.
|
| 727 |
+
const built = _buildPosition(initialFen);
|
| 728 |
+
let loadError = null;
|
| 729 |
+
if (built.ok) {
|
| 730 |
+
_position = built.pos;
|
| 731 |
+
} else {
|
| 732 |
+
loadError = built.reason;
|
| 733 |
+
_position = Chess.default();
|
| 734 |
+
}
|
| 735 |
+
_startFen = currentFen();
|
| 736 |
+
|
| 737 |
+
// Built from _startFen (the validated/fallback position), not the
|
| 738 |
+
// raw initialFen - otherwise an invalid initialFen leaves the
|
| 739 |
+
// visible board showing the rejected placement while _position
|
| 740 |
+
// already holds the fallback, a desync baked in right at startup.
|
| 741 |
+
cg = Chessground(document.getElementById('board'), {
|
| 742 |
+
fen: _startFen,
|
| 743 |
+
orientation: _restoredOrientation(),
|
| 744 |
+
turnColor: _position.turn,
|
| 745 |
+
check: _position.isCheck() ? _position.turn : false,
|
| 746 |
+
lastMove: undefined,
|
| 747 |
+
coordinates: true,
|
| 748 |
+
// coordinatesOnSquares stays false: that mode prints a combined
|
| 749 |
+
// "a8"-style label on all 64 squares, whereas this app wants the
|
| 750 |
+
// classic look - a rank number down the left column and a file
|
| 751 |
+
// letter along the bottom row only, both sitting inset at the
|
| 752 |
+
// board's edges. Chessground's native coords.ranks/coords.files
|
| 753 |
+
// strips (one rank-only label per row, one file-only label per
|
| 754 |
+
// column) already produce that layout once repositioned via CSS
|
| 755 |
+
// (see chess/board.css) to sit inside the board edge instead of outside
|
| 756 |
+
// it, so the combined per-square mode isn't needed here.
|
| 757 |
+
coordinatesOnSquares: false,
|
| 758 |
+
// Castling is driven by the chessops position (_position), not
|
| 759 |
+
// chessground's own king-two-squares heuristic, so chessground
|
| 760 |
+
// should never second-guess or auto-correct a king move into a
|
| 761 |
+
// castle itself - this also holds for Chess960's king-to-rook
|
| 762 |
+
// representation, which chessground has no native concept of.
|
| 763 |
+
autoCastle: false,
|
| 764 |
+
viewOnly: false,
|
| 765 |
+
disableContextMenu: true,
|
| 766 |
+
highlight: { lastMove: true, check: true },
|
| 767 |
+
animation: { enabled: true, duration: 200 },
|
| 768 |
+
movable: {
|
| 769 |
+
// Any drag is provisionally accepted here and classified
|
| 770 |
+
// afterward by _handleBoardMutation, rather than gating
|
| 771 |
+
// drags up front with a separate edit-mode toggle.
|
| 772 |
+
free: true,
|
| 773 |
+
color: 'both',
|
| 774 |
+
showDests: true,
|
| 775 |
+
events: { after: (orig, dest) => _handleBoardMutation(orig, dest) },
|
| 776 |
+
},
|
| 777 |
+
premovable: { enabled: false },
|
| 778 |
+
draggable: {
|
| 779 |
+
enabled: true,
|
| 780 |
+
showGhost: true,
|
| 781 |
+
deleteOnDropOff: true,
|
| 782 |
+
},
|
| 783 |
+
selectable: { enabled: true },
|
| 784 |
+
events: {
|
| 785 |
+
move: (orig, dest) => _handleBoardMutation(orig, dest),
|
| 786 |
+
change: () => _handleBoardMutation(),
|
| 787 |
+
dropNewPiece: () => _handleBoardMutation(),
|
| 788 |
+
select: (key) => _onCgSelect(key),
|
| 789 |
+
},
|
| 790 |
+
drawable: {
|
| 791 |
+
enabled: true,
|
| 792 |
+
visible: true,
|
| 793 |
+
brushes: {
|
| 794 |
+
dtz: { key: 'dtz', color: '#2451c4', opacity: 0.80, lineWidth: 10 },
|
| 795 |
+
dtc: { key: 'dtc', color: '#d113c6', opacity: 0.80, lineWidth: 10 },
|
| 796 |
+
dtm: { key: 'dtm', color: '#8b93a1', opacity: 0.80, lineWidth: 10 },
|
| 797 |
+
dtm50: { key: 'dtm50', color: '#e1453a', opacity: 0.80, lineWidth: 10 },
|
| 798 |
+
},
|
| 799 |
+
},
|
| 800 |
+
});
|
| 801 |
+
|
| 802 |
+
const dialog = document.getElementById('promotion-dialog');
|
| 803 |
+
dialog.querySelectorAll('.promotion-piece-btn').forEach(btn => {
|
| 804 |
+
btn.addEventListener('click', () => _onPromotionSelect(btn.dataset.piece));
|
| 805 |
+
});
|
| 806 |
+
dialog.addEventListener('click', e => {
|
| 807 |
+
if (e.target === dialog) _cancelPromotion();
|
| 808 |
+
});
|
| 809 |
+
document.addEventListener('keydown', e => {
|
| 810 |
+
if (e.key !== 'Escape' || !dialog.classList.contains('is-visible')) return;
|
| 811 |
+
_cancelPromotion();
|
| 812 |
+
// The promotion dialog sits on top of everything else - stop
|
| 813 |
+
// this Escape from also reaching the PGN dialog/settings-panel
|
| 814 |
+
// handlers (see PgnDialog.jsx, SettingsPanel.jsx), which would otherwise close those too
|
| 815 |
+
// if either happened to be open underneath it.
|
| 816 |
+
e.stopImmediatePropagation();
|
| 817 |
+
});
|
| 818 |
+
|
| 819 |
+
_populateTrayImages();
|
| 820 |
+
_initSpareTrays();
|
| 821 |
+
_syncTrayOrientation();
|
| 822 |
+
|
| 823 |
+
// Restored the same way orientation is above - persist:false since
|
| 824 |
+
// this is a stored preference being applied on load, not a new one
|
| 825 |
+
// being set, so it's not written straight back to the same key.
|
| 826 |
+
setLocked(_restoredLocked(), false);
|
| 827 |
+
|
| 828 |
+
return loadError ? { ok: false, reason: loadError } : { ok: true };
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
/**
|
| 832 |
+
* Set the piece-set CSS <link> and populate the spare-tray and
|
| 833 |
+
* promotion-dialog images. Registered with theme.js via
|
| 834 |
+
* Theme.init(reconstruct) and invoked once at bootstrap, after theme.js
|
| 835 |
+
* injects the board's theme <style> tag.
|
| 836 |
+
*
|
| 837 |
+
* This never touches the Chessground instance itself - piece images are
|
| 838 |
+
* plain CSS (`.cg-wrap piece.<role>.<color>`), not baked into a
|
| 839 |
+
* constructor closure.
|
| 840 |
+
*/
|
| 841 |
+
function reconstruct() {
|
| 842 |
+
if (!cg || !_position) return;
|
| 843 |
+
const link = document.getElementById('chesstb-piece-css');
|
| 844 |
+
if (link) link.href = `/static/css/pieces-${Theme.currentPieceSet()}.css`;
|
| 845 |
+
_populateTrayImages();
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
function setPosition(fen) {
|
| 849 |
+
const prevFen = _position ? currentFen() : null;
|
| 850 |
+
// Re-submitting the FEN already on the board (e.g. pressing Enter in
|
| 851 |
+
// the FEN box without editing it) is a no-op - skip the undo-stack
|
| 852 |
+
// push and tree reset so the current move line survives it.
|
| 853 |
+
if (fen === prevFen) return { ok: true };
|
| 854 |
+
const built = _buildPosition(fen);
|
| 855 |
+
if (!built.ok) return { ok: false, reason: built.reason };
|
| 856 |
+
_position = built.pos;
|
| 857 |
+
if (prevFen !== null) _pushHistory(prevFen);
|
| 858 |
+
_hidePromotionDialog();
|
| 859 |
+
_pendingPromotion = null;
|
| 860 |
+
_lastMoveKeys = undefined;
|
| 861 |
+
_resetMoveLine();
|
| 862 |
+
_syncChessground();
|
| 863 |
+
// Position-change callback, as in playMove()/goBack()/goForward()/
|
| 864 |
+
// goToNode()/clear().
|
| 865 |
+
const nextFen = currentFen();
|
| 866 |
+
if (_onPositionChange) _onPositionChange(nextFen);
|
| 867 |
+
return { ok: true };
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
// Sets a new FEN and variant together in a single step - for a caller
|
| 871 |
+
// that is replacing the position anyway (a shared URL, PGN import,
|
| 872 |
+
// restoreTree()'s fallback path), so there is no "current position"
|
| 873 |
+
// to preserve or validate against the new variant; the new FEN is
|
| 874 |
+
// validated directly against the new variant instead (see
|
| 875 |
+
// _buildPosition). Use setVariant() instead when the FEN is meant to
|
| 876 |
+
// stay exactly as it is and only its interpretation should change
|
| 877 |
+
// (e.g. the settings-panel toggle).
|
| 878 |
+
function setPositionAndVariant(fen, variant) {
|
| 879 |
+
const next = (variant === VARIANTS.CHESS960) ? VARIANTS.CHESS960 : VARIANTS.STANDARD;
|
| 880 |
+
const prevFen = _position ? currentFen() : null;
|
| 881 |
+
if (fen === prevFen && next === _variant) return { ok: true }; // true no-op
|
| 882 |
+
const built = _buildPosition(fen, next);
|
| 883 |
+
if (!built.ok) return { ok: false, reason: built.reason };
|
| 884 |
+
_variant = next;
|
| 885 |
+
_position = built.pos;
|
| 886 |
+
if (prevFen !== null) _pushHistory(prevFen);
|
| 887 |
+
_hidePromotionDialog();
|
| 888 |
+
_pendingPromotion = null;
|
| 889 |
+
_lastMoveKeys = undefined;
|
| 890 |
+
_resetMoveLine();
|
| 891 |
+
_syncChessground();
|
| 892 |
+
if (_onPositionChange) _onPositionChange(currentFen());
|
| 893 |
+
return { ok: true };
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
function clear() {
|
| 897 |
+
const prevFen = currentFen();
|
| 898 |
+
const resetFen = '4k3/8/8/8/8/8/8/4K3 w - - 0 1';
|
| 899 |
+
const built = _buildPosition(resetFen);
|
| 900 |
+
console.assert(built.ok, 'clear(): resetFen must always be valid');
|
| 901 |
+
_pushHistory(prevFen);
|
| 902 |
+
_position = built.pos;
|
| 903 |
+
_hidePromotionDialog();
|
| 904 |
+
_pendingPromotion = null;
|
| 905 |
+
_lastMoveKeys = undefined;
|
| 906 |
+
_resetMoveLine();
|
| 907 |
+
_syncChessground();
|
| 908 |
+
if (_onPositionChange) _onPositionChange(resetFen);
|
| 909 |
+
return resetFen;
|
| 910 |
+
}
|
| 911 |
+
|
| 912 |
+
// Mirrors chessground's own orientation as a class on #board-wrap-inner;
|
| 913 |
+
// chess/board.css uses it to flip flex-direction so the spare-piece trays swap
|
| 914 |
+
// sides visually without moving them in the DOM.
|
| 915 |
+
function _syncTrayOrientation() {
|
| 916 |
+
const wrapEl = document.getElementById('board-wrap-inner');
|
| 917 |
+
if (wrapEl) wrapEl.classList.toggle('orientation-black', cg.state.orientation === 'black');
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
function flip() {
|
| 921 |
+
cg.toggleOrientation();
|
| 922 |
+
_syncTrayOrientation();
|
| 923 |
+
Storage.write('chesstb_orientation', cg.state.orientation);
|
| 924 |
+
clearArrows(); // arrows are orientation-dependent; caller (useExplorer.js) redraws right after flip
|
| 925 |
+
}
|
| 926 |
+
|
| 927 |
+
// ── Lock mode (public) ───────────────────────────────────────────────────
|
| 928 |
+
// Toggles legal-moves-only board interaction. Persisted to localStorage
|
| 929 |
+
// the same way orientation is (see flip()) when persist defaults to
|
| 930 |
+
// true; callers applying an already-known value (init() restoring on
|
| 931 |
+
// load, or useExplorer.js's auto-play engaging/releasing Lock) pass persist:false.
|
| 932 |
+
function setLocked(locked, persist = true) {
|
| 933 |
+
_locked = !!locked;
|
| 934 |
+
if (_locked) setClickPlacePiece(null); // drop any armed spare piece
|
| 935 |
+
document.querySelectorAll('.spare-tray').forEach(el => {
|
| 936 |
+
el.classList.toggle('is-locked', _locked);
|
| 937 |
+
if (_locked) el.setAttribute('title', 'Board is locked');
|
| 938 |
+
else el.removeAttribute('title');
|
| 939 |
+
});
|
| 940 |
+
if (persist) Storage.write('chesstb_locked', _locked);
|
| 941 |
+
_syncChessground();
|
| 942 |
+
}
|
| 943 |
+
function isLocked() { return _locked; }
|
| 944 |
+
|
| 945 |
+
// ── Variant (public) ─────────────────────────��───────────────────────────
|
| 946 |
+
// Persistence and toggle-panel wiring live in useExplorer.js and
|
| 947 |
+
// SettingsPanel.jsx (Chess960 Mode is a Settings-panel item, alongside
|
| 948 |
+
// Autoplay Delay and Show Root Row) - this module only holds the
|
| 949 |
+
// current value and reacts to it changing.
|
| 950 |
+
function setVariant(variant) {
|
| 951 |
+
const next = (variant === VARIANTS.CHESS960) ? VARIANTS.CHESS960 : VARIANTS.STANDARD;
|
| 952 |
+
if (next === _variant) return { ok: true };
|
| 953 |
+
// This reinterprets the current position under a different
|
| 954 |
+
// variant - the FEN itself is not changing (see
|
| 955 |
+
// setPositionAndVariant() above for the "also loading a new
|
| 956 |
+
// position" case) - so unlike every other _buildPosition() call in
|
| 957 |
+
// this file, failure here must not fall back to a default
|
| 958 |
+
// position or otherwise change what's on the board: the position
|
| 959 |
+
// simply isn't representable under the requested variant's
|
| 960 |
+
// castling rules (e.g. Chess960-only castling rights present, or
|
| 961 |
+
// Chess960 castling rights on non-home squares - see
|
| 962 |
+
// _nonOrthodoxCastlingRights), and the switch is refused outright,
|
| 963 |
+
// leaving variant and position exactly as they were.
|
| 964 |
+
if (_position) {
|
| 965 |
+
const trial = _buildPosition(currentFen(), next);
|
| 966 |
+
if (!trial.ok) {
|
| 967 |
+
const modeName = next === VARIANTS.CHESS960 ? 'Chess960' : 'Standard';
|
| 968 |
+
return { ok: false, reason: `Can't switch to ${modeName} mode: ${trial.reason}` };
|
| 969 |
+
}
|
| 970 |
+
}
|
| 971 |
+
_variant = next;
|
| 972 |
+
// The move tree belongs to the active variant; changing variants
|
| 973 |
+
// starts a fresh tree from the current position.
|
| 974 |
+
_lastMoveKeys = undefined;
|
| 975 |
+
_resetMoveLine();
|
| 976 |
+
_syncChessground();
|
| 977 |
+
// Position change with no actual FEN change - still routed through
|
| 978 |
+
// onPositionChange so callers re-probe under the new variant and
|
| 979 |
+
// (via onPositionChange's own guard) auto-play is stopped rather
|
| 980 |
+
// than left running against a variant it started under.
|
| 981 |
+
if (_onPositionChange) _onPositionChange(currentFen());
|
| 982 |
+
return { ok: true };
|
| 983 |
+
}
|
| 984 |
+
function getVariant() { return _variant; }
|
| 985 |
+
function isChess960() { return _variant === VARIANTS.CHESS960; }
|
| 986 |
+
|
| 987 |
+
// Legal move count for the current position, counting each
|
| 988 |
+
// underpromotion choice separately (see _countLegalMoves above) and
|
| 989 |
+
// including Chess960 castling.
|
| 990 |
+
function legalMoveCount() { return _position ? _countLegalMoves(_position) : 0; }
|
| 991 |
+
function isInsufficientMaterial() { return !!_position && _position.isInsufficientMaterial(); }
|
| 992 |
+
|
| 993 |
+
// Result token for an arbitrary FEN, independent of the live board
|
| 994 |
+
// state - used by pgnFormat.js's PGN-result detection. Returns '*'
|
| 995 |
+
// (unknown/in progress) for a FEN that fails to parse.
|
| 996 |
+
function outcomeFromFen(fen) {
|
| 997 |
+
const built = _buildPosition(fen);
|
| 998 |
+
if (!built.ok) return '*';
|
| 999 |
+
const pos = built.pos;
|
| 1000 |
+
if (pos.isCheckmate()) return pos.turn === 'white' ? '0-1' : '1-0';
|
| 1001 |
+
if (pos.isStalemate() || pos.isInsufficientMaterial()) return '1/2-1/2';
|
| 1002 |
+
return '*';
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
function playMove(san) {
|
| 1006 |
+
if (!_position) return false;
|
| 1007 |
+
const move = parseSan(_position, san);
|
| 1008 |
+
if (!move) return false;
|
| 1009 |
+
const fromKey = makeSquare(move.from), toKey = makeSquare(move.to);
|
| 1010 |
+
const playedSan = makeSan(_position, move);
|
| 1011 |
+
_position.play(move);
|
| 1012 |
+
|
| 1013 |
+
_lastMoveKeys = [fromKey, toKey];
|
| 1014 |
+
|
| 1015 |
+
// Track in the move tree (branches instead of overwriting if the
|
| 1016 |
+
// user had navigated back before playing this move).
|
| 1017 |
+
_addMove(playedSan, currentFen(), fromKey, toKey);
|
| 1018 |
+
_emitHistory();
|
| 1019 |
+
_syncChessground();
|
| 1020 |
+
|
| 1021 |
+
// immediate=true: this is a single, definite move (played by
|
| 1022 |
+
// auto-play or by clicking a move in the ranked-moves table, the
|
| 1023 |
+
// two callers of playMove() - see useExplorer.js), not part of a
|
| 1024 |
+
// rapid-fire stream of changes the 300ms debounce guards against.
|
| 1025 |
+
if (_onPositionChange) _onPositionChange(currentFen(), true);
|
| 1026 |
+
return true;
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
// Back/Forward walk the move tree directly (parent / activeChild) so
|
| 1030 |
+
// stepping through a line that includes variations works the same as
|
| 1031 |
+
// stepping through the main line. The plain _history/_future FEN stack
|
| 1032 |
+
// is only consulted once navigation reaches the tree's root (id ROOT)
|
| 1033 |
+
// and goes further back than the tree itself - i.e. undoing past
|
| 1034 |
+
// whatever move/edit started the current tree (see _resetMoveLine).
|
| 1035 |
+
function goBack() {
|
| 1036 |
+
if (_currentId !== ROOT) {
|
| 1037 |
+
const parentId = _nodes[_currentId].parentId;
|
| 1038 |
+
const fen = parentId === ROOT ? _startFen : _nodes[parentId].fen;
|
| 1039 |
+
const built = _buildPosition(fen);
|
| 1040 |
+
if (!built.ok) { _syncChessground(); return false; }
|
| 1041 |
+
_position = built.pos;
|
| 1042 |
+
_lastMoveKeys = parentId === ROOT ? undefined : [_nodes[parentId].from, _nodes[parentId].to];
|
| 1043 |
+
_currentId = parentId;
|
| 1044 |
+
} else {
|
| 1045 |
+
if (_history.length === 0) return false;
|
| 1046 |
+
const prevFen = _history.pop();
|
| 1047 |
+
_future.push(currentFen());
|
| 1048 |
+
const built = _buildPosition(prevFen);
|
| 1049 |
+
if (!built.ok) {
|
| 1050 |
+
_future.pop();
|
| 1051 |
+
_history.push(prevFen);
|
| 1052 |
+
_syncChessground();
|
| 1053 |
+
return false;
|
| 1054 |
+
}
|
| 1055 |
+
_position = built.pos;
|
| 1056 |
+
// The plain _history/_future stack holds FENs only, with no
|
| 1057 |
+
// accompanying from/to squares (unlike tree nodes), so there's
|
| 1058 |
+
// nothing to highlight as the last move here.
|
| 1059 |
+
_lastMoveKeys = undefined;
|
| 1060 |
+
// The loaded position does not match the tree's _startFen, so
|
| 1061 |
+
// the tree is reset. _resetMoveLine() also clears _future, so the
|
| 1062 |
+
// stack is saved and restored around the reset.
|
| 1063 |
+
const _pendingFuture = _future.slice();
|
| 1064 |
+
_resetMoveLine();
|
| 1065 |
+
_future = _pendingFuture;
|
| 1066 |
+
}
|
| 1067 |
+
_hidePromotionDialog();
|
| 1068 |
+
_pendingPromotion = null;
|
| 1069 |
+
_syncChessground();
|
| 1070 |
+
_emitHistory();
|
| 1071 |
+
if (_onPositionChange) _onPositionChange(currentFen());
|
| 1072 |
+
return true;
|
| 1073 |
+
}
|
| 1074 |
+
|
| 1075 |
+
function goForward() {
|
| 1076 |
+
const activeChildId = _activeChildOf(_currentId);
|
| 1077 |
+
if (activeChildId !== null && activeChildId !== undefined) {
|
| 1078 |
+
const node = _nodes[activeChildId];
|
| 1079 |
+
const built = _buildPosition(node.fen);
|
| 1080 |
+
if (!built.ok) { _syncChessground(); return false; }
|
| 1081 |
+
_position = built.pos;
|
| 1082 |
+
_lastMoveKeys = [node.from, node.to];
|
| 1083 |
+
_currentId = activeChildId;
|
| 1084 |
+
} else if (_currentId === ROOT && _future.length > 0) {
|
| 1085 |
+
const nextFen = _future.pop();
|
| 1086 |
+
const built = _buildPosition(nextFen);
|
| 1087 |
+
if (!built.ok) {
|
| 1088 |
+
_future.push(nextFen);
|
| 1089 |
+
_syncChessground();
|
| 1090 |
+
return false;
|
| 1091 |
+
}
|
| 1092 |
+
_history.push(currentFen());
|
| 1093 |
+
_position = built.pos;
|
| 1094 |
+
_lastMoveKeys = undefined;
|
| 1095 |
+
// Mirrors the goBack() fallback above: the position just loaded
|
| 1096 |
+
// isn't described by the current (already-reset) tree either, so
|
| 1097 |
+
// restart the tree here too and preserve any remaining redo
|
| 1098 |
+
// entries across the reset.
|
| 1099 |
+
const _pendingFuture = _future.slice();
|
| 1100 |
+
_resetMoveLine();
|
| 1101 |
+
_future = _pendingFuture;
|
| 1102 |
+
} else {
|
| 1103 |
+
return false;
|
| 1104 |
+
}
|
| 1105 |
+
_hidePromotionDialog();
|
| 1106 |
+
_pendingPromotion = null;
|
| 1107 |
+
_syncChessground();
|
| 1108 |
+
_emitHistory();
|
| 1109 |
+
if (_onPositionChange) _onPositionChange(currentFen());
|
| 1110 |
+
return true;
|
| 1111 |
+
}
|
| 1112 |
+
|
| 1113 |
+
// goToNode() jumps to an arbitrary node in the current move tree (used
|
| 1114 |
+
// by the PGN panel - including variation moves, not just the main line).
|
| 1115 |
+
// Unlike setPosition(), this never calls _resetMoveLine(). targetId ===
|
| 1116 |
+
// ROOT (-1) means "start of the tree" (the position before any move was
|
| 1117 |
+
// played). Clicking any node marks it - and every ancestor on the path
|
| 1118 |
+
// to it - as its parent's activeChild, so Forward continues along
|
| 1119 |
+
// whichever branch was just brought into view.
|
| 1120 |
+
function goToNode(targetId) {
|
| 1121 |
+
if (targetId !== ROOT && !_nodes[targetId]) return false;
|
| 1122 |
+
if (targetId === _currentId) return true;
|
| 1123 |
+
|
| 1124 |
+
// Walked before touching _position/_syncChessground, and bounded to
|
| 1125 |
+
// at most one hop per node, so a corrupt parentId chain (see
|
| 1126 |
+
// _isValidTree) refuses the jump instead of looping forever.
|
| 1127 |
+
const chain = [];
|
| 1128 |
+
let walk = targetId;
|
| 1129 |
+
let guard = _nodes.length + 1;
|
| 1130 |
+
while (walk !== ROOT) {
|
| 1131 |
+
if (guard-- <= 0 || !_nodes[walk]) return false;
|
| 1132 |
+
chain.unshift(walk);
|
| 1133 |
+
walk = _nodes[walk].parentId;
|
| 1134 |
+
}
|
| 1135 |
+
|
| 1136 |
+
const fen = targetId === ROOT ? _startFen : _nodes[targetId].fen;
|
| 1137 |
+
const built = _buildPosition(fen);
|
| 1138 |
+
if (!built.ok) return false;
|
| 1139 |
+
_position = built.pos;
|
| 1140 |
+
|
| 1141 |
+
let parent = ROOT;
|
| 1142 |
+
chain.forEach(id => { _setActiveChild(parent, id); parent = id; });
|
| 1143 |
+
_currentId = targetId;
|
| 1144 |
+
_lastMoveKeys = targetId === ROOT ? undefined : [_nodes[targetId].from, _nodes[targetId].to];
|
| 1145 |
+
|
| 1146 |
+
_hidePromotionDialog();
|
| 1147 |
+
_pendingPromotion = null;
|
| 1148 |
+
_syncChessground();
|
| 1149 |
+
_emitHistory();
|
| 1150 |
+
if (_onPositionChange) _onPositionChange(currentFen());
|
| 1151 |
+
return true;
|
| 1152 |
+
}
|
| 1153 |
+
|
| 1154 |
+
// A legitimately-built tree (see _addMove above) only ever gives a node
|
| 1155 |
+
// a parentId that's ROOT or an already-existing, strictly smaller id,
|
| 1156 |
+
// and only ever lists children/activeChild with a strictly larger one,
|
| 1157 |
+
// so parentId chains and children references can never cycle or dangle.
|
| 1158 |
+
// Persisted state coming back out of localStorage bypasses that
|
| 1159 |
+
// construction path entirely; this re-checks the same invariant before
|
| 1160 |
+
// trusting it, since violating it would let goToNode()'s parentId walk
|
| 1161 |
+
// (or moveLine.js's tree walk) loop forever or index past the array.
|
| 1162 |
+
function _isValidNodeRef(id, nodesLength) {
|
| 1163 |
+
return typeof id === 'number' && Number.isInteger(id) && id >= 0 && id < nodesLength;
|
| 1164 |
+
}
|
| 1165 |
+
function _isValidTree(nodes, rootChildren) {
|
| 1166 |
+
if (!Array.isArray(nodes) || !Array.isArray(rootChildren)) return false;
|
| 1167 |
+
for (let id = 0; id < nodes.length; id++) {
|
| 1168 |
+
const node = nodes[id];
|
| 1169 |
+
if (!node || typeof node.san !== 'string' || typeof node.fen !== 'string') return false;
|
| 1170 |
+
if (node.parentId !== ROOT && !(_isValidNodeRef(node.parentId, nodes.length) && node.parentId < id)) {
|
| 1171 |
+
return false;
|
| 1172 |
+
}
|
| 1173 |
+
if (!Array.isArray(node.children)) return false;
|
| 1174 |
+
for (const childId of node.children) {
|
| 1175 |
+
if (!_isValidNodeRef(childId, nodes.length) || childId <= id) return false;
|
| 1176 |
+
}
|
| 1177 |
+
if (node.activeChild !== null && node.activeChild !== undefined &&
|
| 1178 |
+
!(_isValidNodeRef(node.activeChild, nodes.length) && node.activeChild > id)) {
|
| 1179 |
+
return false;
|
| 1180 |
+
}
|
| 1181 |
+
}
|
| 1182 |
+
return rootChildren.every(childId => _isValidNodeRef(childId, nodes.length));
|
| 1183 |
+
}
|
| 1184 |
+
|
| 1185 |
+
// restoreTree() rehydrates a full move tree (start position + every
|
| 1186 |
+
// node + current node) from persisted state in a single board update,
|
| 1187 |
+
// without replaying each move or triggering individual probes.
|
| 1188 |
+
// The active variant must already be established (via init()'s variant
|
| 1189 |
+
// argument) before this is called - this function only restores
|
| 1190 |
+
// position/tree data, never variant.
|
| 1191 |
+
function restoreTree(startFen, nodes, rootChildren, rootActiveChild, currentId) {
|
| 1192 |
+
if (!_position || !startFen) return false;
|
| 1193 |
+
const validTree = _isValidTree(nodes, rootChildren);
|
| 1194 |
+
const safeNodes = validTree ? nodes : [];
|
| 1195 |
+
const safeRootChildren = validTree ? rootChildren : [];
|
| 1196 |
+
const safeCurrentId = (currentId === ROOT || (validTree && _isValidNodeRef(currentId, safeNodes.length)))
|
| 1197 |
+
? currentId : ROOT;
|
| 1198 |
+
|
| 1199 |
+
const loadFen = safeCurrentId === ROOT ? startFen : safeNodes[safeCurrentId].fen;
|
| 1200 |
+
const built = _buildPosition(loadFen);
|
| 1201 |
+
if (!built.ok) {
|
| 1202 |
+
// Saved state doesn't parse (e.g. corrupted storage) - fall back
|
| 1203 |
+
// to just the start position with an empty tree.
|
| 1204 |
+
const fallback = _buildPosition(startFen);
|
| 1205 |
+
_position = fallback.ok ? fallback.pos : Chess.default();
|
| 1206 |
+
_startFen = currentFen();
|
| 1207 |
+
_nodes = [];
|
| 1208 |
+
_rootChildren = [];
|
| 1209 |
+
_rootActiveChild = null;
|
| 1210 |
+
_currentId = ROOT;
|
| 1211 |
+
_history = [];
|
| 1212 |
+
_future = [];
|
| 1213 |
+
_lastMoveKeys = undefined;
|
| 1214 |
+
if (cg) {
|
| 1215 |
+
_hidePromotionDialog();
|
| 1216 |
+
_syncChessground();
|
| 1217 |
+
}
|
| 1218 |
+
_emitHistory();
|
| 1219 |
+
return false;
|
| 1220 |
+
}
|
| 1221 |
+
_position = built.pos;
|
| 1222 |
+
|
| 1223 |
+
_startFen = startFen;
|
| 1224 |
+
_nodes = safeNodes;
|
| 1225 |
+
_rootChildren = safeRootChildren;
|
| 1226 |
+
_rootActiveChild = _isValidNodeRef(rootActiveChild, safeNodes.length) ? rootActiveChild : null;
|
| 1227 |
+
_currentId = safeCurrentId;
|
| 1228 |
+
_history = [];
|
| 1229 |
+
_future = [];
|
| 1230 |
+
_lastMoveKeys = (safeCurrentId === ROOT || !safeNodes[safeCurrentId])
|
| 1231 |
+
? undefined
|
| 1232 |
+
: [safeNodes[safeCurrentId].from, safeNodes[safeCurrentId].to];
|
| 1233 |
+
|
| 1234 |
+
_hidePromotionDialog();
|
| 1235 |
+
_pendingPromotion = null;
|
| 1236 |
+
if (cg) _syncChessground();
|
| 1237 |
+
_emitHistory();
|
| 1238 |
+
return true;
|
| 1239 |
+
}
|
| 1240 |
+
|
| 1241 |
+
// Install a parsed PGN in one update. Only the displayed start position
|
| 1242 |
+
// is probed; every imported move stays available for navigation/export.
|
| 1243 |
+
function importGame(tree, variant) {
|
| 1244 |
+
const next = variant === VARIANTS.CHESS960 ? VARIANTS.CHESS960 : VARIANTS.STANDARD;
|
| 1245 |
+
const built = _buildPosition(tree.startFen, next);
|
| 1246 |
+
if (!built.ok) return { ok: false, reason: built.reason };
|
| 1247 |
+
if (!_isValidTree(tree.nodes, tree.rootChildren)) {
|
| 1248 |
+
return { ok: false, reason: 'Invalid move tree in PGN.' };
|
| 1249 |
+
}
|
| 1250 |
+
_variant = next;
|
| 1251 |
+
_position = built.pos;
|
| 1252 |
+
restoreTree(tree.startFen, tree.nodes, tree.rootChildren, tree.rootActiveChild, ROOT);
|
| 1253 |
+
if (_onPositionChange) _onPositionChange(currentFen(), true);
|
| 1254 |
+
return { ok: true };
|
| 1255 |
+
}
|
| 1256 |
+
|
| 1257 |
+
function canGoBack() { return _currentId !== ROOT || _history.length > 0; }
|
| 1258 |
+
function canGoForward() {
|
| 1259 |
+
const activeChildId = _activeChildOf(_currentId);
|
| 1260 |
+
return (activeChildId !== null && activeChildId !== undefined) || (_currentId === ROOT && _future.length > 0);
|
| 1261 |
+
}
|
| 1262 |
+
|
| 1263 |
+
function getMoveHistory() {
|
| 1264 |
+
return { nodes: _nodes, rootChildren: _rootChildren, rootActiveChild: _rootActiveChild, currentId: _currentId };
|
| 1265 |
+
}
|
| 1266 |
+
function getStartFen() { return _startFen; } // PGN formatting
|
| 1267 |
+
function setOnMoveHistoryChange(fn) {
|
| 1268 |
+
_onMoveHistoryChange = fn;
|
| 1269 |
+
}
|
| 1270 |
+
|
| 1271 |
+
return {
|
| 1272 |
+
init, reconstruct, setPosition, setPositionAndVariant, clear, flip, currentFen,
|
| 1273 |
+
playMove, goBack, goForward, goToNode, restoreTree, importGame, canGoBack, canGoForward,
|
| 1274 |
+
getMoveHistory, getStartFen, setOnMoveHistoryChange,
|
| 1275 |
+
drawArrows, clearArrows,
|
| 1276 |
+
setLocked, isLocked,
|
| 1277 |
+
setVariant, getVariant, isChess960, legalMoveCount, isInsufficientMaterial, outcomeFromFen,
|
| 1278 |
+
};
|
| 1279 |
+
|
| 1280 |
+
})();
|
| 1281 |
+
|
| 1282 |
+
export { Board, VARIANTS };
|
frontend/src/engine/gameState.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* gameState.js: URL-hash sharing and persisted game state
|
| 3 |
+
*
|
| 4 |
+
* The hash is rewritten on every position change so the address bar stays
|
| 5 |
+
* share-able, and is read back on load (and on hashchange) to restore a
|
| 6 |
+
* shared link. Persisting the full move tree to localStorage additionally
|
| 7 |
+
* lets a round-trip through a different route (e.g. /admin, which has no
|
| 8 |
+
* #fen= of its own) restore the board position and PGN panel together.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { VARIANTS } from './board.js';
|
| 12 |
+
import * as Storage from './storage.js';
|
| 13 |
+
|
| 14 |
+
export function writeHash(fen, variant) {
|
| 15 |
+
try {
|
| 16 |
+
let hash = '#fen=' + encodeURIComponent(fen);
|
| 17 |
+
if (variant === VARIANTS.CHESS960) hash += '&variant=' + VARIANTS.CHESS960;
|
| 18 |
+
history.replaceState(null, '', hash);
|
| 19 |
+
} catch { /* sandboxed context */ }
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function readHashFen() {
|
| 23 |
+
try {
|
| 24 |
+
const m = location.hash.match(/[#&]fen=([^&]+)/);
|
| 25 |
+
return m ? decodeURIComponent(m[1]) : null;
|
| 26 |
+
} catch { return null; }
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// Returns VARIANTS.CHESS960 / VARIANTS.STANDARD, or null (Standard) if the
|
| 30 |
+
// hash has no (or an unrecognized) variant param. Never inferred from the
|
| 31 |
+
// FEN itself.
|
| 32 |
+
export function readHashVariant() {
|
| 33 |
+
try {
|
| 34 |
+
const m = location.hash.match(/[#&]variant=([^&]+)/);
|
| 35 |
+
if (!m) return null;
|
| 36 |
+
const v = decodeURIComponent(m[1]);
|
| 37 |
+
if (v === VARIANTS.CHESS960) return VARIANTS.CHESS960;
|
| 38 |
+
if (v === VARIANTS.STANDARD) return VARIANTS.STANDARD;
|
| 39 |
+
return null;
|
| 40 |
+
} catch { return null; }
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const _LS_GAME = 'chesstb_last_game';
|
| 44 |
+
|
| 45 |
+
export function saveGameState(history, startFen, variant) {
|
| 46 |
+
Storage.writeJson(_LS_GAME, {
|
| 47 |
+
variant,
|
| 48 |
+
startFen,
|
| 49 |
+
nodes: history.nodes,
|
| 50 |
+
rootChildren: history.rootChildren,
|
| 51 |
+
rootActiveChild: history.rootActiveChild,
|
| 52 |
+
currentId: history.currentId,
|
| 53 |
+
});
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export function readLastGame() {
|
| 57 |
+
const parsed = Storage.readJson(_LS_GAME);
|
| 58 |
+
if (!parsed || typeof parsed.startFen !== 'string' || !Array.isArray(parsed.nodes)) {
|
| 59 |
+
return null;
|
| 60 |
+
}
|
| 61 |
+
parsed.variant = parsed.variant === VARIANTS.CHESS960 ? VARIANTS.CHESS960 : VARIANTS.STANDARD;
|
| 62 |
+
const node = parsed.nodes[parsed.currentId];
|
| 63 |
+
parsed.currentFen = (!node || typeof node.fen !== 'string') ? parsed.startFen : node.fen;
|
| 64 |
+
return parsed;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// Client-side mirror of the backend's own FEN padding (app.py's
|
| 68 |
+
// _normalize_fen): pads a partial FEN out to all six fields with the
|
| 69 |
+
// standard defaults.
|
| 70 |
+
export function normalizeFen(s) {
|
| 71 |
+
const trimmed = s.trim();
|
| 72 |
+
const defaults = ['8/8/8/8/8/8/8/8', 'w', '-', '-', '0', '1'];
|
| 73 |
+
const parts = trimmed ? trimmed.split(/\s+/) : [];
|
| 74 |
+
while (parts.length < 6) parts.push(defaults[parts.length]);
|
| 75 |
+
return parts.slice(0, 6).join(' ');
|
| 76 |
+
}
|
frontend/src/engine/moveLine.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* moveLine.js: move-tree → render items
|
| 3 |
+
*
|
| 4 |
+
* Walks the branching move tree the same way the PGN exporter does (see
|
| 5 |
+
* pgnFormat.js's childrenOf/collectMainPath) and produces a flat list of
|
| 6 |
+
* render items for the move-list panel: 'row' items for the main line's
|
| 7 |
+
* Rank/White/Black grid, and 'variation' items for parenthesised
|
| 8 |
+
* alternatives, each carrying enough to expand into its own paragraph
|
| 9 |
+
* without re-walking the tree.
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import { ROOT_ID, childrenOf, collectMainPath } from './pgnFormat.js';
|
| 13 |
+
|
| 14 |
+
// Builds the main line as row items: one full move per row. A move with
|
| 15 |
+
// alternatives gets a 'variation' item right after it instead of packed
|
| 16 |
+
// into its own cell - since a variation spans the full row width, the
|
| 17 |
+
// next row after it always starts fresh.
|
| 18 |
+
export function buildMainLine(nodes, rootChildren, nodeIds, parentId, whiteToMove, moveNum, currentId) {
|
| 19 |
+
const items = [];
|
| 20 |
+
let row = { kind: 'row', num: moveNum, white: whiteToMove ? null : { placeholder: true }, black: null };
|
| 21 |
+
items.push(row);
|
| 22 |
+
|
| 23 |
+
nodeIds.forEach((id, i) => {
|
| 24 |
+
const node = nodes[id];
|
| 25 |
+
const parentForThis = i === 0 ? parentId : nodeIds[i - 1];
|
| 26 |
+
const kids = childrenOf(nodes, rootChildren, parentForThis);
|
| 27 |
+
const hasVariation = kids.length > 1;
|
| 28 |
+
|
| 29 |
+
if (whiteToMove) row.white = { node, isCurrent: node.id === currentId };
|
| 30 |
+
else row.black = { node, isCurrent: node.id === currentId };
|
| 31 |
+
|
| 32 |
+
const wasWhite = whiteToMove;
|
| 33 |
+
const thisMoveNum = moveNum;
|
| 34 |
+
if (!whiteToMove) moveNum++;
|
| 35 |
+
whiteToMove = !whiteToMove;
|
| 36 |
+
|
| 37 |
+
if (hasVariation) {
|
| 38 |
+
for (let v = 1; v < kids.length; v++) {
|
| 39 |
+
const varPath = collectMainPath(nodes, kids[v]);
|
| 40 |
+
items.push({ kind: 'variation', nodeIds: varPath, whiteToMove: wasWhite, moveNum: thisMoveNum, depth: 1 });
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
if (i !== nodeIds.length - 1) {
|
| 45 |
+
if (wasWhite && !hasVariation) {
|
| 46 |
+
// continues into this row's still-empty black cell
|
| 47 |
+
} else {
|
| 48 |
+
row = { kind: 'row', num: moveNum, white: whiteToMove ? null : { placeholder: true }, black: null };
|
| 49 |
+
items.push(row);
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
});
|
| 53 |
+
return items;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// Builds one variation's body as a sequence of paragraphs (chip lists):
|
| 57 |
+
// a move within it that itself has alternatives interrupts the paragraph:
|
| 58 |
+
// the nested variation(s) become their own, more deeply indented items,
|
| 59 |
+
// and this variation's remaining moves continue in a fresh paragraph at
|
| 60 |
+
// the same depth.
|
| 61 |
+
export function buildVariation(nodes, rootChildren, nodeIds, whiteToMove, moveNum, currentId, depth) {
|
| 62 |
+
const items = [];
|
| 63 |
+
let para = { kind: 'para', depth, chips: [] };
|
| 64 |
+
items.push(para);
|
| 65 |
+
|
| 66 |
+
nodeIds.forEach((id, i) => {
|
| 67 |
+
const node = nodes[id];
|
| 68 |
+
// i === 0 is the move that spawned this variation body - its
|
| 69 |
+
// siblings were already enumerated by the caller as separate
|
| 70 |
+
// variation items, so this doesn't re-derive them here.
|
| 71 |
+
const parentForThis = i === 0 ? null : nodeIds[i - 1];
|
| 72 |
+
const kids = parentForThis !== null ? childrenOf(nodes, rootChildren, parentForThis) : null;
|
| 73 |
+
const hasVariation = !!(kids && kids.length > 1);
|
| 74 |
+
|
| 75 |
+
if (whiteToMove || i === 0) {
|
| 76 |
+
para.chips.push({ kind: 'num', text: moveNum + (whiteToMove ? '.' : '…') });
|
| 77 |
+
}
|
| 78 |
+
para.chips.push({ kind: 'move', node, isCurrent: node.id === currentId });
|
| 79 |
+
|
| 80 |
+
const wasWhite = whiteToMove;
|
| 81 |
+
const thisMoveNum = moveNum;
|
| 82 |
+
if (!whiteToMove) moveNum++;
|
| 83 |
+
whiteToMove = !whiteToMove;
|
| 84 |
+
|
| 85 |
+
if (hasVariation) {
|
| 86 |
+
for (let v = 1; v < kids.length; v++) {
|
| 87 |
+
const varPath = collectMainPath(nodes, kids[v]);
|
| 88 |
+
items.push({ kind: 'variation', nodeIds: varPath, whiteToMove: wasWhite, moveNum: thisMoveNum, depth: depth + 1 });
|
| 89 |
+
}
|
| 90 |
+
if (i !== nodeIds.length - 1) {
|
| 91 |
+
para = { kind: 'para', depth, chips: [] };
|
| 92 |
+
items.push(para);
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
});
|
| 96 |
+
return items;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
export function buildMoveListItems(nodes, rootChildren, currentId, turn, fullmove) {
|
| 100 |
+
if (!rootChildren || rootChildren.length === 0) return [];
|
| 101 |
+
const mainPath = collectMainPath(nodes, rootChildren[0]);
|
| 102 |
+
return buildMainLine(nodes, rootChildren, mainPath, ROOT_ID, turn === 'w', fullmove, currentId);
|
| 103 |
+
}
|
frontend/src/engine/pgnFormat.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* pgnFormat.js: move-tree helpers, PGN export text, and PGN import parsing
|
| 3 |
+
*
|
| 4 |
+
* The tree-walking helpers (childrenOf/collectMainPath) are shared by the
|
| 5 |
+
* move-list panel and the PGN exporter so the two can't disagree about
|
| 6 |
+
* where a variation forks.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import { Board, VARIANTS } from './board.js';
|
| 10 |
+
import { Chess } from '../vendor/chessops/chess.js';
|
| 11 |
+
import { parseFen, makeFen } from '../vendor/chessops/fen.js';
|
| 12 |
+
import { parsePgnStrict, PgnError } from '../vendor/chessops/pgn.js';
|
| 13 |
+
import { parseSan, makeSan } from '../vendor/chessops/san.js';
|
| 14 |
+
import { makeSquare } from '../vendor/chessops/util.js';
|
| 15 |
+
|
| 16 |
+
export const ROOT_ID = -1;
|
| 17 |
+
|
| 18 |
+
export function childrenOf(nodes, rootChildren, id) {
|
| 19 |
+
return id === ROOT_ID ? rootChildren : nodes[id].children;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// Follows children[0] repeatedly from startId - one line's worth of node
|
| 23 |
+
// ids (its own "main" continuation).
|
| 24 |
+
export function collectMainPath(nodes, startId) {
|
| 25 |
+
const ids = [];
|
| 26 |
+
let cur = startId;
|
| 27 |
+
while (cur !== null && cur !== undefined) {
|
| 28 |
+
ids.push(cur);
|
| 29 |
+
const kids = nodes[cur].children;
|
| 30 |
+
cur = kids.length ? kids[0] : null;
|
| 31 |
+
}
|
| 32 |
+
return ids;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// Derives {turn, fullmove} from a FEN's 2nd/6th fields, for numbering a
|
| 36 |
+
// move line that doesn't start from a fresh game.
|
| 37 |
+
export function parseFenMeta(fen) {
|
| 38 |
+
const parts = String(fen || '').trim().split(/\s+/);
|
| 39 |
+
const turn = parts[1] === 'b' ? 'b' : 'w';
|
| 40 |
+
const fullmove = parseInt(parts[5], 10);
|
| 41 |
+
return { turn, fullmove: Number.isFinite(fullmove) && fullmove > 0 ? fullmove : 1 };
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Same recursive shape as the move-list panel's own rendering - builds PGN
|
| 45 |
+
// movetext tokens (including variations, in "(...)" notation) instead of
|
| 46 |
+
// DOM/JSX nodes.
|
| 47 |
+
function buildMoveTokens(nodes, rootChildren, nodeIds, parentId, whiteToMove, moveNum, skipFirstFork) {
|
| 48 |
+
const tokens = [];
|
| 49 |
+
nodeIds.forEach((id, i) => {
|
| 50 |
+
const node = nodes[id];
|
| 51 |
+
if (whiteToMove) {
|
| 52 |
+
tokens.push(`${moveNum}.`, node.san);
|
| 53 |
+
} else if (i === 0) {
|
| 54 |
+
tokens.push(`${moveNum}...`, node.san);
|
| 55 |
+
} else {
|
| 56 |
+
tokens.push(node.san);
|
| 57 |
+
}
|
| 58 |
+
if (!(i === 0 && skipFirstFork)) {
|
| 59 |
+
const parentForThis = i === 0 ? parentId : nodeIds[i - 1];
|
| 60 |
+
const kids = childrenOf(nodes, rootChildren, parentForThis);
|
| 61 |
+
for (let v = 1; v < kids.length; v++) {
|
| 62 |
+
const varPath = collectMainPath(nodes, kids[v]);
|
| 63 |
+
const varTokens = buildMoveTokens(nodes, rootChildren, varPath, parentForThis, whiteToMove, moveNum, true);
|
| 64 |
+
tokens.push(`(${varTokens.join(' ')})`);
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
if (!whiteToMove) moveNum++;
|
| 68 |
+
whiteToMove = !whiteToMove;
|
| 69 |
+
});
|
| 70 |
+
return tokens;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// Builds a PGN export: the standard tag-roster header block (plus
|
| 74 |
+
// FEN/SetUp, since the explorer's starting position is rarely the default
|
| 75 |
+
// array) followed by movetext ending in a result token.
|
| 76 |
+
export function buildPgnText() {
|
| 77 |
+
const { nodes, rootChildren } = Board.getMoveHistory();
|
| 78 |
+
if (!rootChildren || rootChildren.length === 0) return '';
|
| 79 |
+
|
| 80 |
+
const startFen = Board.getStartFen();
|
| 81 |
+
const mainPath = collectMainPath(nodes, rootChildren[0]);
|
| 82 |
+
const finalFen = nodes[mainPath[mainPath.length - 1]].fen;
|
| 83 |
+
const result = Board.outcomeFromFen(finalFen);
|
| 84 |
+
|
| 85 |
+
const today = new Date();
|
| 86 |
+
const pad = n => String(n).padStart(2, '0');
|
| 87 |
+
const dateStr = `${today.getFullYear()}.${pad(today.getMonth() + 1)}.${pad(today.getDate())}`;
|
| 88 |
+
|
| 89 |
+
const headers = [
|
| 90 |
+
['Event', 'ChessTB Explorer'],
|
| 91 |
+
['Site', '?'],
|
| 92 |
+
['Date', dateStr],
|
| 93 |
+
['Round', '?'],
|
| 94 |
+
['White', '?'],
|
| 95 |
+
['Black', '?'],
|
| 96 |
+
['Result', result],
|
| 97 |
+
];
|
| 98 |
+
if (Board.isChess960()) headers.push(['Variant', 'Chess960']);
|
| 99 |
+
headers.push(['FEN', startFen], ['SetUp', '1']);
|
| 100 |
+
const headerBlock = headers.map(([k, v]) => `[${k} "${v}"]`).join('\n');
|
| 101 |
+
|
| 102 |
+
const { turn, fullmove } = parseFenMeta(startFen);
|
| 103 |
+
const tokens = buildMoveTokens(nodes, rootChildren, mainPath, ROOT_ID, turn === 'w', fullmove, false);
|
| 104 |
+
tokens.push(result);
|
| 105 |
+
|
| 106 |
+
return `${headerBlock}\n\n${tokens.join(' ')}`;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// Case-insensitive PGN tag lookup - chessops preserves whatever case the
|
| 110 |
+
// source PGN used.
|
| 111 |
+
function pgnHeaderValue(headers, name) {
|
| 112 |
+
const wanted = name.toLowerCase();
|
| 113 |
+
for (const key of headers.keys()) {
|
| 114 |
+
if (key.toLowerCase() === wanted) return headers.get(key);
|
| 115 |
+
}
|
| 116 |
+
return null;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// chessops's own PGN Variant parsing maps every Chess960-like name to the
|
| 120 |
+
// same generic rules Standard uses, so Chess960 identity is read from the
|
| 121 |
+
// raw header text instead.
|
| 122 |
+
function isChess960VariantHeader(value) {
|
| 123 |
+
return /chess\s*960|fischer\s*random/i.test(value || '');
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// Parses and validates the entire game tree, including variations, into
|
| 127 |
+
// Board's move-history format and the variant declared by its header.
|
| 128 |
+
// Returns { ok: false, error } on any rejection.
|
| 129 |
+
export function parsePgn(text) {
|
| 130 |
+
const trimmed = text.trim();
|
| 131 |
+
if (!trimmed) return { ok: false, error: '' };
|
| 132 |
+
|
| 133 |
+
let games = [];
|
| 134 |
+
try {
|
| 135 |
+
games = parsePgnStrict(trimmed);
|
| 136 |
+
} catch (error) {
|
| 137 |
+
const msg = (error instanceof PgnError && error.message === 'ERR_PGN_MULTIPLE_GAMES')
|
| 138 |
+
? 'PGN contains multiple games. Paste one game at a time.'
|
| 139 |
+
: 'Could not parse PGN. Check the format and try again.';
|
| 140 |
+
return { ok: false, error: msg };
|
| 141 |
+
}
|
| 142 |
+
const game = games[0];
|
| 143 |
+
const moves = game ? [...game.moves.mainline()] : [];
|
| 144 |
+
if (!game || (game.headers.size === 0 && moves.length === 0)) {
|
| 145 |
+
return { ok: false, error: 'Could not parse PGN. Check the format and try again.' };
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
const variant = isChess960VariantHeader(pgnHeaderValue(game.headers, 'Variant'))
|
| 149 |
+
? VARIANTS.CHESS960 : VARIANTS.STANDARD;
|
| 150 |
+
|
| 151 |
+
const fenHeader = pgnHeaderValue(game.headers, 'FEN');
|
| 152 |
+
if (variant === VARIANTS.CHESS960 && !fenHeader) {
|
| 153 |
+
return { ok: false, error: 'Chess960 PGN requires a FEN starting position.' };
|
| 154 |
+
}
|
| 155 |
+
let pos = null;
|
| 156 |
+
if (fenHeader) {
|
| 157 |
+
const parsed = parseFen(fenHeader);
|
| 158 |
+
const built = parsed.isErr ? null : Chess.fromSetup(parsed.value);
|
| 159 |
+
pos = (built && !built.isErr) ? built.value : null;
|
| 160 |
+
if (!pos) return { ok: false, error: 'Invalid FEN starting position in PGN.' };
|
| 161 |
+
}
|
| 162 |
+
if (!pos) pos = Chess.default();
|
| 163 |
+
const startFen = makeFen(pos.toSetup());
|
| 164 |
+
|
| 165 |
+
const tree = { startFen, nodes: [], rootChildren: [], rootActiveChild: null, currentId: ROOT_ID };
|
| 166 |
+
const pending = [{ source: game.moves, parentId: ROOT_ID, pos }];
|
| 167 |
+
// An explicit stack handles long lines without recursion limits.
|
| 168 |
+
while (pending.length) {
|
| 169 |
+
const frame = pending.pop();
|
| 170 |
+
const children = frame.source.children;
|
| 171 |
+
const next = [];
|
| 172 |
+
for (const child of children) {
|
| 173 |
+
const childPos = frame.pos.clone();
|
| 174 |
+
const move = parseSan(childPos, child.data.san);
|
| 175 |
+
if (!move) return { ok: false, error: `Invalid move in PGN: ${child.data.san}` };
|
| 176 |
+
const san = makeSan(childPos, move);
|
| 177 |
+
childPos.play(move);
|
| 178 |
+
const id = tree.nodes.length;
|
| 179 |
+
tree.nodes.push({
|
| 180 |
+
id, san, fen: makeFen(childPos.toSetup()),
|
| 181 |
+
from: makeSquare(move.from), to: makeSquare(move.to),
|
| 182 |
+
parentId: frame.parentId, children: [], activeChild: null,
|
| 183 |
+
});
|
| 184 |
+
if (frame.parentId === ROOT_ID) {
|
| 185 |
+
tree.rootChildren.push(id);
|
| 186 |
+
if (tree.rootActiveChild === null) tree.rootActiveChild = id;
|
| 187 |
+
} else {
|
| 188 |
+
const parent = tree.nodes[frame.parentId];
|
| 189 |
+
parent.children.push(id);
|
| 190 |
+
if (parent.activeChild === null) parent.activeChild = id;
|
| 191 |
+
}
|
| 192 |
+
next.push({ source: child, parentId: id, pos: childPos });
|
| 193 |
+
}
|
| 194 |
+
// Visit the main continuation first, preserving source branch order.
|
| 195 |
+
pending.push(...next.reverse());
|
| 196 |
+
}
|
| 197 |
+
return { ok: true, tree, variant };
|
| 198 |
+
}
|
frontend/src/engine/scroll.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Scrolls `el` into view within `container` only - never any ancestor
|
| 2 |
+
// beyond it (unlike Element.scrollIntoView(), which would also drag the
|
| 3 |
+
// mobile screen-carousel over to whichever panel is calling this).
|
| 4 |
+
export function scrollIntoViewWithin(el, container) {
|
| 5 |
+
if (!el || !container) return;
|
| 6 |
+
const elRect = el.getBoundingClientRect();
|
| 7 |
+
const containerRect = container.getBoundingClientRect();
|
| 8 |
+
if (elRect.top < containerRect.top) {
|
| 9 |
+
container.scrollTop -= (containerRect.top - elRect.top);
|
| 10 |
+
} else if (elRect.bottom > containerRect.bottom) {
|
| 11 |
+
container.scrollTop += (elRect.bottom - containerRect.bottom);
|
| 12 |
+
}
|
| 13 |
+
if (elRect.left < containerRect.left) {
|
| 14 |
+
container.scrollLeft -= (containerRect.left - elRect.left);
|
| 15 |
+
} else if (elRect.right > containerRect.right) {
|
| 16 |
+
container.scrollLeft += (elRect.right - containerRect.right);
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
// While .moves-body-scroll overflows vertically, its scrollbar narrows the
|
| 21 |
+
// content box. The body table is widened by the scrollbar's measured width
|
| 22 |
+
// so its columns keep the same pixel widths as the header table's.
|
| 23 |
+
export function syncMovesGutter() {
|
| 24 |
+
const scroller = document.querySelector('.moves-body-scroll');
|
| 25 |
+
if (!scroller) return;
|
| 26 |
+
if (scroller.scrollHeight <= scroller.clientHeight + 1) {
|
| 27 |
+
scroller.classList.remove('has-vscroll');
|
| 28 |
+
scroller.style.removeProperty('--moves-vscroll-width');
|
| 29 |
+
return;
|
| 30 |
+
}
|
| 31 |
+
const scrollbarWidth = Math.max(0, scroller.offsetWidth - scroller.clientWidth);
|
| 32 |
+
scroller.style.setProperty('--moves-vscroll-width', `${scrollbarWidth}px`);
|
| 33 |
+
scroller.classList.add('has-vscroll');
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export function initMovesGutterSync() {
|
| 37 |
+
const scroller = document.querySelector('.moves-body-scroll');
|
| 38 |
+
const tbody = document.getElementById('moves-tbody');
|
| 39 |
+
if (!scroller || !tbody) return () => {};
|
| 40 |
+
syncMovesGutter();
|
| 41 |
+
const observer = new ResizeObserver(syncMovesGutter);
|
| 42 |
+
observer.observe(scroller);
|
| 43 |
+
observer.observe(tbody);
|
| 44 |
+
window.addEventListener('resize', syncMovesGutter, { passive: true });
|
| 45 |
+
return () => {
|
| 46 |
+
observer.disconnect();
|
| 47 |
+
window.removeEventListener('resize', syncMovesGutter);
|
| 48 |
+
};
|
| 49 |
+
}
|
frontend/src/engine/storage.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* storage.js: localStorage helpers
|
| 3 |
+
*
|
| 4 |
+
* Every read/write goes through try/catch, since localStorage can throw in
|
| 5 |
+
* a sandboxed or private-browsing context. A failed read returns `fallback`;
|
| 6 |
+
* a failed write is silently ignored - callers treat persistence as
|
| 7 |
+
* best-effort.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
export function read(key, fallback = null) {
|
| 11 |
+
try {
|
| 12 |
+
const raw = localStorage.getItem(key);
|
| 13 |
+
return raw === null ? fallback : raw;
|
| 14 |
+
} catch (_) {
|
| 15 |
+
return fallback;
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function write(key, value) {
|
| 20 |
+
try { localStorage.setItem(key, String(value)); } catch (_) { /* unavailable or full */ }
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function readJson(key, fallback = null) {
|
| 24 |
+
try {
|
| 25 |
+
const raw = localStorage.getItem(key);
|
| 26 |
+
if (raw === null) return fallback;
|
| 27 |
+
const parsed = JSON.parse(raw);
|
| 28 |
+
return parsed === null || parsed === undefined ? fallback : parsed;
|
| 29 |
+
} catch (_) {
|
| 30 |
+
return fallback;
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export function writeJson(key, value) {
|
| 35 |
+
try { localStorage.setItem(key, JSON.stringify(value)); } catch (_) { /* unavailable or full */ }
|
| 36 |
+
}
|
frontend/src/engine/tablebase.js
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* tablebase.js - /probe/stream SSE client and results state
|
| 3 |
+
*
|
| 4 |
+
* Owns the SSE client for /probe/stream and all state for the four ranked
|
| 5 |
+
* move tables (DTZ / DTC / DTM / DTM50):
|
| 6 |
+
* - setResultHandler(fn) - callback after each successful probe, used for
|
| 7 |
+
* auto-play.
|
| 8 |
+
* - setNoteHandler(fn) - callback with the message of a warning or info
|
| 9 |
+
* dot tapped with touch or pen input.
|
| 10 |
+
* - setViewStateHandler(fn) - callback with a plain object describing
|
| 11 |
+
* everything the results panel needs to render (loading/progress/error,
|
| 12 |
+
* the Root row, and the ranked move rows). Rendering itself is the
|
| 13 |
+
* caller's responsibility; this module never touches the DOM to draw
|
| 14 |
+
* the table.
|
| 15 |
+
* - attachTableEvents(tbody) - wires click-to-play, hover pairing, and
|
| 16 |
+
* hover-triggered prefetch onto the actual rendered <tbody>. Returns a
|
| 17 |
+
* cleanup function.
|
| 18 |
+
* - Prefetch on hover: warms the cache for the child position of a
|
| 19 |
+
* hovered move.
|
| 20 |
+
* - The Root row: the current position's own score for each metric,
|
| 21 |
+
* included in the view state ahead of the ranked child moves when the
|
| 22 |
+
* "Show Root Row" setting is on (see setShowRootRow/getShowRootRow).
|
| 23 |
+
* - A coverage indicator: distinct state when error_code === "missing_table".
|
| 24 |
+
* - An info flag on draws by insufficient material, and a warning flag on
|
| 25 |
+
* a cursed win / blessed loss outcome - both carry a tooltip string for
|
| 26 |
+
* the caller to render. The warning tooltip has a second line when the
|
| 27 |
+
* DTZ score is rounded.
|
| 28 |
+
* - CSV export of the current move table.
|
| 29 |
+
* - A result cache keyed by (variant, FEN), so revisiting an already-
|
| 30 |
+
* probed position reuses the cached result instead of issuing a new
|
| 31 |
+
* /probe/stream call.
|
| 32 |
+
*/
|
| 33 |
+
|
| 34 |
+
import { Board, VARIANTS } from './board.js';
|
| 35 |
+
import * as Storage from './storage.js';
|
| 36 |
+
|
| 37 |
+
// ── Bounded LRU cache ────────────────────────────────────────────────────
|
| 38 |
+
const _MAX_CACHE_ENTRIES = 500;
|
| 39 |
+
|
| 40 |
+
class _BoundedCache {
|
| 41 |
+
constructor(maxSize) {
|
| 42 |
+
this._maxSize = maxSize;
|
| 43 |
+
this._map = new Map();
|
| 44 |
+
}
|
| 45 |
+
has(key) { return this._map.has(key); }
|
| 46 |
+
get(key) {
|
| 47 |
+
if (!this._map.has(key)) return undefined;
|
| 48 |
+
const value = this._map.get(key);
|
| 49 |
+
this._map.delete(key);
|
| 50 |
+
this._map.set(key, value); // refresh recency
|
| 51 |
+
return value;
|
| 52 |
+
}
|
| 53 |
+
set(key, value) {
|
| 54 |
+
this._map.delete(key);
|
| 55 |
+
this._map.set(key, value);
|
| 56 |
+
if (this._map.size > this._maxSize) {
|
| 57 |
+
this._map.delete(this._map.keys().next().value); // evict oldest
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
delete(key) { this._map.delete(key); }
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const Tablebase = (() => {
|
| 64 |
+
|
| 65 |
+
let _currentController = null;
|
| 66 |
+
let _probeSeq = 0;
|
| 67 |
+
let _loadingTimer = null;
|
| 68 |
+
let _pendingProgress = null;
|
| 69 |
+
let _onMoveSelect = null;
|
| 70 |
+
let _onNote = null;
|
| 71 |
+
let _onResult = null;
|
| 72 |
+
let _onViewState = null;
|
| 73 |
+
let _lastData = null;
|
| 74 |
+
let _lastFen = null; // FEN of the currently-applied results
|
| 75 |
+
let _lastVariant = null; // variant the currently-applied results were probed under
|
| 76 |
+
let _prefetchCache = new _BoundedCache(_MAX_CACHE_ENTRIES); // (variant,child_fen) → probe result data
|
| 77 |
+
let _resultCache = new _BoundedCache(_MAX_CACHE_ENTRIES); // (variant,fen) → last probe result
|
| 78 |
+
|
| 79 |
+
// Hover-prefetch state - module-scoped so probe() can see and cancel an
|
| 80 |
+
// in-flight prefetch for the exact position it's about to probe itself.
|
| 81 |
+
let _prefetchTimer = null;
|
| 82 |
+
let _prefetchController = null; // in-flight prefetch fetch, if any
|
| 83 |
+
let _prefetchKey = null; // cache key for the timer/fetch above
|
| 84 |
+
let _prefetchRun = null; // identity token for the current pending/in-flight run
|
| 85 |
+
let _prefetchPromise = null; // current run's eventual { ok, data|error, aborted } outcome
|
| 86 |
+
let _prefetchAttached = false; // true while a probe() call is awaiting _prefetchPromise
|
| 87 |
+
|
| 88 |
+
function _cancelPrefetch() {
|
| 89 |
+
clearTimeout(_prefetchTimer);
|
| 90 |
+
_prefetchTimer = null;
|
| 91 |
+
if (_prefetchAttached) return;
|
| 92 |
+
if (_prefetchController) { _prefetchController.abort(); _prefetchController = null; }
|
| 93 |
+
_prefetchKey = null;
|
| 94 |
+
_prefetchRun = null;
|
| 95 |
+
_prefetchPromise = null;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
function _cacheKey(fen, variant) { return `${variant || VARIANTS.STANDARD}\u0000${fen}`; }
|
| 99 |
+
|
| 100 |
+
// ── Show Root Row setting ────────────────────────────────────────────
|
| 101 |
+
const _LS_SHOW_ROOT_ROW = 'chesstb_show_root_row';
|
| 102 |
+
function _readShowRootRowPref() { return Storage.read(_LS_SHOW_ROOT_ROW) === 'true'; }
|
| 103 |
+
let _showRootRow = _readShowRootRowPref();
|
| 104 |
+
|
| 105 |
+
const _ROOT_LABEL = 'Root';
|
| 106 |
+
|
| 107 |
+
// ── WDL / formatting helpers ─────────────────────────────────────────
|
| 108 |
+
function _wdlToOutcome(wdl) {
|
| 109 |
+
return { 2:'win', 1:'cursed_win', 0:'draw', '-1':'blessed_loss', '-2':'loss' }[wdl] ?? 'unknown';
|
| 110 |
+
}
|
| 111 |
+
function _plural(n) { return Math.abs(n) === 1 ? 'ply' : 'plies'; }
|
| 112 |
+
function _pliesText(n) {
|
| 113 |
+
const a = Math.abs(n);
|
| 114 |
+
return `${a} ${_plural(a)}`;
|
| 115 |
+
}
|
| 116 |
+
function _dtcRootRaw(data) {
|
| 117 |
+
return {
|
| 118 |
+
san: _ROOT_LABEL,
|
| 119 |
+
plies: data.dtc_available ? data.dtc[2] : 0,
|
| 120 |
+
order: data.dtc_available ? data.dtc[1] : 0,
|
| 121 |
+
outcome: data.dtc_available ? _wdlToOutcome(data.dtc[0]) : 'not_available',
|
| 122 |
+
available: data.dtc_available,
|
| 123 |
+
};
|
| 124 |
+
}
|
| 125 |
+
function _csvPlies(entry) { return entry.available === false ? 'N/A' : entry.plies; }
|
| 126 |
+
|
| 127 |
+
function _isPawnless(fen) {
|
| 128 |
+
if (!fen) return false;
|
| 129 |
+
return !/[Pp]/.test(fen.split(' ')[0]);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function _dtcDisplayEntry(entry, ownFen) {
|
| 133 |
+
if (!entry) return null;
|
| 134 |
+
if (!entry.available) return { ...entry, orderText: 'N/A' };
|
| 135 |
+
return _isPawnless(ownFen)
|
| 136 |
+
? { ...entry, orderText: '-' }
|
| 137 |
+
: { ...entry, orderText: String(entry.order) };
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
function _dtcScoreText(entry) {
|
| 141 |
+
const n = Math.abs(entry.plies);
|
| 142 |
+
return `${n} ${_plural(n)}/${entry.orderText}`;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
const _ROUNDED_NOTE = 'Rounded Score — this could be off by 1 ply.';
|
| 146 |
+
|
| 147 |
+
function _warningText(entry) {
|
| 148 |
+
const text = entry.outcome === 'cursed_win'
|
| 149 |
+
? 'Cursed Win — a win in principle, but a draw under the 50-move rule.'
|
| 150 |
+
: entry.outcome === 'blessed_loss'
|
| 151 |
+
? 'Blessed Loss — a loss in principle, but a draw under the 50-move rule.'
|
| 152 |
+
: null;
|
| 153 |
+
return text && entry.rounded ? `${text}\n${_ROUNDED_NOTE}` : text;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
// ── Cell descriptors ──────────────────────────────────────────────────
|
| 157 |
+
// Same shape/formatting rules as a rendered <td> pair, as plain data;
|
| 158 |
+
// the caller renders these into cells (see ResultsPanel). `metric` and
|
| 159 |
+
// `groupClass` drive the CSS grouping; `interactive` gates whether the
|
| 160 |
+
// cell is clickable/prefetchable (false for the Root row).
|
| 161 |
+
function _createMoveCells(entry, groupClass, interactive = true, dtcFormat = false) {
|
| 162 |
+
const metric = groupClass.slice('group-'.length);
|
| 163 |
+
const headers = interactive
|
| 164 |
+
? { move: `th-${metric}-grp th-${metric}-move`, score: `th-${metric}-grp th-${metric}-score` }
|
| 165 |
+
: null;
|
| 166 |
+
if (!entry) {
|
| 167 |
+
return [
|
| 168 |
+
{ kind: 'move', className: `${groupClass} col-move is-empty-cell`, headers: headers?.move ?? null, text: '—', title: null, interactive: false },
|
| 169 |
+
{ kind: 'score', className: `${groupClass} col-score is-empty-cell`, headers: headers?.score ?? null, text: '—', title: null, interactive: false },
|
| 170 |
+
];
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
let scoreText;
|
| 174 |
+
if (entry.is_mate) scoreText = 'Checkmate';
|
| 175 |
+
else if (entry.draw_reason === 'stalemate') scoreText = 'Stalemate';
|
| 176 |
+
else if (entry.outcome === 'draw') scoreText = 'Draw';
|
| 177 |
+
else if (entry.outcome === 'unknown') scoreText = 'Unknown';
|
| 178 |
+
else if (entry.outcome === 'not_available') scoreText = 'Not Available';
|
| 179 |
+
else if (dtcFormat) scoreText = _dtcScoreText(entry);
|
| 180 |
+
else scoreText = _pliesText(entry.plies);
|
| 181 |
+
|
| 182 |
+
const outcomeLabel = {
|
| 183 |
+
win: 'Win', loss: 'Loss', cursed_win: 'Cursed win', blessed_loss: 'Blessed loss',
|
| 184 |
+
}[entry.outcome];
|
| 185 |
+
const scoreLabel = outcomeLabel && !entry.is_mate
|
| 186 |
+
? `${outcomeLabel}: ${scoreText}` : scoreText;
|
| 187 |
+
|
| 188 |
+
const moveCell = {
|
| 189 |
+
kind: 'move',
|
| 190 |
+
className: `${groupClass} col-move${interactive ? '' : ' is-root'}`,
|
| 191 |
+
headers: headers?.move ?? null,
|
| 192 |
+
text: entry.san,
|
| 193 |
+
title: entry.san,
|
| 194 |
+
interactive,
|
| 195 |
+
dataSan: interactive ? entry.san : null,
|
| 196 |
+
dataChildFen: interactive ? (entry.child_fen || '') : null,
|
| 197 |
+
};
|
| 198 |
+
const scoreCell = {
|
| 199 |
+
kind: 'score',
|
| 200 |
+
className: `${groupClass} col-score${entry.is_mate ? ' is-mate' : ''}`,
|
| 201 |
+
headers: headers?.score ?? null,
|
| 202 |
+
text: scoreText,
|
| 203 |
+
title: scoreLabel,
|
| 204 |
+
interactive,
|
| 205 |
+
dataSan: interactive ? entry.san : null,
|
| 206 |
+
dataChildFen: interactive ? (entry.child_fen || '') : null,
|
| 207 |
+
outcome: entry.outcome,
|
| 208 |
+
warning: _warningText(entry),
|
| 209 |
+
info: entry.draw_reason === 'insufficient_material' ? 'Draw by Insufficient Material' : null,
|
| 210 |
+
};
|
| 211 |
+
return [moveCell, scoreCell];
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// ── Root row entries ──────────────────────────────────────────────────
|
| 215 |
+
function _rootEntries(data, fen) {
|
| 216 |
+
const maxRows = Math.max(
|
| 217 |
+
(data.moves_dtz || []).length,
|
| 218 |
+
(data.moves_dtc || []).length,
|
| 219 |
+
(data.moves_dtm || []).length,
|
| 220 |
+
(data.moves_dtm50 || []).length,
|
| 221 |
+
);
|
| 222 |
+
const rootIsMate = maxRows === 0 && data.wdl !== 0;
|
| 223 |
+
|
| 224 |
+
const dtzEntry = {
|
| 225 |
+
san: _ROOT_LABEL, plies: data.dtz_available ? data.dtz : 0,
|
| 226 |
+
outcome: data.dtz_available ? _wdlToOutcome(data.wdl) : 'not_available',
|
| 227 |
+
available: data.dtz_available,
|
| 228 |
+
rounded: data.dtz_rounded === true,
|
| 229 |
+
is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null,
|
| 230 |
+
};
|
| 231 |
+
const dtcRaw = {
|
| 232 |
+
..._dtcRootRaw(data),
|
| 233 |
+
is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null,
|
| 234 |
+
};
|
| 235 |
+
const dtcEntry = _dtcDisplayEntry(dtcRaw, fen);
|
| 236 |
+
const dtmEntry = {
|
| 237 |
+
san: _ROOT_LABEL, plies: data.dtm_available ? data.dtm : 0,
|
| 238 |
+
outcome: data.dtm_available ? _wdlToOutcome(data.wdl) : 'not_available',
|
| 239 |
+
available: data.dtm_available,
|
| 240 |
+
is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null,
|
| 241 |
+
};
|
| 242 |
+
const dtm50Entry = {
|
| 243 |
+
san: _ROOT_LABEL, plies: data.dtm50_available ? data.dtm50[1] : 0,
|
| 244 |
+
outcome: data.dtm50_available ? _wdlToOutcome(data.dtm50[0]) : 'not_available',
|
| 245 |
+
available: data.dtm50_available,
|
| 246 |
+
is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null,
|
| 247 |
+
};
|
| 248 |
+
return { dtzEntry, dtcEntry, dtmEntry, dtm50Entry };
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
// ── View state ────────────────────────────────────────────────────────
|
| 252 |
+
// Single object describing everything the results panel renders.
|
| 253 |
+
// Rebuilt on every meaningful change and pushed to _onViewState.
|
| 254 |
+
let _viewState = {
|
| 255 |
+
pending: false,
|
| 256 |
+
loading: false,
|
| 257 |
+
progress: { visible: false, pct: 0, label: '' },
|
| 258 |
+
error: null, // { message, isCoverage } | null
|
| 259 |
+
moveCount: '',
|
| 260 |
+
showRootRow: _showRootRow,
|
| 261 |
+
rootRow: null, // { cells: [...] } | null
|
| 262 |
+
rows: [], // [{ rank, cells: [...] }]
|
| 263 |
+
emptyState: true,
|
| 264 |
+
emptyMessage: 'Choose a position to see its best moves.',
|
| 265 |
+
};
|
| 266 |
+
function _publish(patch) {
|
| 267 |
+
_viewState = { ..._viewState, ...patch };
|
| 268 |
+
if (_onViewState) _onViewState(_viewState);
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
function _beginLoading(mySeq) {
|
| 272 |
+
clearTimeout(_loadingTimer);
|
| 273 |
+
_pendingProgress = { visible: true, pct: null, label: 'Checking position…' };
|
| 274 |
+
// Fast and cached responses should not flash through an empty table.
|
| 275 |
+
// Disable old move actions immediately, then show loading if needed.
|
| 276 |
+
_publish({ pending: true, error: null, ...(_viewState.loading ? { progress: _pendingProgress } : {}) });
|
| 277 |
+
_loadingTimer = setTimeout(() => {
|
| 278 |
+
if (mySeq !== _probeSeq) return;
|
| 279 |
+
_blankResults();
|
| 280 |
+
_publish({ loading: true, progress: _pendingProgress });
|
| 281 |
+
}, 180);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
function _rootRowState(data, fen) {
|
| 285 |
+
if (!_showRootRow) return null;
|
| 286 |
+
if (!data) {
|
| 287 |
+
const cells = ['group-dtz', 'group-dtc', 'group-dtm', 'group-dtm50']
|
| 288 |
+
.flatMap(g => _createMoveCells(null, g));
|
| 289 |
+
return { cells };
|
| 290 |
+
}
|
| 291 |
+
const { dtzEntry, dtcEntry, dtmEntry, dtm50Entry } = _rootEntries(data, fen);
|
| 292 |
+
const cells = [
|
| 293 |
+
..._createMoveCells(dtzEntry, 'group-dtz', false),
|
| 294 |
+
..._createMoveCells(dtcEntry, 'group-dtc', false, true),
|
| 295 |
+
..._createMoveCells(dtmEntry, 'group-dtm', false),
|
| 296 |
+
..._createMoveCells(dtm50Entry, 'group-dtm50', false),
|
| 297 |
+
];
|
| 298 |
+
return { cells };
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
function _rowsState(movesDtz, movesDtc, movesDtm, movesDtm50) {
|
| 302 |
+
const maxRows = Math.max(movesDtz.length, movesDtc.length, movesDtm.length, movesDtm50.length);
|
| 303 |
+
const rows = [];
|
| 304 |
+
for (let i = 0; i < maxRows; i++) {
|
| 305 |
+
const dtcEntry = _dtcDisplayEntry(movesDtc[i] || null, (movesDtc[i] || {}).child_fen);
|
| 306 |
+
const cells = [
|
| 307 |
+
..._createMoveCells(movesDtz[i] || null, 'group-dtz'),
|
| 308 |
+
..._createMoveCells(dtcEntry, 'group-dtc', true, true),
|
| 309 |
+
..._createMoveCells(movesDtm[i] || null, 'group-dtm'),
|
| 310 |
+
..._createMoveCells(movesDtm50[i] || null, 'group-dtm50'),
|
| 311 |
+
];
|
| 312 |
+
rows.push({ rank: i + 1, cells });
|
| 313 |
+
}
|
| 314 |
+
return rows;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
function _blankResults() {
|
| 318 |
+
_publish({
|
| 319 |
+
rootRow: _rootRowState(null, _lastFen),
|
| 320 |
+
rows: [],
|
| 321 |
+
emptyState: false,
|
| 322 |
+
moveCount: _viewState.moveCount, // set client-side from the FEN - untouched here
|
| 323 |
+
});
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
function _isComplete(data) {
|
| 327 |
+
return !(data && data.summary && data.summary.unknown > 0);
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
function _applyResults(data, fen, variant) {
|
| 331 |
+
clearTimeout(_loadingTimer);
|
| 332 |
+
_lastData = data;
|
| 333 |
+
_lastFen = fen;
|
| 334 |
+
_lastVariant = variant;
|
| 335 |
+
if (_isComplete(data)) _resultCache.set(_cacheKey(fen, variant), data);
|
| 336 |
+
|
| 337 |
+
const movesDtz = data.moves_dtz || [];
|
| 338 |
+
const movesDtc = data.moves_dtc || [];
|
| 339 |
+
const movesDtm = data.moves_dtm || [];
|
| 340 |
+
const movesDtm50 = data.moves_dtm50 || [];
|
| 341 |
+
const rows = _rowsState(movesDtz, movesDtc, movesDtm, movesDtm50);
|
| 342 |
+
const maxRows = rows.length;
|
| 343 |
+
|
| 344 |
+
_publish({
|
| 345 |
+
pending: false,
|
| 346 |
+
loading: false,
|
| 347 |
+
progress: { visible: false, pct: 0, label: '' },
|
| 348 |
+
error: null,
|
| 349 |
+
rootRow: _rootRowState(data, fen),
|
| 350 |
+
rows,
|
| 351 |
+
emptyState: maxRows === 0,
|
| 352 |
+
emptyMessage: data.draw_reason === 'insufficient_material'
|
| 353 |
+
? 'Draw by insufficient material.'
|
| 354 |
+
: data.draw_reason === 'stalemate'
|
| 355 |
+
? 'Stalemate. No legal moves.'
|
| 356 |
+
: data.wdl === -2 && maxRows === 0
|
| 357 |
+
? 'Checkmate. No legal moves.'
|
| 358 |
+
: data.wdl === 0
|
| 359 |
+
? 'Draw.'
|
| 360 |
+
: 'No ranked moves for this position.',
|
| 361 |
+
moveCount: maxRows > 0 ? `${maxRows} legal move${maxRows === 1 ? '' : 's'}` : '',
|
| 362 |
+
});
|
| 363 |
+
if (_onResult) _onResult(data);
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
function _applyCachedResult(data, fen, variant, mySeq) {
|
| 367 |
+
clearTimeout(_loadingTimer);
|
| 368 |
+
Promise.resolve().then(() => {
|
| 369 |
+
if (mySeq !== _probeSeq) return;
|
| 370 |
+
_applyResults(data, fen, variant);
|
| 371 |
+
});
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
function _reset() {
|
| 375 |
+
clearTimeout(_loadingTimer);
|
| 376 |
+
_publish({ pending: false, loading: false, progress: { visible: false, pct: 0, label: '' }, rootRow: _rootRowState(null, _lastFen), rows: [], emptyState: false, error: null });
|
| 377 |
+
_lastData = null;
|
| 378 |
+
_lastFen = null;
|
| 379 |
+
_lastVariant = null;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
function _showCoverageError(errData) {
|
| 383 |
+
const pc = errData.piece_count;
|
| 384 |
+
const note = pc ? ` (position has ${pc} pieces)` : '';
|
| 385 |
+
_publish({ error: { message: `Not in tablebase${note} — load a tablebase with more piece coverage.`, isCoverage: true } });
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
function _handleError(errData, onError) {
|
| 389 |
+
const msg = errData.error || 'Unknown error.';
|
| 390 |
+
const isCoverageError = errData.error_code === 'missing_table';
|
| 391 |
+
if (isCoverageError) {
|
| 392 |
+
_showCoverageError(errData);
|
| 393 |
+
} else {
|
| 394 |
+
_publish({ error: { message: msg, isCoverage: false } });
|
| 395 |
+
}
|
| 396 |
+
if (onError) onError(msg, isCoverageError);
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
// ── SSE streaming client ──────────────────────────────────────────────
|
| 400 |
+
async function* _sseEvents(response) {
|
| 401 |
+
if (!response.body) throw { error: 'Network error.' };
|
| 402 |
+
const reader = response.body.getReader();
|
| 403 |
+
const decoder = new TextDecoder();
|
| 404 |
+
let buffer = '';
|
| 405 |
+
while (true) {
|
| 406 |
+
const { done, value } = await reader.read();
|
| 407 |
+
if (done) break;
|
| 408 |
+
buffer += decoder.decode(value, { stream: true });
|
| 409 |
+
const events = buffer.split('\n\n');
|
| 410 |
+
buffer = events.pop() || '';
|
| 411 |
+
for (const rawEvent of events) {
|
| 412 |
+
const line = rawEvent.trim();
|
| 413 |
+
if (!line.startsWith('data: ')) continue;
|
| 414 |
+
try { yield JSON.parse(line.slice(6)); } catch { /* skip malformed frame */ }
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
async function _probeStream(fen, variant, signal, mySeq) {
|
| 420 |
+
const response = await fetch('/probe/stream', {
|
| 421 |
+
method: 'POST',
|
| 422 |
+
headers: { 'Content-Type': 'application/json' },
|
| 423 |
+
body: JSON.stringify({ fen, variant }),
|
| 424 |
+
signal,
|
| 425 |
+
});
|
| 426 |
+
if (!response.ok) {
|
| 427 |
+
const err = await response.json().catch(() => ({ error: 'Network error.' }));
|
| 428 |
+
throw err;
|
| 429 |
+
}
|
| 430 |
+
for await (const evtData of _sseEvents(response)) {
|
| 431 |
+
if (signal.aborted || mySeq !== _probeSeq) throw new DOMException('Cancelled', 'AbortError');
|
| 432 |
+
if (evtData.status === 'probing') {
|
| 433 |
+
const total = evtData.total;
|
| 434 |
+
_pendingProgress = {
|
| 435 |
+
visible: true,
|
| 436 |
+
pct: total > 0 ? Math.round((evtData.completed / total) * 100) : null,
|
| 437 |
+
label: total > 0 ? `Evaluating moves · ${evtData.completed}/${total}` : 'Preparing result…',
|
| 438 |
+
};
|
| 439 |
+
if (_viewState.loading) _publish({ progress: _pendingProgress });
|
| 440 |
+
} else if (evtData.status === 'done') {
|
| 441 |
+
return evtData;
|
| 442 |
+
} else if (evtData.status === 'error') {
|
| 443 |
+
throw evtData;
|
| 444 |
+
}
|
| 445 |
+
}
|
| 446 |
+
throw { error: 'Stream ended unexpectedly.' };
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
function probe(fen, variant, onError) {
|
| 450 |
+
const mySeq = ++_probeSeq;
|
| 451 |
+
clearTimeout(_loadingTimer);
|
| 452 |
+
if (_currentController) { _currentController.abort(); _currentController = null; }
|
| 453 |
+
const key = _cacheKey(fen, variant);
|
| 454 |
+
|
| 455 |
+
if (_prefetchKey === key) {
|
| 456 |
+
if (_prefetchController && _prefetchPromise) {
|
| 457 |
+
_prefetchAttached = true;
|
| 458 |
+
const waitingOn = _prefetchPromise;
|
| 459 |
+
_beginLoading(mySeq);
|
| 460 |
+
waitingOn.then(outcome => {
|
| 461 |
+
_prefetchAttached = false;
|
| 462 |
+
if (mySeq !== _probeSeq) return;
|
| 463 |
+
if (outcome.ok) {
|
| 464 |
+
_applyResults(outcome.data, fen, variant);
|
| 465 |
+
} else {
|
| 466 |
+
_reset();
|
| 467 |
+
if (!outcome.aborted) _handleError(outcome.error && outcome.error.error ? outcome.error : { error: 'Cannot reach backend.' }, onError);
|
| 468 |
+
}
|
| 469 |
+
});
|
| 470 |
+
return;
|
| 471 |
+
}
|
| 472 |
+
_cancelPrefetch();
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
if (_prefetchCache.has(key)) {
|
| 476 |
+
const cached = _prefetchCache.get(key);
|
| 477 |
+
_prefetchCache.delete(key);
|
| 478 |
+
_applyCachedResult(cached, fen, variant, mySeq);
|
| 479 |
+
return;
|
| 480 |
+
}
|
| 481 |
+
if (_resultCache.has(key)) {
|
| 482 |
+
_applyCachedResult(_resultCache.get(key), fen, variant, mySeq);
|
| 483 |
+
return;
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
_currentController = new AbortController();
|
| 487 |
+
const myController = _currentController;
|
| 488 |
+
const signal = myController.signal;
|
| 489 |
+
|
| 490 |
+
_beginLoading(mySeq);
|
| 491 |
+
|
| 492 |
+
_probeStream(fen, variant, signal, mySeq)
|
| 493 |
+
.then(data => {
|
| 494 |
+
if (mySeq !== _probeSeq) return;
|
| 495 |
+
if (_currentController === myController) _currentController = null;
|
| 496 |
+
if (data.error) {
|
| 497 |
+
_reset();
|
| 498 |
+
_handleError(data, onError);
|
| 499 |
+
} else {
|
| 500 |
+
_applyResults(data, fen, variant);
|
| 501 |
+
}
|
| 502 |
+
})
|
| 503 |
+
.catch(err => {
|
| 504 |
+
if (err && err.name === 'AbortError') return;
|
| 505 |
+
if (mySeq !== _probeSeq) return;
|
| 506 |
+
if (_currentController === myController) _currentController = null;
|
| 507 |
+
_reset();
|
| 508 |
+
_handleError(err && err.error ? err : { error: 'Cannot reach backend.' }, onError);
|
| 509 |
+
console.error('[Tablebase]', err);
|
| 510 |
+
});
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
// ── CSV export ────────────────────────────────────────────────────────
|
| 514 |
+
function _csvField(value) {
|
| 515 |
+
const s = String(value);
|
| 516 |
+
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
function exportCsv() {
|
| 520 |
+
if (!_lastData) return;
|
| 521 |
+
const dtz = _lastData.moves_dtz || [];
|
| 522 |
+
const dtc = _lastData.moves_dtc || [];
|
| 523 |
+
const dtm = _lastData.moves_dtm || [];
|
| 524 |
+
const dtm50 = _lastData.moves_dtm50 || [];
|
| 525 |
+
const rows = Math.max(dtz.length, dtc.length, dtm.length, dtm50.length);
|
| 526 |
+
const lines = [];
|
| 527 |
+
if (_lastFen) lines.push(`[FEN "${_lastFen}"]`);
|
| 528 |
+
lines.push('Rank,DTZ Move,DTZ Plies,DTZ Outcome,DTC Move,DTC Plies,DTC Order,DTC Outcome,DTM Move,DTM Plies,DTM Outcome,DTM50 Move,DTM50 Plies,DTM50 Outcome');
|
| 529 |
+
if (_showRootRow) {
|
| 530 |
+
const { dtzEntry, dtcEntry, dtmEntry, dtm50Entry } = _rootEntries(_lastData, _lastFen);
|
| 531 |
+
lines.push([
|
| 532 |
+
'',
|
| 533 |
+
_ROOT_LABEL, _csvPlies(dtzEntry), dtzEntry.outcome,
|
| 534 |
+
_ROOT_LABEL, _csvPlies(dtcEntry), dtcEntry.orderText, dtcEntry.outcome,
|
| 535 |
+
_ROOT_LABEL, _csvPlies(dtmEntry), dtmEntry.outcome,
|
| 536 |
+
_ROOT_LABEL, _csvPlies(dtm50Entry), dtm50Entry.outcome,
|
| 537 |
+
].map(_csvField).join(','));
|
| 538 |
+
}
|
| 539 |
+
for (let i = 0; i < rows; i++) {
|
| 540 |
+
const d0 = dtz[i], d2 = dtm[i], d3 = dtm50[i];
|
| 541 |
+
const d1 = _dtcDisplayEntry(dtc[i] || null, (dtc[i] || {}).child_fen);
|
| 542 |
+
lines.push([
|
| 543 |
+
i + 1,
|
| 544 |
+
d0 ? d0.san : '', d0 ? _csvPlies(d0) : '', d0 ? d0.outcome : '',
|
| 545 |
+
d1 ? d1.san : '', d1 ? _csvPlies(d1) : '', d1 ? d1.orderText : '', d1 ? d1.outcome : '',
|
| 546 |
+
d2 ? d2.san : '', d2 ? _csvPlies(d2) : '', d2 ? d2.outcome : '',
|
| 547 |
+
d3 ? d3.san : '', d3 ? _csvPlies(d3) : '', d3 ? d3.outcome : '',
|
| 548 |
+
].map(_csvField).join(','));
|
| 549 |
+
}
|
| 550 |
+
const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv' });
|
| 551 |
+
const a = document.createElement('a');
|
| 552 |
+
a.href = URL.createObjectURL(blob);
|
| 553 |
+
a.download = 'chesstb_moves.csv';
|
| 554 |
+
a.click();
|
| 555 |
+
URL.revokeObjectURL(a.href);
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
// ── Public API ────────────────────────────────────────────────────────
|
| 559 |
+
function setMoveSelectHandler(fn) { _onMoveSelect = fn; }
|
| 560 |
+
function setNoteHandler(fn) { _onNote = fn; }
|
| 561 |
+
function setResultHandler(fn) { _onResult = fn; }
|
| 562 |
+
function setViewStateHandler(fn) { _onViewState = fn; if (fn) fn(_viewState); }
|
| 563 |
+
function getViewState() { return _viewState; }
|
| 564 |
+
function getLastData() { return _lastData; }
|
| 565 |
+
function getLastFen() { return _lastFen; }
|
| 566 |
+
function getLastVariant() { return _lastVariant; }
|
| 567 |
+
|
| 568 |
+
function setShowRootRow(show) {
|
| 569 |
+
_showRootRow = !!show;
|
| 570 |
+
Storage.write(_LS_SHOW_ROOT_ROW, _showRootRow);
|
| 571 |
+
_publish({ showRootRow: _showRootRow, rootRow: _rootRowState(_lastData, _lastFen) });
|
| 572 |
+
}
|
| 573 |
+
function getShowRootRow() { return _showRootRow; }
|
| 574 |
+
|
| 575 |
+
function clearErrorLine() { _publish({ error: null }); }
|
| 576 |
+
|
| 577 |
+
// Sets the legal-move count text ahead of any probe result - the count
|
| 578 |
+
// is computed client-side from the FEN, not from probe data.
|
| 579 |
+
function setMoveCount(text) { _publish({ moveCount: text }); }
|
| 580 |
+
|
| 581 |
+
// ── Table DOM wiring (hover pairing + prefetch + click-to-play) ───────
|
| 582 |
+
// Attached to the rendered <tbody> by the results panel component.
|
| 583 |
+
// Pointer position is tracked so a table rebuild under a stationary
|
| 584 |
+
// pointer (e.g. a probe result arriving mid-hover) can re-evaluate
|
| 585 |
+
// what is underneath it.
|
| 586 |
+
function attachTableEvents(tbody) {
|
| 587 |
+
if (!tbody) return () => {};
|
| 588 |
+
let hoveredPair = [];
|
| 589 |
+
let lastPointerX = null;
|
| 590 |
+
let lastPointerY = null;
|
| 591 |
+
let lastPointerType = '';
|
| 592 |
+
|
| 593 |
+
function pairCells(cell) {
|
| 594 |
+
if (cell.classList.contains('col-move')) {
|
| 595 |
+
const next = cell.nextElementSibling;
|
| 596 |
+
return next && next.dataset.san ? [cell, next] : [cell];
|
| 597 |
+
}
|
| 598 |
+
if (cell.classList.contains('col-score')) {
|
| 599 |
+
const prev = cell.previousElementSibling;
|
| 600 |
+
return prev && prev.dataset.san ? [prev, cell] : [cell];
|
| 601 |
+
}
|
| 602 |
+
return [cell];
|
| 603 |
+
}
|
| 604 |
+
function updatePairHover(target) {
|
| 605 |
+
const cell = target.closest('td[data-san]');
|
| 606 |
+
hoveredPair.forEach(c => c.classList.remove('is-pair-hover'));
|
| 607 |
+
hoveredPair = [];
|
| 608 |
+
if (cell) {
|
| 609 |
+
hoveredPair = pairCells(cell);
|
| 610 |
+
hoveredPair.forEach(c => c.classList.add('is-pair-hover'));
|
| 611 |
+
}
|
| 612 |
+
}
|
| 613 |
+
function updatePrefetch(target) {
|
| 614 |
+
const cell = target.closest('td[data-child-fen]');
|
| 615 |
+
if (!cell || !cell.dataset.childFen) { _cancelPrefetch(); return; }
|
| 616 |
+
const childFen = cell.dataset.childFen;
|
| 617 |
+
const variant = Board.getVariant() || VARIANTS.STANDARD;
|
| 618 |
+
const key = _cacheKey(childFen, variant);
|
| 619 |
+
if (key === _prefetchKey) return;
|
| 620 |
+
_cancelPrefetch();
|
| 621 |
+
if (_prefetchCache.has(key) || _resultCache.has(key)) return;
|
| 622 |
+
_prefetchKey = key;
|
| 623 |
+
const run = {};
|
| 624 |
+
_prefetchRun = run;
|
| 625 |
+
_prefetchTimer = setTimeout(() => {
|
| 626 |
+
const controller = new AbortController();
|
| 627 |
+
_prefetchController = controller;
|
| 628 |
+
_prefetchPromise = fetch('/probe/stream', {
|
| 629 |
+
method: 'POST',
|
| 630 |
+
headers: { 'Content-Type': 'application/json', 'X-Prefetch': '1' },
|
| 631 |
+
body: JSON.stringify({ fen: childFen, variant }),
|
| 632 |
+
signal: controller.signal,
|
| 633 |
+
})
|
| 634 |
+
.then(async r => {
|
| 635 |
+
if (!r.ok) {
|
| 636 |
+
const err = await r.json().catch(() => ({ error: 'Network error.' }));
|
| 637 |
+
return { ok: false, error: err };
|
| 638 |
+
}
|
| 639 |
+
for await (const d of _sseEvents(r)) {
|
| 640 |
+
if (d.status === 'done') {
|
| 641 |
+
if (_isComplete(d)) _prefetchCache.set(key, d);
|
| 642 |
+
return { ok: true, data: d };
|
| 643 |
+
}
|
| 644 |
+
if (d.status === 'error') return { ok: false, error: d };
|
| 645 |
+
}
|
| 646 |
+
return { ok: false, error: { error: 'Stream ended unexpectedly.' } };
|
| 647 |
+
})
|
| 648 |
+
.catch(err => ({ ok: false, error: err, aborted: err && err.name === 'AbortError' }))
|
| 649 |
+
.finally(() => {
|
| 650 |
+
if (_prefetchRun === run) {
|
| 651 |
+
_prefetchController = null; _prefetchKey = null;
|
| 652 |
+
_prefetchRun = null; _prefetchPromise = null;
|
| 653 |
+
}
|
| 654 |
+
});
|
| 655 |
+
}, 400);
|
| 656 |
+
}
|
| 657 |
+
function reapplyHoverAtPointer() {
|
| 658 |
+
if (lastPointerX === null) return;
|
| 659 |
+
const el = document.elementFromPoint(lastPointerX, lastPointerY);
|
| 660 |
+
if (!el || !tbody.contains(el)) {
|
| 661 |
+
hoveredPair.forEach(c => c.classList.remove('is-pair-hover'));
|
| 662 |
+
hoveredPair = [];
|
| 663 |
+
return;
|
| 664 |
+
}
|
| 665 |
+
updatePairHover(el);
|
| 666 |
+
updatePrefetch(el);
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
function onPointerdown(e) { lastPointerType = e.pointerType; }
|
| 670 |
+
function onClick(e) {
|
| 671 |
+
if (_viewState.pending) return;
|
| 672 |
+
const type = e.pointerType || lastPointerType;
|
| 673 |
+
lastPointerType = '';
|
| 674 |
+
const dot = e.target.closest('.warning-dot, .info-dot');
|
| 675 |
+
if (dot && (type === 'touch' || type === 'pen')) {
|
| 676 |
+
if (_onNote && dot.title) _onNote(dot.title);
|
| 677 |
+
return;
|
| 678 |
+
}
|
| 679 |
+
const cell = e.target.closest('td[data-san]');
|
| 680 |
+
if (cell && _onMoveSelect) _onMoveSelect(cell.dataset.san);
|
| 681 |
+
}
|
| 682 |
+
function onKeydown(e) {
|
| 683 |
+
if (_viewState.pending) return;
|
| 684 |
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
| 685 |
+
const cell = e.target.closest('td[data-san]');
|
| 686 |
+
if (cell && _onMoveSelect) { e.preventDefault(); _onMoveSelect(cell.dataset.san); }
|
| 687 |
+
}
|
| 688 |
+
function onMousemove(e) { lastPointerX = e.clientX; lastPointerY = e.clientY; }
|
| 689 |
+
function onMouseover(e) {
|
| 690 |
+
lastPointerX = e.clientX; lastPointerY = e.clientY;
|
| 691 |
+
updatePairHover(e.target); updatePrefetch(e.target);
|
| 692 |
+
}
|
| 693 |
+
function onMouseleave() {
|
| 694 |
+
lastPointerX = null; lastPointerY = null;
|
| 695 |
+
hoveredPair.forEach(c => c.classList.remove('is-pair-hover'));
|
| 696 |
+
hoveredPair = [];
|
| 697 |
+
_cancelPrefetch();
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
tbody.addEventListener('pointerdown', onPointerdown);
|
| 701 |
+
tbody.addEventListener('click', onClick);
|
| 702 |
+
tbody.addEventListener('keydown', onKeydown);
|
| 703 |
+
tbody.addEventListener('mousemove', onMousemove);
|
| 704 |
+
tbody.addEventListener('mouseover', onMouseover);
|
| 705 |
+
tbody.addEventListener('mouseleave', onMouseleave);
|
| 706 |
+
|
| 707 |
+
// The pointer may already be sitting over a cell that just
|
| 708 |
+
// (re)appeared without itself moving - re-check on every render.
|
| 709 |
+
reapplyHoverAtPointer();
|
| 710 |
+
_reapplyOnNextRender = reapplyHoverAtPointer;
|
| 711 |
+
|
| 712 |
+
return () => {
|
| 713 |
+
tbody.removeEventListener('pointerdown', onPointerdown);
|
| 714 |
+
tbody.removeEventListener('click', onClick);
|
| 715 |
+
tbody.removeEventListener('keydown', onKeydown);
|
| 716 |
+
tbody.removeEventListener('mousemove', onMousemove);
|
| 717 |
+
tbody.removeEventListener('mouseover', onMouseover);
|
| 718 |
+
tbody.removeEventListener('mouseleave', onMouseleave);
|
| 719 |
+
if (_reapplyOnNextRender === reapplyHoverAtPointer) _reapplyOnNextRender = () => {};
|
| 720 |
+
};
|
| 721 |
+
}
|
| 722 |
+
let _reapplyOnNextRender = () => {};
|
| 723 |
+
// Called by the results panel after each render commits, so hover
|
| 724 |
+
// state re-syncs against the freshly-rendered cells.
|
| 725 |
+
function notifyTableRendered() { _reapplyOnNextRender(); }
|
| 726 |
+
|
| 727 |
+
return {
|
| 728 |
+
probe, setMoveSelectHandler, setNoteHandler, setResultHandler, getLastData, getLastFen, getLastVariant,
|
| 729 |
+
setShowRootRow, getShowRootRow, clearErrorLine, setMoveCount, exportCsv,
|
| 730 |
+
setViewStateHandler, getViewState, attachTableEvents, notifyTableRendered,
|
| 731 |
+
};
|
| 732 |
+
|
| 733 |
+
})();
|
| 734 |
+
|
| 735 |
+
export { Tablebase };
|
frontend/src/engine/theme.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* theme.js: Board and piece theming module
|
| 3 |
+
*
|
| 4 |
+
* Owns the chess board's visual styling: square colours, rank/file label
|
| 5 |
+
* colours, last-move highlight colour, and the piece-set image path. Colours
|
| 6 |
+
* are read from the --board-* tokens in styles/tokens.css. The board uses
|
| 7 |
+
* Libre Brown and the piece set uses CBurnett.
|
| 8 |
+
* Exposes a small public API consumed by board.js and useExplorer.js.
|
| 9 |
+
*
|
| 10 |
+
* The CSS-generation function below targets chessground's DOM/class model
|
| 11 |
+
* (`cg-board`, `coords.ranks`/`coords.files`) - see the comment above
|
| 12 |
+
* _coordLabelCss() for how coordinate-label positioning and colouring are
|
| 13 |
+
* derived against that model.
|
| 14 |
+
*
|
| 15 |
+
* Public API:
|
| 16 |
+
* Theme.init(reconstructBoard) - register board reconstruction callback
|
| 17 |
+
* Theme.apply() - inject the board's theme <style> tag
|
| 18 |
+
* Theme.currentPieceSet() - return the active piece set id string
|
| 19 |
+
* Theme.pieceThemeFn() - return piece-code → image-path function
|
| 20 |
+
* (used by board.js's spare trays and
|
| 21 |
+
* promotion dialog)
|
| 22 |
+
*/
|
| 23 |
+
|
| 24 |
+
function _token(name) {
|
| 25 |
+
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function _boardColors() {
|
| 29 |
+
const light = _token('--board-light');
|
| 30 |
+
const dark = _token('--board-dark');
|
| 31 |
+
return {
|
| 32 |
+
id: 'libre-brown',
|
| 33 |
+
lightSquare: light,
|
| 34 |
+
darkSquare: dark,
|
| 35 |
+
labelLight: dark, // label colour on light-square corners
|
| 36 |
+
labelDark: light, // label colour on dark-square corners
|
| 37 |
+
highlight: _token('--board-highlight'),
|
| 38 |
+
};
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const PIECE_SET = 'cburnett';
|
| 42 |
+
|
| 43 |
+
let _reconstructBoard = null; // callback registered via init()
|
| 44 |
+
|
| 45 |
+
// ── Private: CSS generation ───────────────────────────────────────────────
|
| 46 |
+
//
|
| 47 |
+
// Chessground has no per-square light/dark CSS classes - the checkerboard
|
| 48 |
+
// pattern is a single background-image on `cg-board`. It is rendered here
|
| 49 |
+
// as a tiny tiled SVG data URI (_checkerboardDataUri). Rank/file labels
|
| 50 |
+
// are chessground's own `coords.ranks`/`coords.files` elements, positioned
|
| 51 |
+
// by static CSS in chess/board.css to sit inset at the board's corners
|
| 52 |
+
// rather than chessground's default outside-the-board placement. Colour is
|
| 53 |
+
// done with :nth-child selectors scoped by orientation, not the
|
| 54 |
+
// `.coord-light`/`.coord-dark` classes renderCoords() (wrap.ts) puts on
|
| 55 |
+
// each label: those classes don't correspond to the actual square each
|
| 56 |
+
// label overlays once repositioned this way (chessground's own official
|
| 57 |
+
// theme CSS doesn't use them for coords.ranks/coords.files either, for the
|
| 58 |
+
// same reason: they're only meaningful for chessground's default
|
| 59 |
+
// outside-the-board placement). See the matching comment in
|
| 60 |
+
// chess/board.css for the full derivation.
|
| 61 |
+
|
| 62 |
+
function _checkerboardDataUri(light, dark) {
|
| 63 |
+
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2'>
|
| 64 |
+
<rect width='1' height='1' fill='${light}'/>
|
| 65 |
+
<rect x='1' width='1' height='1' fill='${dark}'/>
|
| 66 |
+
<rect y='1' width='1' height='1' fill='${dark}'/>
|
| 67 |
+
<rect x='1' y='1' width='1' height='1' fill='${light}'/>
|
| 68 |
+
</svg>`;
|
| 69 |
+
return `data:image/svg+xml;base64,${btoa(svg)}`;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// A light-square label gets c.labelLight, a dark-square label gets
|
| 73 |
+
// c.labelDark. class + type + type + :nth-child, matched on all four
|
| 74 |
+
// orientation/parity combinations, carries the specificity needed to
|
| 75 |
+
// override chess/board.css's own default coord-label rule (see the comment there
|
| 76 |
+
// for why .coord-light/.coord-dark aren't used) regardless of source
|
| 77 |
+
// order. For white orientation the odd nth-child positions land on the
|
| 78 |
+
// a-file's dark squares (a1/a3/a5/a7, per chess/board.css's comment) and so take
|
| 79 |
+
// c.labelDark; the even positions (a2/a4/a6/a8, light squares) take
|
| 80 |
+
// c.labelLight. No text-shadow: the label colour itself carries enough
|
| 81 |
+
// contrast against its own square.
|
| 82 |
+
function _coordLabelCss(c) {
|
| 83 |
+
return `.orientation-white coords.ranks coord:nth-child(odd),
|
| 84 |
+
.orientation-white coords.files coord:nth-child(odd) { color: ${c.labelDark} !important; }
|
| 85 |
+
.orientation-white coords.ranks coord:nth-child(even),
|
| 86 |
+
.orientation-white coords.files coord:nth-child(even) { color: ${c.labelLight} !important; }
|
| 87 |
+
.orientation-black coords.ranks coord:nth-child(odd),
|
| 88 |
+
.orientation-black coords.files coord:nth-child(odd) { color: ${c.labelLight} !important; }
|
| 89 |
+
.orientation-black coords.ranks coord:nth-child(even),
|
| 90 |
+
.orientation-black coords.files coord:nth-child(even) { color: ${c.labelDark} !important; }`;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function _cssForBoard(c) {
|
| 94 |
+
return `/* chesstb-theme: ${c.id} */
|
| 95 |
+
cg-board {
|
| 96 |
+
background-image: url("${_checkerboardDataUri(c.lightSquare, c.darkSquare)}");
|
| 97 |
+
/* _checkerboardDataUri() returns a 2x2 tile (2 squares per axis).
|
| 98 |
+
25% 25% sizes it to 2/8 of the board per axis, so the tile repeats
|
| 99 |
+
4x4 (background-repeat's default) to fill all 64 squares; matches
|
| 100 |
+
the base checkerboard sizing in chess/board.css. */
|
| 101 |
+
background-size: 25% 25%;
|
| 102 |
+
}
|
| 103 |
+
${_coordLabelCss(c)}
|
| 104 |
+
cg-board square.last-move { background-color: ${c.highlight} !important; }`;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function _applyCSS() {
|
| 108 |
+
let tag = document.getElementById('chesstb-theme');
|
| 109 |
+
if (!tag) {
|
| 110 |
+
tag = document.createElement('style');
|
| 111 |
+
tag.id = 'chesstb-theme';
|
| 112 |
+
document.head.appendChild(tag);
|
| 113 |
+
}
|
| 114 |
+
tag.textContent = `@layer app {\n${_cssForBoard(_boardColors())}\n}`;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// ── Public API ────────────────────────────────────────────────────────────
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* Register the board reconstruction callback from board.js.
|
| 121 |
+
* Must be called before Board.init().
|
| 122 |
+
*/
|
| 123 |
+
function init(reconstructBoard) {
|
| 124 |
+
_reconstructBoard = reconstructBoard;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/**
|
| 128 |
+
* Inject the board's theme <style> tag and populate the piece-set-dependent
|
| 129 |
+
* tray/promotion images.
|
| 130 |
+
*/
|
| 131 |
+
function apply() {
|
| 132 |
+
_applyCSS();
|
| 133 |
+
if (_reconstructBoard) _reconstructBoard();
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/** Return the active piece set id string. */
|
| 137 |
+
function currentPieceSet() { return PIECE_SET; }
|
| 138 |
+
|
| 139 |
+
// Built once - pieceThemeFn() below always returns this same function,
|
| 140 |
+
// rather than allocating a fresh closure per call.
|
| 141 |
+
function _pieceThemePath(piece) {
|
| 142 |
+
return `/static/pieces/${PIECE_SET}/${piece}.svg`;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
/**
|
| 146 |
+
* Return the piece-code → image-path function for the active piece set
|
| 147 |
+
* (e.g. 'wQ' -> '/static/pieces/cburnett/wQ.svg'). Called by board.js to
|
| 148 |
+
* populate the spare-tray and promotion-dialog images.
|
| 149 |
+
*/
|
| 150 |
+
function pieceThemeFn() {
|
| 151 |
+
return _pieceThemePath;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
export const Theme = {
|
| 155 |
+
init, apply,
|
| 156 |
+
currentPieceSet,
|
| 157 |
+
pieceThemeFn,
|
| 158 |
+
};
|
frontend/src/engine/utils.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* utils.js: Shared helpers
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Returns a debounced version of fn that fires after `delay` ms of inactivity.
|
| 7 |
+
* The returned function also exposes cancel(), so a caller that's about to
|
| 8 |
+
* issue an equivalent call immediately (bypassing the debounce) can drop any
|
| 9 |
+
* pending trailing call instead of leaving it to fire later against a
|
| 10 |
+
* position that's no longer current.
|
| 11 |
+
* @param {Function} fn
|
| 12 |
+
* @param {number} delay
|
| 13 |
+
*/
|
| 14 |
+
export function debounce(fn, delay) {
|
| 15 |
+
let timer;
|
| 16 |
+
const debounced = function (...args) {
|
| 17 |
+
clearTimeout(timer);
|
| 18 |
+
timer = setTimeout(() => fn.apply(this, args), delay);
|
| 19 |
+
};
|
| 20 |
+
debounced.cancel = () => clearTimeout(timer);
|
| 21 |
+
return debounced;
|
| 22 |
+
}
|
frontend/src/hooks/useExplorer.js
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
| 2 |
+
import { Board, VARIANTS } from '../engine/board.js';
|
| 3 |
+
import { Tablebase } from '../engine/tablebase.js';
|
| 4 |
+
import { Theme } from '../engine/theme.js';
|
| 5 |
+
import * as Storage from '../engine/storage.js';
|
| 6 |
+
import { debounce } from '../engine/utils.js';
|
| 7 |
+
import {
|
| 8 |
+
writeHash, readHashFen, readHashVariant, readLastGame, saveGameState, normalizeFen,
|
| 9 |
+
} from '../engine/gameState.js';
|
| 10 |
+
import { buildPgnText, parsePgn } from '../engine/pgnFormat.js';
|
| 11 |
+
|
| 12 |
+
const DEFAULT_FEN = '4k3/8/8/8/8/8/8/4K2R w - - 0 1';
|
| 13 |
+
|
| 14 |
+
const METRIC_CONFIG = {
|
| 15 |
+
dtz: { dataKey: 'moves_dtz', label: 'DTZ' },
|
| 16 |
+
dtc: { dataKey: 'moves_dtc', label: 'DTC' },
|
| 17 |
+
dtm: { dataKey: 'moves_dtm', label: 'DTM' },
|
| 18 |
+
dtm50: { dataKey: 'moves_dtm50', label: 'DTM50' },
|
| 19 |
+
};
|
| 20 |
+
const AUTO_PLAY_METRICS = Object.keys(METRIC_CONFIG);
|
| 21 |
+
|
| 22 |
+
const AUTO_PLAY_DELAY_MIN_MS = 0;
|
| 23 |
+
const AUTO_PLAY_DELAY_MAX_MS = 2500;
|
| 24 |
+
const AUTO_PLAY_DELAY_STEP_MS = 50;
|
| 25 |
+
const AUTO_PLAY_DELAY_DEFAULT_MS = 1250;
|
| 26 |
+
const LS_AUTOPLAY_DELAY_MS = 'chesstb_autoplay_delay_ms';
|
| 27 |
+
|
| 28 |
+
function clampAutoPlayDelayMs(ms) {
|
| 29 |
+
if (!Number.isFinite(ms)) return AUTO_PLAY_DELAY_DEFAULT_MS;
|
| 30 |
+
const snapped = Math.round(ms / AUTO_PLAY_DELAY_STEP_MS) * AUTO_PLAY_DELAY_STEP_MS;
|
| 31 |
+
return Math.min(AUTO_PLAY_DELAY_MAX_MS, Math.max(AUTO_PLAY_DELAY_MIN_MS, snapped));
|
| 32 |
+
}
|
| 33 |
+
function readAutoPlayDelayMs() {
|
| 34 |
+
const raw = Storage.read(LS_AUTOPLAY_DELAY_MS);
|
| 35 |
+
const ms = raw === null ? NaN : parseInt(raw, 10);
|
| 36 |
+
return Number.isFinite(ms) ? clampAutoPlayDelayMs(ms) : AUTO_PLAY_DELAY_DEFAULT_MS;
|
| 37 |
+
}
|
| 38 |
+
export function formatAutoPlayDelay(ms) {
|
| 39 |
+
return ms === 0 ? '0s' : `${parseFloat((ms / 1000).toFixed(2))}s`;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
let _nextToastId = 1;
|
| 43 |
+
const NOTE_MS = 4500;
|
| 44 |
+
|
| 45 |
+
export function useExplorer() {
|
| 46 |
+
const [ready, setReady] = useState(false);
|
| 47 |
+
const [fen, setFen] = useState(DEFAULT_FEN);
|
| 48 |
+
const [variant, setVariant] = useState(VARIANTS.STANDARD);
|
| 49 |
+
const [locked, setLockedState] = useState(false);
|
| 50 |
+
const [canBack, setCanBack] = useState(false);
|
| 51 |
+
const [canForward, setCanForward] = useState(false);
|
| 52 |
+
const [moveHistory, setMoveHistory] = useState({ nodes: [], rootChildren: [], rootActiveChild: null, currentId: -1 });
|
| 53 |
+
const [startFen, setStartFen] = useState(DEFAULT_FEN);
|
| 54 |
+
// The PGN panel stays blank (not even "No moves yet") until Board
|
| 55 |
+
// reports a move-tree change for the first time - a fresh session
|
| 56 |
+
// with nothing restored from a link or localStorage never fires that
|
| 57 |
+
// callback until the player's first action.
|
| 58 |
+
const [historyInitialized, setHistoryInitialized] = useState(false);
|
| 59 |
+
const [fenError, setFenError] = useState(null);
|
| 60 |
+
|
| 61 |
+
const [tb, setTb] = useState(() => Tablebase.getViewState());
|
| 62 |
+
const [autoplayMetric, setAutoplayMetric] = useState(null);
|
| 63 |
+
const [autoplayAvailability, setAutoplayAvailability] = useState(
|
| 64 |
+
() => Object.fromEntries(AUTO_PLAY_METRICS.map(m => [m, { disabled: false, title: `Auto-play best ${METRIC_CONFIG[m].label} move` }]))
|
| 65 |
+
);
|
| 66 |
+
const [lockAutoplayDisabled, setLockAutoplayDisabled] = useState(false);
|
| 67 |
+
|
| 68 |
+
const [showRootRow, setShowRootRowState] = useState(() => Tablebase.getShowRootRow());
|
| 69 |
+
const [autoplayDelayMs, setAutoplayDelayMsState] = useState(() => readAutoPlayDelayMs());
|
| 70 |
+
|
| 71 |
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 72 |
+
const [pgnDialogOpen, setPgnDialogOpen] = useState(false);
|
| 73 |
+
const [pgnError, setPgnError] = useState('');
|
| 74 |
+
const [toasts, setToasts] = useState([]);
|
| 75 |
+
const [activeMetric, setActiveMetric] = useState('dtz');
|
| 76 |
+
|
| 77 |
+
const fenInputRef = useRef(null);
|
| 78 |
+
const tbodyRef = useRef(null);
|
| 79 |
+
const actionsRef = useRef({});
|
| 80 |
+
const bootedRef = useRef(false);
|
| 81 |
+
|
| 82 |
+
// ── Toasts ──────────────────────────────────────────────────────────
|
| 83 |
+
function toast(message, durationMs = 1800) {
|
| 84 |
+
const id = _nextToastId++;
|
| 85 |
+
setToasts(prev => [...prev, { id, message, leaving: false }]);
|
| 86 |
+
setTimeout(() => {
|
| 87 |
+
setToasts(prev => prev.map(t => (t.id === id ? { ...t, leaving: true } : t)));
|
| 88 |
+
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 400);
|
| 89 |
+
}, durationMs);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const activeNotesRef = useRef(new Set());
|
| 93 |
+
|
| 94 |
+
// ── One-time bootstrap ────────────────────────────────────────────────
|
| 95 |
+
useEffect(() => {
|
| 96 |
+
if (bootedRef.current) return;
|
| 97 |
+
bootedRef.current = true;
|
| 98 |
+
|
| 99 |
+
const hashFen = readHashFen();
|
| 100 |
+
const hashVariant = readHashVariant();
|
| 101 |
+
const lastGame = readLastGame();
|
| 102 |
+
|
| 103 |
+
let initialFen, initialVariant, restoreGame;
|
| 104 |
+
if (lastGame && hashFen &&
|
| 105 |
+
normalizeFen(hashFen) === lastGame.currentFen &&
|
| 106 |
+
(hashVariant || VARIANTS.STANDARD) === lastGame.variant) {
|
| 107 |
+
initialFen = normalizeFen(lastGame.startFen);
|
| 108 |
+
initialVariant = lastGame.variant;
|
| 109 |
+
restoreGame = lastGame;
|
| 110 |
+
} else if (hashFen) {
|
| 111 |
+
initialFen = normalizeFen(hashFen);
|
| 112 |
+
initialVariant = hashVariant || VARIANTS.STANDARD;
|
| 113 |
+
restoreGame = null;
|
| 114 |
+
} else if (lastGame) {
|
| 115 |
+
initialFen = normalizeFen(lastGame.startFen);
|
| 116 |
+
initialVariant = lastGame.variant;
|
| 117 |
+
restoreGame = lastGame;
|
| 118 |
+
} else {
|
| 119 |
+
initialFen = DEFAULT_FEN;
|
| 120 |
+
initialVariant = VARIANTS.STANDARD;
|
| 121 |
+
restoreGame = null;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
const debouncedProbe = debounce(Tablebase.probe, 300);
|
| 125 |
+
|
| 126 |
+
// ── Auto-play state (refs - pure bookkeeping, no re-render needed) ──
|
| 127 |
+
const autoPlayTimerRef = { current: null };
|
| 128 |
+
const autoPlayCycleIdRef = { current: 0 };
|
| 129 |
+
const autoPlayPacingReadyRef = { current: false };
|
| 130 |
+
const autoPlayDataReadyRef = { current: false };
|
| 131 |
+
const autoPlayMoveInFlightRef = { current: false };
|
| 132 |
+
const autoPlayLockEngagedRef = { current: false };
|
| 133 |
+
const autoPlayMetricRef = { current: null }; // mirrors autoplayMetric state for synchronous checks
|
| 134 |
+
|
| 135 |
+
function bestMovesForArrows(data) {
|
| 136 |
+
return {
|
| 137 |
+
dtz: data?.dtz_available ? (data.moves_dtz || [])[0] || null : null,
|
| 138 |
+
dtc: data?.dtc_available ? (data.moves_dtc || [])[0] || null : null,
|
| 139 |
+
dtm: data?.dtm_available ? (data.moves_dtm || [])[0] || null : null,
|
| 140 |
+
dtm50: data?.dtm50_available ? (data.moves_dtm50 || [])[0] || null : null,
|
| 141 |
+
};
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
function applySetLocked(next, persist = true) {
|
| 145 |
+
Board.setLocked(next, persist);
|
| 146 |
+
setLockedState(next);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
function stopAutoPlay() {
|
| 150 |
+
const prevMetric = autoPlayMetricRef.current;
|
| 151 |
+
autoPlayMetricRef.current = null;
|
| 152 |
+
setAutoplayMetric(null);
|
| 153 |
+
autoPlayCycleIdRef.current++;
|
| 154 |
+
clearTimeout(autoPlayTimerRef.current);
|
| 155 |
+
autoPlayTimerRef.current = null;
|
| 156 |
+
autoPlayPacingReadyRef.current = false;
|
| 157 |
+
autoPlayDataReadyRef.current = false;
|
| 158 |
+
if (autoPlayLockEngagedRef.current) {
|
| 159 |
+
autoPlayLockEngagedRef.current = false;
|
| 160 |
+
applySetLocked(false, false);
|
| 161 |
+
}
|
| 162 |
+
setLockAutoplayDisabled(false);
|
| 163 |
+
return prevMetric;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function armAutoPlayWait(dataAlreadyReady) {
|
| 167 |
+
const cycleId = ++autoPlayCycleIdRef.current;
|
| 168 |
+
autoPlayPacingReadyRef.current = false;
|
| 169 |
+
autoPlayDataReadyRef.current = dataAlreadyReady;
|
| 170 |
+
clearTimeout(autoPlayTimerRef.current);
|
| 171 |
+
autoPlayTimerRef.current = null;
|
| 172 |
+
if (!dataAlreadyReady) return;
|
| 173 |
+
autoPlayTimerRef.current = setTimeout(() => {
|
| 174 |
+
if (cycleId !== autoPlayCycleIdRef.current) return;
|
| 175 |
+
autoPlayPacingReadyRef.current = true;
|
| 176 |
+
tryPlayAutoPlayMove(cycleId);
|
| 177 |
+
}, actionsRef.current.getAutoplayDelayMs());
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
function tryPlayAutoPlayMove(cycleId) {
|
| 181 |
+
if (cycleId !== autoPlayCycleIdRef.current) return;
|
| 182 |
+
if (!autoPlayMetricRef.current) return;
|
| 183 |
+
if (!autoPlayPacingReadyRef.current || !autoPlayDataReadyRef.current) return;
|
| 184 |
+
|
| 185 |
+
const metric = autoPlayMetricRef.current;
|
| 186 |
+
const data = Tablebase.getLastData();
|
| 187 |
+
if (!data) return;
|
| 188 |
+
const moves = data[METRIC_CONFIG[metric].dataKey] || [];
|
| 189 |
+
const best = moves.find(m => m.outcome !== 'unknown' && m.available !== false);
|
| 190 |
+
if (!best) { stopAutoPlay(); return; }
|
| 191 |
+
autoPlayMoveInFlightRef.current = true;
|
| 192 |
+
const played = Board.playMove(best.san);
|
| 193 |
+
autoPlayMoveInFlightRef.current = false;
|
| 194 |
+
if (!played) { stopAutoPlay(); return; }
|
| 195 |
+
if (best.is_mate || best.outcome === 'draw') { stopAutoPlay(); return; }
|
| 196 |
+
armAutoPlayWait(false);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
function onAutoPlayResult() {
|
| 200 |
+
if (!autoPlayMetricRef.current || autoPlayDataReadyRef.current) return;
|
| 201 |
+
armAutoPlayWait(true);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function startAutoPlay(metric) {
|
| 205 |
+
if (!METRIC_CONFIG[metric]) return;
|
| 206 |
+
stopAutoPlay();
|
| 207 |
+
autoPlayMetricRef.current = metric;
|
| 208 |
+
setAutoplayMetric(metric);
|
| 209 |
+
if (!Board.isLocked()) {
|
| 210 |
+
autoPlayLockEngagedRef.current = true;
|
| 211 |
+
applySetLocked(true, false);
|
| 212 |
+
}
|
| 213 |
+
setLockAutoplayDisabled(true);
|
| 214 |
+
const dataAlreadyReady = Tablebase.getLastFen() === Board.currentFen() &&
|
| 215 |
+
Tablebase.getLastVariant() === Board.getVariant();
|
| 216 |
+
armAutoPlayWait(dataAlreadyReady);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
function updateAutoplayAvailability(data) {
|
| 220 |
+
const availableByMetric = {
|
| 221 |
+
dtz: data?.dtz_available, dtc: data?.dtc_available,
|
| 222 |
+
dtm: data?.dtm_available, dtm50: data?.dtm50_available,
|
| 223 |
+
};
|
| 224 |
+
const rootWdlByMetric = {
|
| 225 |
+
dtz: data?.wdl,
|
| 226 |
+
dtc: Array.isArray(data?.dtc) ? data.dtc[0] : null,
|
| 227 |
+
dtm: data?.wdl,
|
| 228 |
+
dtm50: Array.isArray(data?.dtm50) ? data.dtm50[0] : null,
|
| 229 |
+
};
|
| 230 |
+
const next = {};
|
| 231 |
+
AUTO_PLAY_METRICS.forEach(metric => {
|
| 232 |
+
const unavailable = availableByMetric[metric] === false;
|
| 233 |
+
const isDraw = !unavailable && rootWdlByMetric[metric] === 0;
|
| 234 |
+
if (unavailable || isDraw) {
|
| 235 |
+
if (autoPlayMetricRef.current === metric) stopAutoPlay();
|
| 236 |
+
next[metric] = {
|
| 237 |
+
disabled: true,
|
| 238 |
+
title: unavailable
|
| 239 |
+
? `Auto-play unavailable — no ${METRIC_CONFIG[metric].label} table for this material`
|
| 240 |
+
: `${METRIC_CONFIG[metric].label} auto-play unavailable — position is a draw`,
|
| 241 |
+
};
|
| 242 |
+
} else {
|
| 243 |
+
next[metric] = { disabled: false, title: `Auto-play best ${METRIC_CONFIG[metric].label} move` };
|
| 244 |
+
}
|
| 245 |
+
});
|
| 246 |
+
setAutoplayAvailability(next);
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// ── Position-change callback ────────────────────────────────────
|
| 250 |
+
function onPositionChange(nextFen, immediate) {
|
| 251 |
+
if (autoPlayMetricRef.current && !autoPlayMoveInFlightRef.current) stopAutoPlay();
|
| 252 |
+
|
| 253 |
+
if (fenInputRef.current) {
|
| 254 |
+
fenInputRef.current.value = nextFen;
|
| 255 |
+
fenInputRef.current.classList.remove('is-invalid');
|
| 256 |
+
}
|
| 257 |
+
setFenError(null);
|
| 258 |
+
Tablebase.clearErrorLine();
|
| 259 |
+
setFen(nextFen);
|
| 260 |
+
setVariant(Board.getVariant());
|
| 261 |
+
setCanBack(Board.canGoBack());
|
| 262 |
+
setCanForward(Board.canGoForward());
|
| 263 |
+
writeHash(nextFen, Board.getVariant());
|
| 264 |
+
Board.clearArrows();
|
| 265 |
+
|
| 266 |
+
const count = Board.isInsufficientMaterial() ? 0 : Board.legalMoveCount();
|
| 267 |
+
Tablebase.setMoveCount(count > 0 ? `${count} legal move${count === 1 ? '' : 's'}` : '');
|
| 268 |
+
|
| 269 |
+
const onProbeError = () => setReady(true);
|
| 270 |
+
const activeVariant = Board.getVariant();
|
| 271 |
+
if (immediate) {
|
| 272 |
+
debouncedProbe.cancel();
|
| 273 |
+
Tablebase.probe(nextFen, activeVariant, onProbeError);
|
| 274 |
+
} else {
|
| 275 |
+
debouncedProbe(nextFen, activeVariant, onProbeError);
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
Board.setOnMoveHistoryChange((history, sFen) => {
|
| 280 |
+
setMoveHistory(history);
|
| 281 |
+
setStartFen(sFen);
|
| 282 |
+
setHistoryInitialized(true);
|
| 283 |
+
setCanBack(Board.canGoBack());
|
| 284 |
+
setCanForward(Board.canGoForward());
|
| 285 |
+
saveGameState(history, sFen, Board.getVariant());
|
| 286 |
+
});
|
| 287 |
+
|
| 288 |
+
Tablebase.setResultHandler(data => {
|
| 289 |
+
Board.drawArrows(bestMovesForArrows(data));
|
| 290 |
+
updateAutoplayAvailability(data);
|
| 291 |
+
setReady(true);
|
| 292 |
+
});
|
| 293 |
+
Tablebase.setMoveSelectHandler(san => {
|
| 294 |
+
const played = Board.playMove(san);
|
| 295 |
+
if (!played) toast('Illegal move — this position may have changed.');
|
| 296 |
+
});
|
| 297 |
+
Tablebase.setNoteHandler(message => {
|
| 298 |
+
const active = activeNotesRef.current;
|
| 299 |
+
if (active.has(message)) return;
|
| 300 |
+
active.add(message);
|
| 301 |
+
setTimeout(() => active.delete(message), NOTE_MS + 400);
|
| 302 |
+
toast(message, NOTE_MS);
|
| 303 |
+
});
|
| 304 |
+
Tablebase.setViewStateHandler(setTb);
|
| 305 |
+
|
| 306 |
+
// ── Board bootstrap ──────────────────────────────────────────────
|
| 307 |
+
Theme.init(Board.reconstruct);
|
| 308 |
+
setReady(false);
|
| 309 |
+
const boardInit = Board.init(onPositionChange, initialFen, initialVariant);
|
| 310 |
+
if (!boardInit.ok) toast(boardInit.reason || 'Invalid starting position — loaded the default position instead.');
|
| 311 |
+
if (restoreGame) {
|
| 312 |
+
Board.restoreTree(restoreGame.startFen, restoreGame.nodes, restoreGame.rootChildren, restoreGame.rootActiveChild, restoreGame.currentId);
|
| 313 |
+
}
|
| 314 |
+
Theme.apply();
|
| 315 |
+
|
| 316 |
+
setVariant(Board.getVariant());
|
| 317 |
+
setLockedState(Board.isLocked());
|
| 318 |
+
setFen(Board.currentFen());
|
| 319 |
+
setStartFen(Board.getStartFen());
|
| 320 |
+
setCanBack(Board.canGoBack());
|
| 321 |
+
setCanForward(Board.canGoForward());
|
| 322 |
+
if (fenInputRef.current) fenInputRef.current.value = Board.currentFen();
|
| 323 |
+
|
| 324 |
+
// ── Public actions ───────────────────────────────────────────────
|
| 325 |
+
function applyFen(rawFen) {
|
| 326 |
+
const norm = normalizeFen(rawFen);
|
| 327 |
+
const result = Board.setPosition(norm);
|
| 328 |
+
if (!result.ok) {
|
| 329 |
+
if (fenInputRef.current) fenInputRef.current.classList.add('is-invalid');
|
| 330 |
+
setFenError(result.reason || 'Invalid position.');
|
| 331 |
+
return;
|
| 332 |
+
}
|
| 333 |
+
if (fenInputRef.current) fenInputRef.current.classList.remove('is-invalid');
|
| 334 |
+
setFenError(null);
|
| 335 |
+
Tablebase.clearErrorLine();
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
function setTurn(turnChar) {
|
| 339 |
+
const parts = Board.currentFen().split(' ');
|
| 340 |
+
if (parts[1] === turnChar) return;
|
| 341 |
+
parts[1] = turnChar;
|
| 342 |
+
applyFen(parts.join(' '));
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
function copyFen() {
|
| 346 |
+
navigator.clipboard.writeText(Board.currentFen())
|
| 347 |
+
.then(() => toast('FEN copied!'))
|
| 348 |
+
.catch(() => {
|
| 349 |
+
fenInputRef.current?.select();
|
| 350 |
+
document.execCommand('copy');
|
| 351 |
+
toast('FEN copied!');
|
| 352 |
+
});
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
function exportPgn() {
|
| 356 |
+
const text = buildPgnText();
|
| 357 |
+
if (!text) { toast('Play a move to export a PGN.'); return; }
|
| 358 |
+
const url = URL.createObjectURL(new Blob([text + '\n'], { type: 'application/x-chess-pgn;charset=utf-8' }));
|
| 359 |
+
const link = document.createElement('a');
|
| 360 |
+
link.href = url;
|
| 361 |
+
link.download = 'chesstb_game.pgn';
|
| 362 |
+
document.body.appendChild(link);
|
| 363 |
+
link.click();
|
| 364 |
+
link.remove();
|
| 365 |
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
function flip() {
|
| 369 |
+
Board.flip();
|
| 370 |
+
Board.drawArrows(bestMovesForArrows(Tablebase.getLastData()));
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
function clearBoard() { Board.clear(); }
|
| 374 |
+
function goBack() { Board.goBack(); }
|
| 375 |
+
function goForward() { Board.goForward(); }
|
| 376 |
+
function goToNode(id) { Board.goToNode(id); }
|
| 377 |
+
|
| 378 |
+
function setLockedAction(next) {
|
| 379 |
+
if (autoPlayMetricRef.current !== null) return; // soft-disabled during auto-play
|
| 380 |
+
applySetLocked(next);
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
function toggleAutoPlay(metric) {
|
| 384 |
+
if (autoPlayMetricRef.current === metric) stopAutoPlay();
|
| 385 |
+
else startAutoPlay(metric);
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
function setShowRootRowAction(next) { Tablebase.setShowRootRow(next); setShowRootRowState(next); }
|
| 389 |
+
|
| 390 |
+
function setChess960Action(next) {
|
| 391 |
+
const result = Board.setVariant(next ? VARIANTS.CHESS960 : VARIANTS.STANDARD);
|
| 392 |
+
if (!result.ok) { toast(result.reason || "Can't switch modes for this position.", 3000); return; }
|
| 393 |
+
setVariant(Board.getVariant());
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
function setAutoplayDelayAction(ms) {
|
| 397 |
+
const clamped = clampAutoPlayDelayMs(ms);
|
| 398 |
+
Storage.write(LS_AUTOPLAY_DELAY_MS, clamped);
|
| 399 |
+
setAutoplayDelayMsState(clamped);
|
| 400 |
+
}
|
| 401 |
+
function getAutoplayDelayMs() { return actionsRef.current.autoplayDelayMsLive; }
|
| 402 |
+
|
| 403 |
+
function openPgnDialog() { setPgnDialogOpen(true); setPgnError(''); }
|
| 404 |
+
function closePgnDialog() { setPgnDialogOpen(false); }
|
| 405 |
+
function parsePgnAction(text) {
|
| 406 |
+
const result = parsePgn(text);
|
| 407 |
+
if (!result.ok) { setPgnError(result.error); return; }
|
| 408 |
+
const loaded = Board.importGame(result.tree, result.variant);
|
| 409 |
+
if (!loaded.ok) { setPgnError(loaded.reason); return; }
|
| 410 |
+
setPgnError('');
|
| 411 |
+
closePgnDialog();
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
// Exposed to components via the stable actionsRef object.
|
| 415 |
+
Object.assign(actionsRef.current, {
|
| 416 |
+
applyFen, setTurn, copyFen, exportPgn, flip, clearBoard, goBack, goForward, goToNode,
|
| 417 |
+
setLockedAction, toggleAutoPlay, setShowRootRowAction, setChess960Action,
|
| 418 |
+
setAutoplayDelayAction, getAutoplayDelayMs, onAutoPlayResult, openPgnDialog, closePgnDialog,
|
| 419 |
+
parsePgnAction, toast, setActiveMetric,
|
| 420 |
+
exportCsv: Tablebase.exportCsv,
|
| 421 |
+
autoplayDelayMsLive: readAutoPlayDelayMs(),
|
| 422 |
+
});
|
| 423 |
+
|
| 424 |
+
// Very first probe of the session runs immediately (no debounce).
|
| 425 |
+
onPositionChange(Board.currentFen(), true);
|
| 426 |
+
}, []);
|
| 427 |
+
|
| 428 |
+
// Wait for the results to commit and paint before starting the delay.
|
| 429 |
+
// Two frames also let a zero-delay run show each position's results.
|
| 430 |
+
useEffect(() => {
|
| 431 |
+
if (!autoplayMetric || tb.loading) return;
|
| 432 |
+
let frame = requestAnimationFrame(() => {
|
| 433 |
+
frame = requestAnimationFrame(() => actionsRef.current.onAutoPlayResult());
|
| 434 |
+
});
|
| 435 |
+
return () => cancelAnimationFrame(frame);
|
| 436 |
+
}, [tb, autoplayMetric]);
|
| 437 |
+
|
| 438 |
+
// Keep the ref mirror of autoplayDelayMs current for armAutoPlayWait's
|
| 439 |
+
// synchronous read (it can't see React state directly from inside a
|
| 440 |
+
// closure captured once at bootstrap).
|
| 441 |
+
useEffect(() => {
|
| 442 |
+
actionsRef.current.autoplayDelayMsLive = autoplayDelayMs;
|
| 443 |
+
}, [autoplayDelayMs]);
|
| 444 |
+
|
| 445 |
+
// ── Keyboard shortcuts ───────────���──────────────────────────────────
|
| 446 |
+
useEffect(() => {
|
| 447 |
+
function onKeydown(e) {
|
| 448 |
+
const tag = document.activeElement?.tagName;
|
| 449 |
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
| 450 |
+
if (document.activeElement?.isContentEditable) return;
|
| 451 |
+
if (document.getElementById('promotion-dialog')?.classList.contains('is-visible')) return;
|
| 452 |
+
if (pgnDialogOpen) return;
|
| 453 |
+
if (settingsOpen) return;
|
| 454 |
+
|
| 455 |
+
if (e.key === 'ArrowLeft' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { e.preventDefault(); actionsRef.current.goBack(); }
|
| 456 |
+
if (e.key === 'ArrowRight' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { e.preventDefault(); actionsRef.current.goForward(); }
|
| 457 |
+
if (e.key.toLowerCase() === 'f' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); actionsRef.current.flip(); }
|
| 458 |
+
if (e.key.toLowerCase() === 'c' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); actionsRef.current.clearBoard(); }
|
| 459 |
+
}
|
| 460 |
+
document.addEventListener('keydown', onKeydown);
|
| 461 |
+
return () => document.removeEventListener('keydown', onKeydown);
|
| 462 |
+
}, [pgnDialogOpen, settingsOpen]);
|
| 463 |
+
|
| 464 |
+
// ── hashchange (shared links opened while the tab is open) ───────────
|
| 465 |
+
useEffect(() => {
|
| 466 |
+
function onHashChange() {
|
| 467 |
+
const newFen = readHashFen();
|
| 468 |
+
if (!newFen) return;
|
| 469 |
+
const newVariant = readHashVariant() || VARIANTS.STANDARD;
|
| 470 |
+
const result = Board.setPositionAndVariant(normalizeFen(newFen), newVariant);
|
| 471 |
+
if (!result.ok) {
|
| 472 |
+
actionsRef.current.toast(result.reason || 'Invalid position in URL.');
|
| 473 |
+
writeHash(Board.currentFen(), Board.getVariant());
|
| 474 |
+
}
|
| 475 |
+
}
|
| 476 |
+
window.addEventListener('hashchange', onHashChange);
|
| 477 |
+
return () => window.removeEventListener('hashchange', onHashChange);
|
| 478 |
+
}, []);
|
| 479 |
+
|
| 480 |
+
const actions = useMemo(() => new Proxy({}, {
|
| 481 |
+
get: (_t, prop) => (...args) => actionsRef.current[prop]?.(...args),
|
| 482 |
+
}), []);
|
| 483 |
+
|
| 484 |
+
return {
|
| 485 |
+
ready, fen, variant, locked, canBack, canForward, moveHistory, startFen, historyInitialized, fenError,
|
| 486 |
+
tb, autoplayMetric, autoplayAvailability, lockAutoplayDisabled,
|
| 487 |
+
showRootRow, autoplayDelayMs, settingsOpen, setSettingsOpen,
|
| 488 |
+
pgnDialogOpen, pgnError, toasts, activeMetric,
|
| 489 |
+
fenInputRef, tbodyRef, actions,
|
| 490 |
+
};
|
| 491 |
+
}
|
frontend/src/main.jsx
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import './styles/index.css';
|
| 2 |
+
import { createRoot } from 'react-dom/client';
|
| 3 |
+
import App from './App.jsx';
|
| 4 |
+
|
| 5 |
+
const rootEl = document.getElementById('root');
|
| 6 |
+
const sourceRepositoryUrl = rootEl?.dataset.sourceRepositoryUrl || '';
|
| 7 |
+
|
| 8 |
+
createRoot(rootEl).render(<App sourceRepositoryUrl={sourceRepositoryUrl} />);
|
frontend/src/styles/admin-page.css
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ── Admin page ────────────────────────────────────────────────────────
|
| 2 |
+
The dashboard is an ordinary scrolling document. Selectors are scoped
|
| 3 |
+
under `.admin-page` on the page's <body>. */
|
| 4 |
+
|
| 5 |
+
body.admin-page {
|
| 6 |
+
height: auto;
|
| 7 |
+
min-height: 100vh;
|
| 8 |
+
overflow: auto;
|
| 9 |
+
padding: var(--space-6);
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.admin-page h1 {
|
| 13 |
+
font-size: var(--text-xl);
|
| 14 |
+
color: var(--text);
|
| 15 |
+
margin-bottom: var(--space-5);
|
| 16 |
+
display: flex;
|
| 17 |
+
align-items: center;
|
| 18 |
+
gap: 10px;
|
| 19 |
+
}
|
| 20 |
+
/* Colour and hover come from the global `a` rule in base.css. Targeted by
|
| 21 |
+
class rather than `h1 a` because h1 may also hold the GitHub icon
|
| 22 |
+
button (site-header__link--icon). */
|
| 23 |
+
.admin-page__back-link { font-size: var(--text-sm); margin-left: auto; }
|
| 24 |
+
|
| 25 |
+
/* Card-section labels, sized to match the app's other panel-title rows
|
| 26 |
+
(`.pgn-panel-title`, `.panel-header__title`) rather than a one-off
|
| 27 |
+
value. */
|
| 28 |
+
.admin-page h2 {
|
| 29 |
+
font-size: var(--text-xs);
|
| 30 |
+
font-weight: 700;
|
| 31 |
+
text-transform: uppercase;
|
| 32 |
+
letter-spacing: 0.08em;
|
| 33 |
+
color: var(--text-muted);
|
| 34 |
+
margin-bottom: var(--space-3);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.admin-page .grid {
|
| 38 |
+
display: grid;
|
| 39 |
+
grid-template-columns: 1fr 1fr;
|
| 40 |
+
gap: var(--space-4);
|
| 41 |
+
margin-bottom: var(--space-4);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/* Same background/border/radius recipe as `.pgn-panel` / `.results-panel`. */
|
| 45 |
+
.admin-page .card {
|
| 46 |
+
background: var(--surface);
|
| 47 |
+
border: 1px solid var(--border);
|
| 48 |
+
border-radius: var(--r-lg);
|
| 49 |
+
padding: 20px;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.admin-page .stat {
|
| 53 |
+
display: flex;
|
| 54 |
+
justify-content: space-between;
|
| 55 |
+
padding: 6px 0;
|
| 56 |
+
border-bottom: 1px solid var(--border);
|
| 57 |
+
font-size: var(--text-sm);
|
| 58 |
+
}
|
| 59 |
+
.admin-page .stat:last-child { border-bottom: none; }
|
| 60 |
+
.admin-page .stat .label { color: var(--text-dim); }
|
| 61 |
+
.admin-page .stat .value {
|
| 62 |
+
color: var(--text);
|
| 63 |
+
font-variant-numeric: tabular-nums;
|
| 64 |
+
font-family: var(--font-mono);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.admin-page .bar-wrap {
|
| 68 |
+
height: 8px;
|
| 69 |
+
background: var(--border);
|
| 70 |
+
border-radius: 4px;
|
| 71 |
+
overflow: hidden;
|
| 72 |
+
margin: var(--space-2) 0 var(--space-3);
|
| 73 |
+
}
|
| 74 |
+
/* Same gradient as the results panel's `.probe-progress__fill`. */
|
| 75 |
+
.admin-page .bar-fill {
|
| 76 |
+
height: 100%;
|
| 77 |
+
background: linear-gradient(90deg, var(--progress-from), var(--progress-to));
|
| 78 |
+
border-radius: 4px;
|
| 79 |
+
transition: width 0.4s;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.admin-page .toolbar-row {
|
| 83 |
+
display: flex;
|
| 84 |
+
gap: var(--space-2);
|
| 85 |
+
margin-bottom: 20px;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/* Destructive-action modifier for the shared `.btn` component (the
|
| 89 |
+
Refresh button next to it needs no modifier - plain `.btn` already
|
| 90 |
+
matches). Follows the same pattern as `.btn--apply`'s gold accent,
|
| 91 |
+
using the `--loss-glow` token for the hover fill. */
|
| 92 |
+
.admin-page .btn.danger { border-color: var(--loss); color: var(--loss); }
|
| 93 |
+
.admin-page .btn.danger:hover { background: var(--loss-glow); border-color: var(--loss); }
|
| 94 |
+
|
| 95 |
+
.admin-page .status {
|
| 96 |
+
font-size: var(--text-xs);
|
| 97 |
+
color: var(--text-muted);
|
| 98 |
+
margin-top: 10px;
|
| 99 |
+
min-height: 18px;
|
| 100 |
+
}
|
| 101 |
+
.admin-page .status.ok { color: var(--win); }
|
| 102 |
+
.admin-page .status.err { color: var(--loss); }
|
| 103 |
+
|
| 104 |
+
.admin-page .refresh-row {
|
| 105 |
+
display: flex;
|
| 106 |
+
align-items: center;
|
| 107 |
+
gap: var(--space-3);
|
| 108 |
+
margin-bottom: 20px;
|
| 109 |
+
font-size: var(--text-xs);
|
| 110 |
+
color: var(--text-muted);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/* Login card for GET /admin without a valid session. Centered fixed-width
|
| 114 |
+
panel; reuses `.card`'s background, border and radius. */
|
| 115 |
+
.admin-page .login-card {
|
| 116 |
+
max-width: 320px;
|
| 117 |
+
margin: 15vh auto 0;
|
| 118 |
+
background: var(--surface);
|
| 119 |
+
border: 1px solid var(--border);
|
| 120 |
+
border-radius: var(--r-lg);
|
| 121 |
+
padding: 28px;
|
| 122 |
+
text-align: center;
|
| 123 |
+
}
|
| 124 |
+
.admin-page .login-card h1 { justify-content: center; margin-bottom: var(--space-2); }
|
| 125 |
+
.admin-page .login-card__hint {
|
| 126 |
+
font-size: var(--text-xs);
|
| 127 |
+
color: var(--text-muted);
|
| 128 |
+
margin-bottom: var(--space-4);
|
| 129 |
+
}
|
| 130 |
+
.admin-page .login-card__back { display: block; margin-top: var(--space-4); font-size: var(--text-sm); }
|
| 131 |
+
|
| 132 |
+
/* Same recipe as FenBar.jsx's `.fen-input`, minus the icon padding;
|
| 133 |
+
this field has no leading icon. */
|
| 134 |
+
.admin-page .login-input {
|
| 135 |
+
width: 100%;
|
| 136 |
+
height: 34px;
|
| 137 |
+
padding: 0 10px;
|
| 138 |
+
margin-bottom: var(--space-3);
|
| 139 |
+
font-family: var(--font-mono);
|
| 140 |
+
font-size: var(--text-xs);
|
| 141 |
+
color: var(--text);
|
| 142 |
+
background: var(--surface-2);
|
| 143 |
+
border: 1px solid var(--border);
|
| 144 |
+
border-radius: var(--r-md);
|
| 145 |
+
outline: none;
|
| 146 |
+
text-align: center;
|
| 147 |
+
transition: border-color var(--tr), box-shadow var(--tr);
|
| 148 |
+
}
|
| 149 |
+
.admin-page .login-input:focus { border-color: var(--gold-dim); box-shadow: 0 0 0 2px var(--gold-glow); }
|
| 150 |
+
|
| 151 |
+
@media (max-width: 640px) {
|
| 152 |
+
.admin-page .grid { grid-template-columns: 1fr; }
|
| 153 |
+
}
|
| 154 |
+
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
| 155 |
+
|
| 156 |
+
@media (hover: none) and (pointer: coarse) {
|
| 157 |
+
.admin-page .login-input { font-size: var(--text-lg); }
|
| 158 |
+
}
|
frontend/src/styles/admin.css
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@layer reset, vendor, app;
|
| 2 |
+
@import "./reset.css" layer(reset);
|
| 3 |
+
@import "./fonts.css";
|
| 4 |
+
@import "./tokens.css" layer(app);
|
| 5 |
+
@import "./base.css" layer(app);
|
| 6 |
+
@import "./components/button.css" layer(app);
|
| 7 |
+
@import "./components/header.css" layer(app);
|
| 8 |
+
@import "./admin-page.css" layer(app);
|
frontend/src/styles/base.css
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Reading-text baseline: the browser's default font size. Layout sizing
|
| 2 |
+
(e.g. --board-size above) responds to the viewport separately. */
|
| 3 |
+
html { font-size: 100%; }
|
| 4 |
+
|
| 5 |
+
/* Body fills the viewport height at minimum and scrolls if its content
|
| 6 |
+
ever needs more room than that. */
|
| 7 |
+
html, body { height: 100%; }
|
| 8 |
+
body {
|
| 9 |
+
min-height: 100%;
|
| 10 |
+
overflow-y: auto;
|
| 11 |
+
overflow-x: hidden;
|
| 12 |
+
font-family: var(--font-ui);
|
| 13 |
+
font-size: var(--text-lg);
|
| 14 |
+
line-height: 1.5;
|
| 15 |
+
background: var(--bg);
|
| 16 |
+
color: var(--text);
|
| 17 |
+
-webkit-font-smoothing: antialiased;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
button { cursor: pointer; font: inherit; touch-action: manipulation; }
|
| 21 |
+
a { color: var(--text-dim); text-decoration: none; }
|
| 22 |
+
a:hover { color: var(--text); }
|
| 23 |
+
:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
|
| 24 |
+
|
| 25 |
+
/* Suppress the tap flash Android/iOS draw on touch, and the ~300ms
|
| 26 |
+
delay before a tap registers as a click, on every interactive
|
| 27 |
+
control. touch-action: manipulation still allows scrolling - only
|
| 28 |
+
double-tap-to-zoom is disabled. */
|
| 29 |
+
button, .btn, a { -webkit-tap-highlight-color: transparent; }
|
| 30 |
+
|
| 31 |
+
/* ── Screen-reader utility ──────────────────────────────────────────── */
|
| 32 |
+
.sr-only {
|
| 33 |
+
position: absolute; width: 0.0625rem; height: 0.0625rem;
|
| 34 |
+
padding: 0; margin: -0.0625rem; overflow: hidden;
|
| 35 |
+
clip: rect(0,0,0,0); white-space: nowrap; border: 0;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* ── Scrollbars ─────────────────────────────────────────────────────── */
|
| 39 |
+
* { scrollbar-width: thin; scrollbar-color: var(--border-2) transparent; }
|
| 40 |
+
::-webkit-scrollbar { width: 0.375rem; height: 0.375rem; }
|
| 41 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 42 |
+
::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 0.1875rem; }
|
| 43 |
+
|
| 44 |
+
.icon { width: 1rem; height: 1rem; flex-shrink: 0; }
|
frontend/src/styles/chess/board.css
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ── Board group ────────────────────────────────────────────────────── */
|
| 2 |
+
/* Keep the side-to-move toggle and board directly below the FEN controls. */
|
| 3 |
+
.board-group {
|
| 4 |
+
margin-top: 0;
|
| 5 |
+
width: var(--board-size);
|
| 6 |
+
flex-shrink: 0;
|
| 7 |
+
display: flex;
|
| 8 |
+
flex-direction: column;
|
| 9 |
+
gap: 0.375rem;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
/* ── Board wrap ─────────────────────────────────────────────────────── */
|
| 13 |
+
.board-wrap {
|
| 14 |
+
border-radius: var(--r-md);
|
| 15 |
+
overflow: hidden;
|
| 16 |
+
box-shadow: var(--shadow-board);
|
| 17 |
+
width: var(--board-size);
|
| 18 |
+
flex-shrink: 0;
|
| 19 |
+
}
|
| 20 |
+
.board-wrap-inner {
|
| 21 |
+
position: relative;
|
| 22 |
+
width: var(--board-size);
|
| 23 |
+
display: flex;
|
| 24 |
+
flex-direction: column;
|
| 25 |
+
}
|
| 26 |
+
/* Set by board.js's _syncTrayOrientation() to mirror chessground's own
|
| 27 |
+
orientation. column-reverse visually swaps the black/white trays around
|
| 28 |
+
#board without moving them in the DOM. */
|
| 29 |
+
.board-wrap-inner.orientation-black {
|
| 30 |
+
flex-direction: column-reverse;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/* ── Chessground sizing ─────────────────────────────────────────────── */
|
| 34 |
+
/* Chessground's own CSS (chessground.base.css) sizes cg-wrap/cg-board/
|
| 35 |
+
piece/square as percentages of their container. #board is the
|
| 36 |
+
percentage-sizing root; height is set explicitly here since chessground
|
| 37 |
+
doesn't compute it from width in JS. Both dimensions come from the
|
| 38 |
+
shared --board-size custom property (see tokens.css), so the board resizes
|
| 39 |
+
together with .left-col/.board-wrap/.board-wrap-inner/.spare-tray
|
| 40 |
+
without drifting out of alignment with them.
|
| 41 |
+
|
| 42 |
+
Chessground has its own rounding step one DOM layer deeper: render.ts's
|
| 43 |
+
updateBounds() reads #board's rendered width, then deliberately snaps
|
| 44 |
+
cg-container's width down to a whole number of device pixels evenly
|
| 45 |
+
divisible by 8 (so all 8 squares render crisply, with no anti-aliased
|
| 46 |
+
sub-pixel square boundaries),
|
| 47 |
+
`Math.floor((bounds.width * devicePixelRatio) / 8) * 8 / devicePixelRatio`,
|
| 48 |
+
and sets that as an inline style on cg-container. At integer device
|
| 49 |
+
pixel ratios (1x, 2x) this is a no-op whenever #board's current rendered
|
| 50 |
+
width happens to divide evenly by 8, but at fractional ratios (1.5x is
|
| 51 |
+
common on Windows displays) it usually doesn't: at --board-size's
|
| 52 |
+
ceiling (31.5rem = 504px), 504 * 1.5 = 756, 756 / 8 = 94.5,
|
| 53 |
+
floor'd to 94 * 8 = 752, back to 752 / 1.5 ≈ 501.3px - about 2.7px short
|
| 54 |
+
of #board's actual width on the right/bottom edge. Squares/pieces are
|
| 55 |
+
plain CSS percentages with no JS-computed inline style of their own, so
|
| 56 |
+
pinning cg-container's own width and height with !important (overriding
|
| 57 |
+
chessground's inline style) to the same --board-size as #board is
|
| 58 |
+
enough to keep the whole board exact and stable regardless of device
|
| 59 |
+
pixel ratio or the current viewport size. */
|
| 60 |
+
#board {
|
| 61 |
+
width: var(--board-size);
|
| 62 |
+
height: var(--board-size);
|
| 63 |
+
}
|
| 64 |
+
cg-container, cg-board {
|
| 65 |
+
width: var(--board-size) !important;
|
| 66 |
+
height: var(--board-size) !important;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/* ── Board square colours - Lichess brown ───────────────────────────── */
|
| 70 |
+
/* Fallback board colours. theme.js overrides these values after the board
|
| 71 |
+
is initialized, while the defaults prevent an unthemed initial render. */
|
| 72 |
+
cg-board {
|
| 73 |
+
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyJyBoZWlnaHQ9JzInPgo8cmVjdCB3aWR0aD0nMScgaGVpZ2h0PScxJyBmaWxsPScjZjBkOWI1Jy8+CjxyZWN0IHg9JzEnIHdpZHRoPScxJyBoZWlnaHQ9JzEnIGZpbGw9JyNiNTg4NjMnLz4KPHJlY3QgeT0nMScgd2lkdGg9JzEnIGhlaWdodD0nMScgZmlsbD0nI2I1ODg2MycvPgo8cmVjdCB4PScxJyB5PScxJyB3aWR0aD0nMScgaGVpZ2h0PScxJyBmaWxsPScjZjBkOWI1Jy8+Cjwvc3ZnPg==");
|
| 74 |
+
/* The SVG above is a 2x2 tile (2 squares per axis). 25% 25% sizes it
|
| 75 |
+
to 2/8 of the board per axis, so the tile repeats 4x4
|
| 76 |
+
(background-repeat's default) to fill all 64 squares. */
|
| 77 |
+
background-size: 25% 25%;
|
| 78 |
+
}
|
| 79 |
+
/* ── Rank/file coordinate labels - inset onto board edges ─────────── */
|
| 80 |
+
/* board.js sets coordinatesOnSquares: false, which uses chessground's
|
| 81 |
+
native coords.ranks/coords.files strips - one rank-only label per row,
|
| 82 |
+
one file-only label per column - rather than a combined "a8"-style
|
| 83 |
+
label rendered on all 64 squares. Chessground positions these outside
|
| 84 |
+
the board by default (which .board-wrap's overflow:hidden would clip
|
| 85 |
+
anyway), so they're repositioned here to sit inset at the board's own
|
| 86 |
+
edges, at a small (0.125rem) corner inset.
|
| 87 |
+
|
| 88 |
+
Colour is not done via renderCoords()'s own .coord-light/.coord-dark
|
| 89 |
+
classes (chessground's source, wrap.ts): those classes don't
|
| 90 |
+
correspond to the actual square each label overlays once repositioned
|
| 91 |
+
this way, and chessground's own official theme CSS
|
| 92 |
+
(chessground.brown.css) doesn't use them for coords.ranks/coords.files
|
| 93 |
+
either - it uses structural :nth-child selectors scoped by an
|
| 94 |
+
.orientation-white/.orientation-black class on the wrap element
|
| 95 |
+
instead (that class exists for lichess's own outside-the-board label
|
| 96 |
+
rhythm, not square-accurate contrast, so its odd/even color pairing
|
| 97 |
+
isn't reusable here - only the selector structure is). ranks/files are fixed
|
| 98 |
+
DOM arrays ['1'..'8']/['a'..'h'] (types.ts), so :nth-child parity is
|
| 99 |
+
deterministic; the odd/even -> colour mapping below follows the board
|
| 100 |
+
square colours (a1 dark, a8 light, etc.) for each orientation rather than
|
| 101 |
+
chessground's coordinate colour convention. */
|
| 102 |
+
.orientation-white coords.ranks coord:nth-child(odd),
|
| 103 |
+
.orientation-white coords.files coord:nth-child(odd) {
|
| 104 |
+
color: var(--board-light) !important; /* light text - these sit on dark squares */
|
| 105 |
+
}
|
| 106 |
+
.orientation-white coords.ranks coord:nth-child(even),
|
| 107 |
+
.orientation-white coords.files coord:nth-child(even) {
|
| 108 |
+
color: var(--board-dark) !important; /* dark text - these sit on light squares */
|
| 109 |
+
}
|
| 110 |
+
.orientation-black coords.ranks coord:nth-child(odd),
|
| 111 |
+
.orientation-black coords.files coord:nth-child(odd) {
|
| 112 |
+
color: var(--board-dark) !important; /* flipped board: parity-to-square-colour swaps */
|
| 113 |
+
}
|
| 114 |
+
.orientation-black coords.ranks coord:nth-child(even),
|
| 115 |
+
.orientation-black coords.files coord:nth-child(even) {
|
| 116 |
+
color: var(--board-light) !important;
|
| 117 |
+
}
|
| 118 |
+
coords.ranks coord, coords.files coord {
|
| 119 |
+
font-size: max(0.5625rem, calc(var(--board-size) / 56)) !important;
|
| 120 |
+
font-weight: 600 !important;
|
| 121 |
+
font-family: var(--font-ui) !important;
|
| 122 |
+
}
|
| 123 |
+
.cg-wrap coords.ranks {
|
| 124 |
+
left: 0 !important;
|
| 125 |
+
top: 0 !important;
|
| 126 |
+
width: max(0.875rem, calc(var(--board-size) / 38)) !important;
|
| 127 |
+
height: 100% !important;
|
| 128 |
+
padding-left: 0.125rem !important; /* safe on this axis: one 0.875rem-wide column, not tiled horizontally */
|
| 129 |
+
opacity: 1 !important;
|
| 130 |
+
}
|
| 131 |
+
.cg-wrap coords.ranks coord {
|
| 132 |
+
transform: none !important;
|
| 133 |
+
/* Chessground's own coord rule is `flex: 1 1 auto` - each cell's
|
| 134 |
+
basis comes from its own glyph's content width/height, so 8 cells
|
| 135 |
+
sharing the board's row/column don't land on exact one-square (1/8)
|
| 136 |
+
multiples; the shortfall accumulates top-to-bottom (or left-to-right
|
| 137 |
+
for files), and since labels are aligned within their own cell, that
|
| 138 |
+
accumulating error reads as the label drifting further from the
|
| 139 |
+
square's true edge the further along the row/column it is (barely
|
| 140 |
+
visible at '1'/'2', a couple of px off by '8'). flex:0 0 12.5% pins
|
| 141 |
+
every cell to exactly one square-width (12.5% of the board)
|
| 142 |
+
regardless of its glyph's own size, eliminating that drift.
|
| 143 |
+
The corner inset itself is deliberately per-cell padding (below),
|
| 144 |
+
not container padding: padding on the container would shrink the
|
| 145 |
+
width available for the 8 tiled cells, so flex:0 0 12.5% would land
|
| 146 |
+
on smaller, still-uneven cells instead of a true one-eighth each. */
|
| 147 |
+
flex: 0 0 12.5% !important;
|
| 148 |
+
padding-top: 0.125rem !important;
|
| 149 |
+
}
|
| 150 |
+
.cg-wrap coords.files {
|
| 151 |
+
left: 0 !important;
|
| 152 |
+
bottom: 0 !important;
|
| 153 |
+
width: 100% !important;
|
| 154 |
+
height: max(0.875rem, calc(var(--board-size) / 38)) !important;
|
| 155 |
+
text-align: right !important;
|
| 156 |
+
text-transform: none !important;
|
| 157 |
+
/* align-items:flex-end, not padding-bottom on this container: files is
|
| 158 |
+
a row-direction flex container, so vertical is its cross axis, and
|
| 159 |
+
container padding on the cross axis doesn't push each cell's own
|
| 160 |
+
content toward the bottom the way main-axis padding does.
|
| 161 |
+
flex-end makes each cell
|
| 162 |
+
shrink-to-content and sit at the bottom of the row; the actual
|
| 163 |
+
corner inset is per-cell padding-bottom below, mirroring how
|
| 164 |
+
coords.ranks coord's padding-top is per-cell (its main axis, where
|
| 165 |
+
per-item padding works predictably). */
|
| 166 |
+
align-items: flex-end !important;
|
| 167 |
+
opacity: 1 !important;
|
| 168 |
+
}
|
| 169 |
+
.cg-wrap coords.files coord {
|
| 170 |
+
flex: 0 0 12.5% !important; /* see the fuller comment on coords.ranks coord above */
|
| 171 |
+
padding-right: 0.125rem !important;
|
| 172 |
+
padding-bottom: 0.125rem !important;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/* ── Last-move highlight ──────────────────────────────────────────────── */
|
| 176 |
+
cg-board square.last-move {
|
| 177 |
+
background-color: var(--board-highlight);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
/* ── Check / checkmate halo ──────────────────────────────────────────── */
|
| 181 |
+
cg-board square.check {
|
| 182 |
+
background-image: radial-gradient(
|
| 183 |
+
ellipse at center,
|
| 184 |
+
rgba(255, 0, 0, 0.90) 0%,
|
| 185 |
+
rgba(231, 0, 0, 0.70) 25%,
|
| 186 |
+
rgba(169, 0, 0, 0.00) 89%,
|
| 187 |
+
rgba(158, 0, 0, 0.00) 100%
|
| 188 |
+
);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/* ── Spare-piece trays ───────────────────────────────��───────────── */
|
| 192 |
+
.spare-tray {
|
| 193 |
+
background-color: var(--tray-bg);
|
| 194 |
+
border: none;
|
| 195 |
+
padding: 0.3125rem 0;
|
| 196 |
+
margin: 0;
|
| 197 |
+
display: flex;
|
| 198 |
+
justify-content: center;
|
| 199 |
+
gap: 0;
|
| 200 |
+
width: var(--board-size);
|
| 201 |
+
box-sizing: border-box;
|
| 202 |
+
}
|
| 203 |
+
/* Each tray's border sits on whichever edge faces the board. By default
|
| 204 |
+
(white orientation) that's the black tray's bottom edge and the white
|
| 205 |
+
tray's top edge. .board-wrap-inner.orientation-black (set by board.js's
|
| 206 |
+
_syncTrayOrientation() to mirror chessground's orientation) reverses
|
| 207 |
+
the trays' stacking order via flex-direction: column-reverse, so the
|
| 208 |
+
rules below mirror which edge faces the board in that orientation. */
|
| 209 |
+
.spare-tray--black { border-bottom: 2px solid var(--tray-border); }
|
| 210 |
+
.spare-tray--white { border-top: 2px solid var(--tray-border); }
|
| 211 |
+
.board-wrap-inner.orientation-black .spare-tray--black {
|
| 212 |
+
border-bottom: none;
|
| 213 |
+
border-top: 2px solid var(--tray-border);
|
| 214 |
+
}
|
| 215 |
+
.board-wrap-inner.orientation-black .spare-tray--white {
|
| 216 |
+
border-top: none;
|
| 217 |
+
border-bottom: 2px solid var(--tray-border);
|
| 218 |
+
}
|
| 219 |
+
.spare-piece {
|
| 220 |
+
width: var(--spare-size);
|
| 221 |
+
height: var(--spare-size);
|
| 222 |
+
border: 0;
|
| 223 |
+
padding: 0;
|
| 224 |
+
margin: 0;
|
| 225 |
+
background: none;
|
| 226 |
+
cursor: grab;
|
| 227 |
+
}
|
| 228 |
+
.spare-piece img {
|
| 229 |
+
width: 100%;
|
| 230 |
+
height: 100%;
|
| 231 |
+
pointer-events: none;
|
| 232 |
+
}
|
| 233 |
+
.spare-piece.cp-selected {
|
| 234 |
+
outline: 2px solid var(--gold);
|
| 235 |
+
outline-offset: 1px;
|
| 236 |
+
border-radius: 0.125rem;
|
| 237 |
+
}
|
| 238 |
+
/* Crosshair cursor on the board while a spare piece is armed for placement */
|
| 239 |
+
#board.cp-active { cursor: crosshair; }
|
| 240 |
+
#board.cp-active square { cursor: crosshair; }
|
| 241 |
+
|
| 242 |
+
/* Drag ghost and arrow overlay: no CSS needed here - both are native
|
| 243 |
+
chessground features (draggable.showGhost / drawable.autoShapes),
|
| 244 |
+
styled via chessground.base.css + the drawable.brushes config in
|
| 245 |
+
board.js. */
|