feat: move engine fully client-side; serve as static; add save/export

Gameplay (legal moves, move application, pressure maps, PGN parsing) now runs
entirely in the browser via a chess.js port of engine.py (static/engine.js +
vendored chess.min.js). The app no longer calls the server during play, so it
stays instant and works even when the backend is scaled to zero or offline.

- engine.js: line-for-line port of engine.py; parity-verified identical output
  (pressure, FEN, status, SAN/UCI) across all 10 built-in games / 829 frames.
- app.js: replace all /api fetches with local engine calls; graceful PGN parse
  errors; asset paths moved to root for static serving.
- Save games to localStorage + load from the game dropdown; export any game as
  a .pgn file. Minimal additive UI in the existing export row.
- games.json generated from games.py (scripts/export_games.py).
- Production Dockerfile -> static-web-server (~9MB image, was ~150MB python).
- make dev -> bun static server; add make games / make parity targets.
- app.py kept as optional fallback (serves static app + /api routes).
- parity/: automated JS-vs-python engine parity test.

git diff stat:

 16 files changed, 722 insertions(+), 128 deletions(-)
This commit is contained in:
Michael Pilosov 2026-06-25 19:41:55 +00:00
parent 94e890f3ca
commit ba1d993676
16 changed files with 723 additions and 129 deletions

7
.gitignore vendored
View File

@ -48,3 +48,10 @@ htmlcov/
# mypy
.mypy_cache/
# Node / parity test
node_modules/
parity/.py_frames.json
parity/.dumperr
bun.lock
bun.lockb

View File

@ -1,15 +1,13 @@
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
# Fully client-side app — production is just static files served by a tiny,
# fast-booting static-web-server (Rust). No Python in the runtime image.
FROM ghcr.io/static-web-server/static-web-server:2
WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
COPY src/ src/
RUN uv sync --no-dev --locked --no-editable
COPY src/chess_pressure/static/ /public/
FROM python:3.13-slim-bookworm
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
ENV SERVER_HOST=0.0.0.0 \
SERVER_PORT=8888 \
SERVER_ROOT=/public \
SERVER_FALLBACK_PAGE=/public/index.html \
SERVER_COMPRESSION=true
EXPOSE 8888
CMD ["chess-pressure"]

View File

@ -1,13 +1,23 @@
.PHONY: dev serve lint fmt build publish fly-install deploy logs status help
.PHONY: dev serve games parity lint fmt build publish fly-install deploy logs status help
# Development server with auto-reload
# Static dev server (client-side app; refresh picks up edits)
dev:
uv run uvicorn chess_pressure.app:app --host 0.0.0.0 --port 8888 --reload
bun run dev-server.js
# Production server
# Optional Python fallback server (also serves the static app + /api routes)
serve:
uv run chess-pressure
# Regenerate static/games.json from games.py (run after editing games)
games:
uv run python scripts/export_games.py
# Verify the JS engine matches python-chess across every built-in game
parity:
cd parity && bun install --silent
uv run python parity/dump_python.py > parity/.py_frames.json
cd parity && bun run check.js
# Lint
lint: fmt
uvx ruff check src/chess_pressure/
@ -45,8 +55,10 @@ status:
help:
@echo "chess-pressure"
@echo ""
@echo " make dev dev server with reload (:8888)"
@echo " make serve production server (:8888)"
@echo " make dev static dev server (:8888)"
@echo " make serve optional Python fallback server (:8888)"
@echo " make games regenerate static/games.json from games.py"
@echo " make parity verify JS engine matches python-chess"
@echo " make lint ruff format + check"
@echo " make fmt ruff format + auto-fix"
@echo " make build build sdist + wheel"

View File

