chess-pressure/src/chess_pressure/app.py
Michael Pilosov ba1d993676 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(-)
2026-06-25 19:41:55 +00:00

119 lines
2.6 KiB
Python

"""FastAPI application for chess pressure visualization."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from .engine import make_move, parse_pgn
from .games import get_game_list, get_game_pgn
STATIC = Path(__file__).resolve().parent / "static"
app = FastAPI(title="Chess Pressure", docs_url=None, redoc_url=None)
# --- Models ---
class PGNUpload(BaseModel):
pgn: str
class MoveRequest(BaseModel):
fen: str
uci: str
# --- Health ---
@app.get("/health", include_in_schema=False)
@app.get("/up", include_in_schema=False)
@app.get("/status", include_in_schema=False)
@app.get("/health-check", include_in_schema=False)
def health():
return {"status": "ok"}
# --- API ---
@app.get("/api/games")
def list_games():
return get_game_list()
@app.get("/api/games/{game_id}")
@lru_cache(maxsize=16)
def load_game(game_id: str):
pgn = get_game_pgn(game_id)
if pgn is None:
raise HTTPException(404, "Game not found")
return parse_pgn(pgn)
@app.post("/api/parse")
def parse_uploaded_pgn(body: PGNUpload):
try:
return parse_pgn(body.pgn)
except ValueError as e:
raise HTTPException(400, str(e))
@app.get("/api/legal")
def legal_moves(fen: str):
import chess
board = chess.Board(fen)
return [m.uci() for m in board.legal_moves]
@app.post("/api/move")
def do_move(body: MoveRequest):
try:
return make_move(body.fen, body.uci)
except ValueError as e:
raise HTTPException(400, str(e))
# --- 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("/", StaticFiles(directory=str(STATIC), html=True), name="static")
def main():
import argparse
parser = argparse.ArgumentParser(description="Chess Pressure server")
parser.add_argument(
"--host", default="0.0.0.0", help="Bind address (default: 0.0.0.0)"
)
parser.add_argument(
"--port", "-p", type=int, default=8888, help="Port (default: 8888)"
)
parser.add_argument(
"--workers", "-w", type=int, default=2, help="Worker processes (default: 2)"
)
args = parser.parse_args()
uvicorn.run(
"chess_pressure.app:app",
host=args.host,
port=args.port,
workers=args.workers,
)
if __name__ == "__main__":
main()