@ -14,7 +14,23 @@ For each square on the board, pressure is the net number of pieces attacking it.
- Step through famous built-in games move by move and watch the pressure heatmap evolve.
- Upload your own PGN to analyze any game.
- Make moves from any position and see legal moves + pressure updates in real time.
- FastAPI backend with a lightweight static frontend.
- Save games to your browser and re-load them later; export any game as a PGN file.
- **Fully client-side gameplay** — legal moves, move application, and pressure are all
computed in the browser (via [chess.js](https://github.com/jhlywa/chess.js)), so the
app keeps working instantly even when the server is asleep or offline. Production is
served as plain static files (no backend in the request path).
## Architecture
The chess logic exists in two equivalent implementations:
- `engine.py` (python-chess) — the original engine; also the parity reference.
- `static/engine.js` (chess.js) — a line-for-line port that runs in the browser.
`make parity` verifies the two produce **identical** output (pressure maps, FENs,
board status, and SAN/UCI) across every built-in game, so the client engine can be
trusted as a drop-in. Production deploys only the static assets; the FastAPI server
(`app.py`, with the `/api` routes below) is kept as an optional local fallback.
## Built-in Games
@ -34,12 +50,28 @@ For each square on the board, pressure is the net number of pieces attacking it.
## Quickstart
```bash
uv run chess-pressure
make dev # static dev server on http://localhost:8888 (bun)
```
The app starts on [http://localhost:8888](http://localhost:8888).
Other useful targets:
## API
```bash
make serve # optional Python fallback server (also serves the static app)
make games # regenerate static/games.json after editing games.py
make parity # verify the JS engine matches python-chess
make deploy # deploy the static image to Fly.io
```
## Deployment
Production is a ~9 MB image built on
[static-web-server](https://static-web-server.net/) (see `Dockerfile`) — it serves
`static/` and nothing else, so it boots fast and scales to zero cleanly. The only
cold-start cost is the initial page load; all gameplay afterwards is local.
## API (optional Python server)
The static app does not use these, but `app.py` still exposes them:
| Method | Endpoint | Description |
|--------|----------|-------------|
@ -54,10 +86,17 @@ The app starts on [http://localhost:8888](http://localhost:8888).
```
src/chess_pressure/
__init__.py
app.py # FastAPI routes and static file serving
engine.py # Pressure computation, PGN parsing, move logic
games.py # Built-in famous games (PGN data)
static/ # Frontend assets (HTML, JS, CSS, piece images)
app.py # Optional FastAPI server (/api routes + static fallback)
engine.py # Pressure computation, PGN parsing, move logic (parity reference)
games.py # Built-in famous games (PGN data)
static/
engine.js # Browser port of engine.py (chess.js)
chess.min.js # Vendored chess.js 1.4.0 (bundled global)
games.json # Built-in games, generated from games.py
app.js, ... # Frontend (HTML, JS, CSS, piece images)
scripts/export_games.py # games.py -> static/games.json
parity/ # JS-vs-python engine parity test
dev-server.js # bun static dev server
```
## License

28
dev-server.js Normal file
View File

@ -0,0 +1,28 @@
// Lightweight static dev server for chess-pressure.
// Serves src/chess_pressure/static/ at the root, matching the production
// static-web-server layout. Files are read fresh per request, so editing a
// JS/CSS/HTML file and refreshing the browser picks up changes immediately.
//
// bun run dev-server.js # serves http://0.0.0.0:8888
// PORT=9000 bun run dev-server.js
const ROOT = new URL("./src/chess_pressure/static/", import.meta.url).pathname;
const PORT = Number(process.env.PORT || 8888);
Bun.serve({
port: PORT,
hostname: "0.0.0.0",
async fetch(req) {
let pathname = decodeURIComponent(new URL(req.url).pathname);
if (pathname === "/" || pathname === "") pathname = "/index.html";
// Strip leading slashes and neutralize any ".." path traversal.
const rel = pathname.replace(/^\/+/, "").replace(/\.\.(\/|\\|$)/g, "");
const file = Bun.file(ROOT + rel);
if (await file.exists()) {
return new Response(file, { headers: { "Cache-Control": "no-cache" } });
}
return new Response("Not found", { status: 404 });
},
});
console.log(`chess-pressure dev server → http://0.0.0.0:${PORT} (serving ${ROOT})`);

93
parity/check.js Normal file
View File

@ -0,0 +1,93 @@
/* Parity test: assert the JS engine matches python-chess across every built-in game.
*
* Reads python output from .py_frames.json (produced by dump_python.py) and
* compares it to engine.js's parse of the same PGNs, frame by frame.
* Exits non-zero on any mismatch.
*/
const fs = require("fs");
const path = require("path");
const { Chess } = require("chess.js");
const makeEngine = require("../src/chess_pressure/static/engine.js");
const engine = makeEngine(Chess);
const pyData = JSON.parse(
fs.readFileSync(path.join(__dirname, ".py_frames.json"), "utf8"),
);
function eq(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
let failures = 0;
let framesChecked = 0;
for (const game of pyData) {
const js = engine.parsePgn(game.pgn);
if (js.result !== game.result) {
console.error(`[${game.id}] result mismatch: py=${game.result} js=${js.result}`);
failures++;
}
if (js.frames.length !== game.frames.length) {
console.error(
`[${game.id}] frame count mismatch: py=${game.frames.length} js=${js.frames.length}`,
);
failures++;
continue;
}
if (js.moves.length !== game.moves.length) {
console.error(
`[${game.id}] move count mismatch: py=${game.moves.length} js=${js.moves.length}`,
);
failures++;
}
for (let i = 0; i < game.frames.length; i++) {
const pf = game.frames[i];
const jf = js.frames[i];
framesChecked++;
if (!eq(pf.pressure, jf.pressure)) {
console.error(`[${game.id}] frame ${i}: unweighted pressure mismatch`);
failures++;
}
if (!eq(pf.pressure_weighted, jf.pressure_weighted)) {
console.error(`[${game.id}] frame ${i}: weighted pressure mismatch`);
failures++;
}
if (pf.board.fen !== jf.board.fen) {
console.error(
`[${game.id}] frame ${i}: fen mismatch\n py=${pf.board.fen}\n js=${jf.board.fen}`,
);
failures++;
}
if (
pf.board.is_check !== jf.board.is_check ||
pf.board.is_checkmate !== jf.board.is_checkmate ||
pf.board.is_stalemate !== jf.board.is_stalemate ||
pf.board.fullmove !== jf.board.fullmove ||
pf.board.turn !== jf.board.turn
) {
console.error(`[${game.id}] frame ${i}: board status mismatch`);
failures++;
}
}
for (let i = 0; i < Math.min(game.moves.length, js.moves.length); i++) {
if (game.moves[i].san !== js.moves[i].san || game.moves[i].uci !== js.moves[i].uci) {
console.error(
`[${game.id}] move ${i} mismatch: py=${game.moves[i].san}/${game.moves[i].uci} js=${js.moves[i].san}/${js.moves[i].uci}`,
);
failures++;
}
}
}
if (failures === 0) {
console.log(
`PARITY OK — ${pyData.length} games, ${framesChecked} frames identical (pressure, fen, status, moves).`,
);
process.exit(0);
} else {
console.error(`PARITY FAILED — ${failures} mismatch(es).`);
process.exit(1);
}

26
parity/dump_python.py Normal file
View File

@ -0,0 +1,26 @@
"""Dump python-chess engine output for every built-in game as JSON on stdout.
Consumed by check.js to verify the JS engine produces identical results.
Run from the package root: `uv run python parity/dump_python.py`.
"""
import json
import sys
from chess_pressure.engine import parse_pgn
from chess_pressure.games import GAMES
out = []
for g in GAMES:
parsed = parse_pgn(g["pgn"])
out.append(
{
"id": g["id"],
"pgn": g["pgn"],
"moves": parsed["moves"],
"frames": parsed["frames"],
"result": parsed["result"],
}
)
json.dump(out, sys.stdout)

8
parity/package.json Normal file
View File

@ -0,0 +1,8 @@
{
"name": "chess-pressure-parity",
"private": true,
"description": "Parity test: JS engine vs python-chess engine",
"dependencies": {
"chess.js": "1.4.0"
}
}

28
scripts/export_games.py Normal file
View File

@ -0,0 +1,28 @@
"""Export the built-in games to static/games.json for the client-side app.
The browser parses these PGNs locally (via chess.js) the server is no longer
in the gameplay path. Re-run after editing games.py:
uv run python scripts/export_games.py
"""
import json
from pathlib import Path
from chess_pressure.games import GAMES
OUT = Path(__file__).resolve().parent.parent / "src" / "chess_pressure" / "static" / "games.json"
data = [
{
"id": g["id"],
"name": g["name"],
"white": g["white"],
"black": g["black"],
"pgn": g["pgn"],
}
for g in GAMES
]
OUT.write_text(json.dumps(data, indent=2) + "\n")
print(f"Wrote {len(data)} games to {OUT}")

View File

@ -7,7 +7,6 @@ from pathlib import Path
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
@ -84,16 +83,12 @@ def do_move(body: MoveRequest):
# --- Static files ---
# The app is fully client-side; production is served statically (see Dockerfile).
# This Python server is kept as an optional fallback / parity reference and the
# /api routes above remain available, but the UI no longer depends on them.
# Mounted last so /api and /health take precedence.
app.mount("/static", StaticFiles(directory=str(STATIC)), name="static")
@app.get("/")
def index():
return FileResponse(
str(STATIC / "index.html"),
headers={"Cache-Control": "no-cache, must-revalidate"},
)
app.mount("/", StaticFiles(directory=str(STATIC), html=True), name="static")
def main():

View File

@ -116,60 +116,46 @@
return;
}
// Fetch legal moves for this square
fetch("/api/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fen, uci: square + square }), // dummy — we just need to check if piece exists
}).catch(() => {});
// Check if there's a piece here by seeing if any legal move starts from this square
// We need to ask the server for legal moves from this position
fetch(`/api/legal?fen=${encodeURIComponent(fen)}`).then(r => r.ok ? r.json() : null).then(data => {
if (!data) return;
const fromMoves = data.filter((m) => m.startsWith(square));
if (fromMoves.length === 0) {
clearSelection();
return;
}
// Legal moves originating from this square (computed locally)
const fromMoves = ChessPressure.legalMoves(fen).filter((m) => m.startsWith(square));
if (fromMoves.length === 0) {
clearSelection();
selectedSquare = square;
legalMoves = fromMoves;
return;
}
clearSelection();
selectedSquare = square;
legalMoves = fromMoves;
// Highlight
const srcEl = document.querySelector(`[data-square="${square}"]`);
if (srcEl) srcEl.classList.add("square-selected");
fromMoves.forEach((m) => {
const target = m.substring(2, 4);
const el = document.querySelector(`[data-square="${target}"]`);
if (el) el.classList.add("square-target");
});
// Highlight
const srcEl = document.querySelector(`[data-square="${square}"]`);
if (srcEl) srcEl.classList.add("square-selected");
fromMoves.forEach((m) => {
const target = m.substring(2, 4);
const el = document.querySelector(`[data-square="${target}"]`);
if (el) el.classList.add("square-target");
});
}
function doMove(fen, uci) {
fetch("/api/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fen, uci }),
})
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (!data) return;
// First divergence from the originally-loaded line: snapshot it so "Reset to original" works.
if (forkPoint === null && currentIndex < gameData.frames.length - 1) {
originalData = JSON.parse(JSON.stringify(gameData));
forkPoint = currentIndex;
}
// Always overwrite any continuation past the current point, so rewinding and
// playing a different move replaces the old line instead of appending to it.
gameData.moves = gameData.moves.slice(0, currentIndex);
gameData.frames = gameData.frames.slice(0, currentIndex + 1);
gameData.moves.push({ san: data.san, uci: data.uci, ply: currentIndex + 1 });
gameData.frames.push(data.frame);
goTo(gameData.frames.length - 1);
updateForkUI();
});
let data;
try {
data = ChessPressure.makeMove(fen, uci); // computed locally — no server round-trip
} catch (e) {
return; // illegal move — ignore
}
// First divergence from the originally-loaded line: snapshot it so "Reset to original" works.
if (forkPoint === null && currentIndex < gameData.frames.length - 1) {
originalData = JSON.parse(JSON.stringify(gameData));
forkPoint = currentIndex;
}
// Always overwrite any continuation past the current point, so rewinding and
// playing a different move replaces the old line instead of appending to it.
gameData.moves = gameData.moves.slice(0, currentIndex);
gameData.frames = gameData.frames.slice(0, currentIndex + 1);
gameData.moves.push({ san: data.san, uci: data.uci, ply: currentIndex + 1 });
gameData.frames.push(data.frame);
goTo(gameData.frames.length - 1);
updateForkUI();
}
// --- Move list ---
@ -322,46 +308,167 @@
updateForkUI();
}
// --- Load game ---
async function loadGame(gameId) {
const r = await fetch(`/api/games/${gameId}`);
if (!r.ok) return;
gameData = await r.json();
forkPoint = null;
originalData = null;
currentIndex = 0;
goTo(0);
updateGameInfo();
updateForkUI();
// --- Game sources (built-in + browser-saved) ---
let BUILTIN_GAMES = [];
const SAVED_KEY = "cp.games";
let currentSavedId = null;
function getSavedGames() {
try {
return JSON.parse(localStorage.getItem(SAVED_KEY)) || [];
} catch (e) {
return [];
}
}
async function loadPGN(pgn) {
const r = await fetch("/api/parse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pgn }),
});
if (!r.ok) {
alert("Failed to parse PGN");
return;
}
gameData = await r.json();
function setSavedGames(list) {
localStorage.setItem(SAVED_KEY, JSON.stringify(list));
}
function applyParsed(parsed, savedId) {
gameData = parsed;
forkPoint = null;
originalData = null;
currentSavedId = savedId || null;
currentIndex = 0;
goTo(0);
updateGameInfo();
updateForkUI();
updateSavedControls();
}
// --- Load game ---
function loadGame(gameId) {
const g = BUILTIN_GAMES.find((x) => x.id === gameId);
if (!g) return;
applyParsed(ChessPressure.parsePgn(g.pgn), null);
}
function loadSaved(savedId) {
const g = getSavedGames().find((x) => x.id === savedId);
if (!g) return;
let parsed;
try {
parsed = ChessPressure.parsePgn(g.pgn);
} catch (e) {
alert("That saved game could not be loaded (corrupted PGN).");
return;
}
applyParsed(parsed, savedId);
}
function loadPGN(pgn) {
let parsed;
try {
parsed = ChessPressure.parsePgn(pgn);
} catch (e) {
alert("Could not parse PGN — please check the format and try again.");
return;
}
applyParsed(parsed, null);
document.getElementById("game-select").value = "";
}
// --- Saved games (localStorage) + PGN export ---
function populateGameSelect() {
const select = document.getElementById("game-select");
const prev = select.value;
select.innerHTML = '<option value="__new">New game</option>';
if (BUILTIN_GAMES.length) {
const og = document.createElement("optgroup");
og.label = "Famous games";
BUILTIN_GAMES.forEach((g) => {
const opt = document.createElement("option");
opt.value = g.id;
opt.textContent = g.name;
og.appendChild(opt);
});
select.appendChild(og);
}
const saved = getSavedGames();
if (saved.length) {
const og = document.createElement("optgroup");
og.label = "Saved games";
saved.forEach((g) => {
const opt = document.createElement("option");
opt.value = "saved:" + g.id;
opt.textContent = g.name;
og.appendChild(opt);
});
select.appendChild(og);
}
// Restore selection if it still exists
if (prev && [...select.options].some((o) => o.value === prev)) {
select.value = prev;
}
}
function updateSavedControls() {
const del = document.getElementById("btn-delete-saved");
if (del) del.style.display = currentSavedId ? "" : "none";
const select = document.getElementById("game-select");
if (currentSavedId) select.value = "saved:" + currentSavedId;
}
function defaultGameName() {
const h = (gameData && gameData.headers) || {};
if (h.White && h.Black) return `${h.White} vs ${h.Black}`;
return "My game";
}
function saveCurrentGame() {
if (!gameData) return;
const name = (prompt("Save game as:", defaultGameName()) || "").trim();
if (!name) return;
const pgn = ChessPressure.toPgn(gameData.headers, gameData.moves);
const saved = getSavedGames();
const id = "g" + Date.now().toString(36);
saved.push({ id, name, pgn, savedAt: new Date().toISOString() });
setSavedGames(saved);
currentSavedId = id;
populateGameSelect();
updateSavedControls();
}
function deleteSavedGame() {
if (!currentSavedId) return;
const saved = getSavedGames();
const g = saved.find((x) => x.id === currentSavedId);
if (!g) return;
if (!confirm(`Delete saved game "${g.name}"?`)) return;
setSavedGames(saved.filter((x) => x.id !== currentSavedId));
currentSavedId = null;
populateGameSelect();
updateSavedControls();
}
function exportPGN() {
if (!gameData) return;
const pgn = ChessPressure.toPgn(gameData.headers, gameData.moves);
const slug = (defaultGameName() || "game")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || "game";
const blob = new Blob([pgn], { type: "application/x-chess-pgn" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = slug + ".pgn";
a.click();
URL.revokeObjectURL(url);
}
// --- Init ---
async function init() {
// Build board
board = Chessboard("board", {
draggable: true,
position: "start",
pieceTheme: "/static/img/chesspieces/wikipedia/{piece}.png",
pieceTheme: "/img/chesspieces/wikipedia/{piece}.png",
onDrop: onDrop,
});
@ -376,21 +483,16 @@
const saved = localStorage.getItem("theme");
if (saved) document.documentElement.dataset.theme = saved;
// Load game list
const r = await fetch("/api/games");
const games = await r.json();
// Load built-in game list (static JSON, parsed in the browser)
const r = await fetch("/games.json");
BUILTIN_GAMES = await r.json();
const select = document.getElementById("game-select");
games.forEach((g) => {
const opt = document.createElement("option");
opt.value = g.id;
opt.textContent = g.name;
select.appendChild(opt);
});
populateGameSelect();
// Load first game by default
if (games.length) {
await loadGame(games[0].id);
select.value = games[0].id;
if (BUILTIN_GAMES.length) {
loadGame(BUILTIN_GAMES[0].id);
select.value = BUILTIN_GAMES[0].id;
}
// Show board after first render (prevents tan flash)
@ -398,10 +500,13 @@
// --- Event listeners ---
select.addEventListener("change", (e) => {
if (e.target.value === "__new") {
const v = e.target.value;
if (v === "__new") {
loadPGN('[White "You"]\n[Black "Opponent"]\n[Result "*"]\n\n*');
} else if (e.target.value) {
loadGame(e.target.value);
} else if (v.startsWith("saved:")) {
loadSaved(v.slice(6));
} else if (v) {
loadGame(v);
}
});
@ -475,6 +580,11 @@
document.getElementById("export-cancel").addEventListener("click", () => exportDialog.close());
document.getElementById("export-go").addEventListener("click", exportGif);
// Save / export PGN / delete saved game
document.getElementById("btn-save").addEventListener("click", saveCurrentGame);
document.getElementById("btn-export-pgn").addEventListener("click", exportPGN);
document.getElementById("btn-delete-saved").addEventListener("click", deleteSavedGame);
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "TEXTAREA" || e.target.tagName === "INPUT") return;
@ -520,7 +630,7 @@
const img = new Image();
img.onload = () => { pieceImages[p] = img; resolve(); };
img.onerror = () => resolve();
img.src = `/static/img/chesspieces/wikipedia/${p}.png`;
img.src = `/img/chesspieces/wikipedia/${p}.png`;
}));
return Promise.all(promises);
}
@ -612,7 +722,7 @@
quality: 10,
width: EXPORT_SIZE,
height: EXPORT_SIZE,
workerScript: "/static/gif.worker.js",
workerScript: "/gif.worker.js",
});
const canvas = document.createElement("canvas");

15
src/chess_pressure/static/chess.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,154 @@
/* Chess Pressure client-side engine.
*
* A direct port of engine.py. Computes the same pressure maps and frame
* structures entirely in the browser, so gameplay never needs the server.
*
* UMD: in the browser it self-initializes from the global `Chess`
* (chess.min.js must load first) and exposes `window.ChessPressure`.
* Under Node/Bun it exports the factory so the parity test can inject its
* own Chess: `require("./engine.js")(require("chess.js").Chess)`.
*/
(function (root, factory) {
if (typeof module === "object" && module.exports) {
module.exports = factory; // caller supplies Chess
} else {
root.ChessPressure = factory(root.Chess);
}
})(typeof self !== "undefined" ? self : this, function (Chess) {
"use strict";
const PIECE_VALUES = { p: 1, n: 3, b: 3, r: 5, q: 9, k: 1 };
const FILES = "abcdefgh";
// square index 0..63 (a1=0, b1=1, ... h8=63) -> "e4" — matches python-chess.
const SQ_NAMES = [];
for (let i = 0; i < 64; i++) SQ_NAMES.push(FILES[i % 8] + (Math.floor(i / 8) + 1));
/** Net pressure per square. Positive = white controls, negative = black. */
function computePressure(chess, weighted) {
const pressure = new Array(64).fill(0);
for (let i = 0; i < 64; i++) {
const sq = SQ_NAMES[i];
for (const color of ["w", "b"]) {
const attackers = chess.attackers(sq, color);
const sign = color === "w" ? 1 : -1;
for (const from of attackers) {
const weight = weighted ? PIECE_VALUES[chess.get(from).type] : 1;
pressure[i] += sign * weight;
}
}
}
return pressure;
}
/** Serialize board state for the frontend (mirrors board_to_dict). */
function boardDict(chess) {
return {
fen: chess.fen(),
turn: chess.turn(),
is_check: chess.isCheck(),
is_checkmate: chess.isCheckmate(),
is_stalemate: chess.isStalemate(),
is_game_over: chess.isGameOver(),
fullmove: chess.moveNumber(),
};
}
function frameFor(chess) {
return {
board: boardDict(chess),
pressure: computePressure(chess, false),
pressure_weighted: computePressure(chess, true),
};
}
function uciOf(move) {
return move.from + move.to + (move.promotion || "");
}
/** Legal moves from a FEN as UCI strings (mirrors /api/legal). */
function legalMoves(fen) {
const chess = new Chess(fen);
return chess.moves({ verbose: true }).map(uciOf);
}
/** Apply a UCI move to a FEN (mirrors make_move). Throws on illegal. */
function makeMove(fen, uci) {
const chess = new Chess(fen);
const move = {
from: uci.slice(0, 2),
to: uci.slice(2, 4),
promotion: uci.length > 4 ? uci[4] : undefined,
};
let result;
try {
result = chess.move(move);
} catch (e) {
throw new Error("Illegal move: " + uci);
}
if (!result) throw new Error("Illegal move: " + uci);
return {
san: result.san,
uci,
frame: frameFor(chess),
legal_moves: chess.moves({ verbose: true }).map(uciOf),
};
}
/** Parse a PGN string into {headers, moves, frames, result} (mirrors parse_pgn). */
function parsePgn(pgnText) {
const parser = new Chess();
parser.loadPgn(pgnText); // throws on unparseable PGN
const headers = parser.getHeaders();
const history = parser.history({ verbose: true });
const replay = headers.FEN ? new Chess(headers.FEN) : new Chess();
const moves = [];
const frames = [frameFor(replay)]; // frame 0 = starting position
for (const mv of history) {
const san = mv.san;
const uci = uciOf(mv);
replay.move({ from: mv.from, to: mv.to, promotion: mv.promotion });
moves.push({ san, uci, ply: replay.history().length });
frames.push(frameFor(replay));
}
return {
headers,
moves,
frames,
result: headers.Result || "*",
};
}
/** Build a PGN-compatible string from headers + a list of {uci} moves. */
function toPgn(headers, moves) {
const chess = headers && headers.FEN ? new Chess(headers.FEN) : new Chess();
if (headers) {
for (const key of Object.keys(headers)) {
if (headers[key] != null && headers[key] !== "") {
chess.setHeader(key, String(headers[key]));
}
}
}
for (const m of moves) {
chess.move({
from: m.uci.slice(0, 2),
to: m.uci.slice(2, 4),
promotion: m.uci.length > 4 ? m.uci[4] : undefined,
});
}
return chess.pgn();
}
return {
computePressure,
boardDict,
frameFor,
legalMoves,
makeMove,
parsePgn,
toPgn,
};
});

View File

@ -0,0 +1,72 @@
[
{
"id": "immortal",
"name": "The Immortal Game (1851)",
"white": "Anderssen",
"black": "Kieseritzky",
"pgn": "[Event \"London\"]\n[Site \"London\"]\n[Date \"1851.06.21\"]\n[White \"Adolf Anderssen\"]\n[Black \"Lionel Kieseritzky\"]\n[Result \"1-0\"]\n\n1. e4 e5 2. f4 exf4 3. Bc4 Qh4+ 4. Kf1 b5 5. Bxb5 Nf6 6. Nf3 Qh6 7. d3 Nh5\n8. Nh4 Qg5 9. Nf5 c6 10. g4 Nf6 11. Rg1 cxb5 12. h4 Qg6 13. h5 Qg5 14. Qf3\nNg8 15. Bxf4 Qf6 16. Nc3 Bc5 17. Nd5 Qxb2 18. Bd6 Bxg1 19. e5 Qxa1+ 20. Ke2\nNa6 21. Nxg7+ Kd8 22. Qf6+ Nxf6 23. Be7# 1-0"
},
{
"id": "opera",
"name": "The Opera Game (1858)",
"white": "Morphy",
"black": "Duke of Brunswick & Count Isouard",
"pgn": "[Event \"Opera House\"]\n[Site \"Paris\"]\n[Date \"1858.11.02\"]\n[White \"Paul Morphy\"]\n[Black \"Duke of Brunswick and Count Isouard\"]\n[Result \"1-0\"]\n\n1. e4 e5 2. Nf3 d6 3. d4 Bg4 4. dxe5 Bxf3 5. Qxf3 dxe5 6. Bc4 Nf6 7. Qb3 Qe7\n8. Nc3 c6 9. Bg5 b5 10. Nxb5 cxb5 11. Bxb5+ Nbd7 12. O-O-O Rd8 13. Rxd7 Rxd7\n14. Rd1 Qe6 15. Bxd7+ Nxd7 16. Qb8+ Nxb8 17. Rd8# 1-0"
},
{
"id": "fischer-spassky-6",
"name": "Fischer vs Spassky, Game 6 (1972)",
"white": "Fischer",
"black": "Spassky",
"pgn": "[Event \"World Championship\"]\n[Site \"Reykjavik\"]\n[Date \"1972.07.23\"]\n[White \"Robert James Fischer\"]\n[Black \"Boris Spassky\"]\n[Result \"1-0\"]\n\n1. c4 e6 2. Nf3 d5 3. d4 Nf6 4. Nc3 Be7 5. Bg5 O-O 6. e3 h6 7. Bh4 b6\n8. cxd5 Nxd5 9. Bxe7 Qxe7 10. Nxd5 exd5 11. Rc1 Be6 12. Qa4 c5 13. Qa3 Rc8\n14. Bb5 a6 15. dxc5 bxc5 16. O-O Ra7 17. Be2 Nd7 18. Nd4 Qf8 19. Nxe6 fxe6\n20. e4 d4 21. f4 Qe7 22. e5 Rb8 23. Bc4 Kh8 24. Qh3 Nf8 25. b3 a5 26. f5\nexf5 27. Rxf5 Nh7 28. Rcf1 Qd8 29. Qg3 Re7 30. h4 Rbb7 31. e6 Rbc7 32. Qe5\nQe8 33. a4 Qd8 34. R1f2 Qe8 35. R2f3 Qd8 36. Bd3 Qe8 37. Qe4 Nf6 38. Rxf6\ngxf6 39. Rxf6 Kg8 40. Bc4 Kh8 41. Qf4 1-0"
},
{
"id": "deep-blue",
"name": "Kasparov vs Deep Blue, Game 2 (1997)",
"white": "Deep Blue",
"black": "Kasparov",
"pgn": "[Event \"IBM Man-Machine\"]\n[Site \"New York\"]\n[Date \"1997.05.04\"]\n[White \"Deep Blue\"]\n[Black \"Garry Kasparov\"]\n[Result \"1-0\"]\n\n1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6\n8. c3 O-O 9. h3 h6 10. d4 Re8 11. Nbd2 Bf8 12. Nf1 Bd7 13. Ng3 Na5 14. Bc2\nc5 15. b3 Nc6 16. d5 Ne7 17. Be3 Ng6 18. Qd2 Nh7 19. a4 Nh4 20. Nxh4 Qxh4\n21. Qe2 Qd8 22. b4 Qc7 23. Rec1 c4 24. Ra3 Rec8 25. Rca1 Qd8 26. f4 Nf6\n27. fxe5 dxe5 28. Qf1 Ne8 29. Qf2 Nd6 30. Bb6 Qe8 31. R3a2 Be7 32. Bc5 Bf8\n33. Nf5 Bxf5 34. exf5 f6 35. Bxd6 Bxd6 36. axb5 axb5 37. Be4 Rxa2 38. Qxa2\nQd7 39. Qa7 Rc7 40. Qb6 Rb7 41. Ra8+ Kf7 42. Qa6 Qc7 43. Qc6 Qb6+ 44. Kf1\nRb8 45. Ra6 1-0"
},
{
"id": "polgar-anand",
"name": "Polgar vs Anand (1999)",
"white": "Polgar",
"black": "Anand",
"pgn": "[Event \"Dos Hermanas\"]\n[Site \"Dos Hermanas\"]\n[Date \"1999.04.??\"]\n[White \"Judit Polgar\"]\n[Black \"Viswanathan Anand\"]\n[Result \"1-0\"]\n\n1.e4 c5 2.Nf3 d6 3.d4 cxd4 4.Nxd4 Nf6 5.Nc3 a6 6.Be3 e6 7.g4 e5 8.Nf5 g6\n9.g5 gxf5 10.exf5 d5 11.Qf3 d4 12.O-O-O Nbd7 13.Bd2 dxc3 14.Bxc3 Bg7 15.Rg1\nO-O 16.gxf6 Qxf6 17.Qe3 Kh8 18.f4 Qb6 19.Qg3 Qh6 20.Rd6 f6 21.Bd2 e4 22.Bc4\nb5 23.Be6 Ra7 24.Rc6 a5 25.Be3 Rb7 26.Bd5 Rb8 27.Rc7 b4 28.b3 Rb5 29.Bc6 Rxf5\n30.Rxc8 Rxc8 31.Bxd7 Rcc5 32.Bxf5 Rxf5 33.Rd1 Kg8 34.Qg2 1-0"
},
{
"id": "polgar-kasparov",
"name": "Polgar vs Kasparov (2002)",
"white": "Polgar",
"black": "Kasparov",
"pgn": "[Event \"Russia vs Rest of the World\"]\n[Site \"Moscow\"]\n[Date \"2002.09.09\"]\n[White \"Judit Polgar\"]\n[Black \"Garry Kasparov\"]\n[Result \"1-0\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bb5 Nf6 4.O-O Nxe4 5.d4 Nd6 6.Bxc6 dxc6 7.dxe5 Nf5\n8.Qxd8+ Kxd8 9.Nc3 h6 10.Rd1+ Ke8 11.h3 Be7 12.Ne2 Nh4 13.Nxh4 Bxh4 14.Be3\nBf5 15.Nd4 Bh7 16.g4 Be7 17.Kg2 h5 18.Nf5 Bf8 19.Kf3 Bg6 20.Rd2 hxg4+ 21.hxg4\nRh3+ 22.Kg2 Rh7 23.Kg3 f6 24.Bf4 Bxf5 25.gxf5 fxe5 26.Re1 Bd6 27.Bxe5 Kd7\n28.c4 c5 29.Bxd6 cxd6 30.Re6 Rah8 31.Rexd6+ Kc8 32.R2d5 Rh3+ 33.Kg2 Rh2+\n34.Kf3 R2h3+ 35.Ke4 b6 36.Rc6+ Kb8 37.Rd7 Rh2 38.Ke3 Rf8 39.Rcc7 Rxf5\n40.Rb7+ Kc8 41.Rdc7+ Kd8 42.Rxg7 Kc8 1-0"
},
{
"id": "hou-caruana",
"name": "Hou Yifan vs Caruana (2017)",
"white": "Hou Yifan",
"black": "Caruana",
"pgn": "[Event \"GRENKE Chess Classic\"]\n[Site \"Karlsruhe\"]\n[Date \"2017.04.15\"]\n[White \"Hou Yifan\"]\n[Black \"Fabiano Caruana\"]\n[Result \"1-0\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bb5 Nf6 4.O-O Nxe4 5.Re1 Nd6 6.Nxe5 Be7 7.Bf1 O-O 8.d4\nNf5 9.Nf3 d5 10.c3 Bd6 11.Nbd2 Nce7 12.Qc2 c6 13.Bd3 g6 14.Nf1 f6 15.h3 Rf7\n16.Bd2 Bd7 17.Re2 c5 18.dxc5 Bxc5 19.Bf4 Rc8 20.Rae1 g5 21.Ng3 Nxg3 22.Bxg3\na5 23.Qd2 a4 24.b4 axb3 25.axb3 Ng6 26.h4 gxh4 27.Nxh4 Nxh4 28.Bxh4 Qf8\n29.Qf4 Bd6 30.Qd4 Rd8 31.Re3 Bc8 32.b4 Kg7 33.Bb5 Bc7 34.Re8 Qd6 35.Bg3 Qb6\n36.Qd3 Bd7 37.Bxd7 Rdxd7 38.Qf5 Bxg3 39.Qg4+ Kh6 40.Qh3+ 1-0"
},
{
"id": "ju-lei",
"name": "Ju Wenjun vs Lei Tingjie, WCC G12 (2023)",
"white": "Ju Wenjun",
"black": "Lei Tingjie",
"pgn": "[Event \"Women's World Championship\"]\n[Site \"Shanghai/Chongqing\"]\n[Date \"2023.07.22\"]\n[White \"Ju Wenjun\"]\n[Black \"Lei Tingjie\"]\n[Result \"1-0\"]\n\n1.d4 d5 2.Nf3 Nf6 3.e3 c5 4.dxc5 e6 5.b4 a5 6.c3 axb4 7.cxb4 b6 8.Bb5+ Bd7\n9.Bxd7+ Nbxd7 10.a4 bxc5 11.b5 Qc7 12.Bb2 Bd6 13.O-O O-O 14.Nbd2 Rfc8 15.Qc2\nc4 16.Bc3 Nc5 17.a5 Nb3 18.Bxf6 Nxa1 19.Bxa1 Qxa5 20.Qc3 Qxc3 21.Bxc3 Rcb8\n22.Nd4 e5 23.Nf5 Bf8 24.Bxe5 Rxb5 25.g4 g6 26.Nd4 Rb2 27.Nb1 Bg7 28.Bxg7 Kxg7\n29.Nc3 Ra5 30.Rd1 Rb6 31.Nde2 Rb3 32.Kg2 h6 33.Kf3 f6 34.Rc1 Kf7 35.Nf4 d4\n36.exd4 g5 37.Ne2 f5 38.gxf5 Rxf5+ 39.Ke3 g4 40.Nf4 Rb8 41.d5 Rf6 42.Rc2 Ra8\n43.Nb5 Rb6 44.Nd4 Ra3+ 45.Ke4 c3 46.Nfe2 Rb2 47.Kd3 Rb1 48.Nxc3 Rh1 49.f3 gxf3\n50.Nxf3 Rf1 51.Nd4 Ke7 52.Kc4 Rf4 53.Rb2 Rh4 54.Rb7+ Kf6 55.Rb2 Ra8 56.Kc5 Rh3\n57.Ncb5 Re3 58.d6 Ke5 59.Nc6+ Ke4 60.d7 Rd3 61.Nd6+ Kf4 62.Rb8 1-0"
},
{
"id": "xie-chiburdanidze",
"name": "Xie Jun vs Chiburdanidze, WCC (1991)",
"white": "Xie Jun",
"black": "Chiburdanidze",
"pgn": "[Event \"Wch (Women)\"]\n[Site \"Manila\"]\n[Date \"1991.??.??\"]\n[White \"Xie Jun\"]\n[Black \"Maia Chiburdanidze\"]\n[Result \"1-0\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bb5 a6 4.Ba4 Nf6 5.O-O Be7 6.Re1 b5 7.Bb3 d6 8.c3 O-O\n9.h3 Nd7 10.d4 Bf6 11.a4 Bb7 12.axb5 axb5 13.Rxa8 Qxa8 14.d5 Ne7 15.Na3 c6\n16.dxc6 Bxc6 17.Qxd6 Nc8 18.Qd1 Nc5 19.Bd5 Nxe4 20.Bxc6 Qxc6 21.Qd3 Ncd6\n22.Nxb5 Qc5 23.Qe2 Ng3 24.Qe3 Qxb5 25.fxg3 e4 26.Nd4 Qb7 27.Nc2 Re8 28.Qc5\nBe5 29.g4 Qe7 30.Be3 h5 31.gxh5 Qh4 32.Bd4 Bxd4+ 33.Qxd4 Re6 34.Qd1 Nc4\n35.b3 Ne5 36.Qe2 Rf6 37.Rf1 Rxf1+ 38.Kxf1 Nd3 39.Nd4 Qf4+ 40.Kg1 Qc1+\n41.Kh2 Nc5 42.Qc2 Qf4+ 43.g3 Qg5 44.b4 Nd3 45.Qe2 f5 46.h4 Qf6 47.Qa2+ Kh7\n48.Qe6 Qxe6 49.Nxe6 e3 50.Nd4 f4 51.b5 1-0"
},
{
"id": "carlsen-nakamura",
"name": "Carlsen vs Nakamura (2010)",
"white": "Carlsen",
"black": "Nakamura",
"pgn": "[Event \"London Chess Classic\"]\n[Site \"London\"]\n[Date \"2010.12.11\"]\n[White \"Magnus Carlsen\"]\n[Black \"Hikaru Nakamura\"]\n[Result \"1-0\"]\n\n1.c4 f5 2.g3 Nf6 3.Bg2 d6 4.Nc3 g6 5.e3 Bg7 6.Nge2 O-O 7.O-O e5 8.b3 Nbd7\n9.d3 c6 10.Ba3 Qc7 11.Qd2 Re8 12.Rae1 Nc5 13.h3 e4 14.dxe4 Nfxe4 15.Qc2 Nxc3\n16.Nxc3 Be6 17.Rd1 Rad8 18.Bb2 Bf7 19.Rd2 a5 20.Rfd1 Be5 21.Ne2 a4 22.b4 Nd7\n23.Bd4 Nb6 24.Bxb6 Qxb6 25.Rb1 Qc7 26.Nd4 Rc8 27.Rc1 Qe7 28.Rd3 c5 29.bxc5\nRxc5 30.Qxa4 Rec8 31.Rb1 Rxc4 32.Qd1 b6 33.Nb5 R4c5 34.Nxd6 Bxd6 35.Rxd6\nBxa2 36.Ra1 Rc1 37.Rxc1 Rxc1 38.Rxg6+ hxg6 39.Qxc1 Qd6 40.h4 Bf7 41.h5 Kh7\n42.hxg6+ Kxg6 43.Qc2 b5 44.g4 Qe5 45.gxf5+ Kg7 46.Qe4 Qd6 47.Qh4 Bc4 48.Bf3\nQf6 49.Qxf6+ Kxf6 50.Be4 Ba2 51.f4 b4 52.Kf2 b3 53.Bd5 Kxf5 54.Kf3 Kf6 55.e4\nKg6 56.Ke3 Kh5 57.Kd4 Kg4 58.f5 Kg5 59.Ke5 1-0"
}
]

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chess Pressure</title>
<link rel="stylesheet" href="/static/style.css?v=23">
<link rel="stylesheet" href="/style.css?v=24">
<link rel="stylesheet" href="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.css">
</head>
<body>
@ -73,7 +73,10 @@
</div>
<div class="export-row">
<button id="btn-export">Export GIF</button>
<button id="btn-save" title="Save this game to your browser">Save</button>
<button id="btn-export-pgn" title="Download this game as a PGN file">Export PGN</button>
<button id="btn-export" title="Export an animated GIF">Export GIF</button>
<button id="btn-delete-saved" class="danger" title="Delete this saved game" style="display:none">Delete</button>
</div>
<div class="status-bar" id="status-bar"></div>
@ -122,8 +125,9 @@
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/chess.js/0.10.3/chess.min.js"></script>
<script src="/static/gif.js"></script>
<script src="/static/app.js?v=23"></script>
<script src="/chess.min.js"></script>
<script src="/gif.js"></script>
<script src="/engine.js"></script>
<script src="/app.js?v=24"></script>
</body>
</html>

View File

@ -287,9 +287,13 @@ main {
/* --- Export --- */
.export-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.4rem 0;
}
.export-row button {
flex: 1 1 auto;
background: var(--bg2);
border: 1px solid var(--border);
color: var(--fg);
@ -297,9 +301,10 @@ main {
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
width: 100%;
white-space: nowrap;
}
.export-row button:hover { background: var(--move-active); }
.export-row button.danger:hover { background: rgba(220, 50, 50, 0.25); }
dialog label {
display: block;
font-size: 0.85rem;