Add persistence layer: TOTP auth, game history, restore
- db/ package: async SQLAlchemy engine + Player/Game/Guess models - api/auth.py: passwordless TOTP login (pyotp), session token via socket auth - api/history.py: record guesses/points, DB-backed standings, restore unfinished games on startup, host-only end_game - api/__init__.py: auth-gated handlers, accounts map, rejoin via account - frontend: Auth (QR + code) and History pages, resume/end-game in lobby/table - docker-compose: real PostgreSQL service wired via DATABASE_URL - tests_history.py for the persistence/auth layer; refresh CLAUDE.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,32 +4,34 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## What this is
|
||||
|
||||
A web implementation of **Bridžik**, a Slovak 4-player trick-taking/bidding card game played with a 32-card Slovak/German-suited deck. The codebase is in mid-migration from a legacy server-rendered HTTP frontend to a realtime Socket.IO frontend; both still live in `api/`. Code, comments, and exception messages are mostly in **Slovak** — keep new strings consistent with that.
|
||||
A web implementation of **Bridžik**, a Slovak 4-player trick-taking/bidding card game played with a 32-card Slovak/German-suited deck. Stack: a pure-Python game engine, a realtime **Socket.IO ASGI** server, an async **SQLAlchemy** persistence layer, and a **React PWA** frontend. Code, comments, and exception messages are mostly in **Slovak** — keep new strings consistent with that.
|
||||
|
||||
## Commands
|
||||
|
||||
```powershell
|
||||
# Run the app (starts on 0.0.0.0:5000)
|
||||
python -m app # or: python app.py / python start.py — all just import `api`
|
||||
# Run the backend (ASGI Socket.IO on 0.0.0.0:5000)
|
||||
python -m app # uvicorn with --reload (dev entrypoint)
|
||||
uvicorn api:app --host 0.0.0.0 --port 5000 # what Docker/prod runs
|
||||
|
||||
# Run via Docker (app on :5000, debugpy on :5678)
|
||||
# Run the whole stack (Postgres + backend :5000 + frontend :5173)
|
||||
docker-compose up --build
|
||||
docker-compose down -v # also drops the pg volume — needed after a schema change
|
||||
|
||||
# Run the full test suite (pure-engine unittest)
|
||||
python -m unittest tests -v
|
||||
|
||||
# Run a single test case / method
|
||||
python -m unittest tests.StashCase
|
||||
python -m unittest tests.StashCase.test_get_winner
|
||||
# Tests
|
||||
python -m unittest tests -v # pure-engine unittest (no deps)
|
||||
python -m unittest tests_history -v # persistence + auth (needs sqlalchemy/aiosqlite/pyotp)
|
||||
python -m unittest tests.StashCase.test_get_winner # single method
|
||||
```
|
||||
|
||||
There is no linter or build step configured.
|
||||
There is no linter or build step configured. The frontend is **only ever run via Docker** — never `npm install`/`npm run dev` on the host.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Game engine — `bridzik.py` (the important part)
|
||||
Three independent layers, each usable without the one above it: **engine** (`bridzik.py`) ← **persistence** (`db/`) ← **app/transport** (`api/`).
|
||||
|
||||
Pure Python, **no Flask dependency**. All game rules live here and are exercised directly by `tests.py`. The state is a strict nested hierarchy, each level enforcing turn order and completion before delegating down:
|
||||
### Game engine — `bridzik.py`
|
||||
|
||||
Pure Python, **no Flask/Socket.IO/DB dependency**. All game rules live here and are exercised directly by `tests.py`. State is a strict nested hierarchy, each level enforcing turn order and completion before delegating down:
|
||||
|
||||
- **`Bridzik`** — a whole game = exactly **4 `Series`**.
|
||||
- **`Series`** — exactly **8 `Round`s**; the starting player rotates per series/round.
|
||||
@@ -42,26 +44,37 @@ Key rules encoded in the engine:
|
||||
- **Scoring**: a player who exactly matches their guess scores `10 + guess`, else 0 (`Round.get_points_summary`).
|
||||
- The total of the 4 guesses may **not** equal the number of tricks (the last bidder is constrained) — see `Round.add_player_guess`.
|
||||
|
||||
Serialization: `Card.JSONEncoder` flattens a `Card` to `{color, value}` name strings. The double `json.loads(json.dumps(...))` pattern used throughout the API exists to strip escaped slashes after custom encoding.
|
||||
Serialization: `Card.JSONEncoder` flattens a `Card` to `{color, value}` name strings. The double `json.loads(json.dumps(...))` pattern in the API strips escaped slashes after custom encoding.
|
||||
|
||||
### Web layer — `api/`
|
||||
### Persistence layer — `db/`
|
||||
|
||||
Two frontends coexist; **`api/__init__.py` is the active one**:
|
||||
Async SQLAlchemy 2.0, independent of Socket.IO (mirrors how the engine is kept clean).
|
||||
|
||||
- **`api/__init__.py` — Socket.IO realtime server (current).** Defines `app`, `socketio`, and all `@socketio.on(...)` handlers (`create_game`, `register_player`, `start_game`, `play_card`, etc.). Supports **multiple concurrent games** via the module-global `games` dict keyed by game id (`gid`), with `Game`/`Player` wrapper classes and Socket.IO rooms. **The server is started as an import side-effect** — `socketio.run(...)` runs at the bottom of this module, which is why `app.py` and `start.py` are one-line `from api import app`.
|
||||
- Known WIP/fixme spots: `create_game` hardcodes `gid = 'a'`; `play_card` references `get_player_cards` without calling it correctly. Expect rough edges here.
|
||||
- **`db/db.py`** — async `engine` + `async_sessionmaker`, declarative `Base`, and `init_db()` (`create_all`). Connection string from env **`DATABASE_URL`** (default `sqlite+aiosqlite:///bridzik.db`; Docker sets PostgreSQL via `asyncpg`). There are **no migrations** — `create_all` only adds new tables, so a changed column needs a fresh DB.
|
||||
- **`db/models.py`** — 3 tables:
|
||||
- **`Player`** — account + auth: `username` (unique login), `totp_secret`, `totp_last_step` (TOTP replay guard), `auth_token` (session token for reconnect).
|
||||
- **`Game`** — one match: `id` (gid), 4 `playerN_id` seats, `name`, `series`/`round` (current position, used for restore), `created_at`, `ended_at`.
|
||||
- **`Guess`** — one player's bid+result in a round: `series_number`, `round_number`, `guess`, `points`. `won` is derived (`points > 0`). Unique on (game, series, round, player) → idempotent writes.
|
||||
|
||||
- **`api/routes.py` + `api/templates/` + `api/forms.py` — legacy server-rendered HTTP frontend (dormant/broken).** It imports `bridzikInstance` from `api`, but that single shared instance is **no longer defined** in `api/__init__.py` (commented out), so these routes will not run as-is. Treat this as reference for the old single-game flow, not as live code, unless you are deliberately reviving it.
|
||||
### App / transport layer — `api/`
|
||||
|
||||
- **`api/utils.py`** — `sort_card_list` (orders a hand by suit then value) and `get_points_sums` (totals standings across all series/rounds). Used by the legacy routes.
|
||||
- **`api/__init__.py`** — the Socket.IO server. `sio = socketio.AsyncServer(async_mode="asgi")` and `app = socketio.ASGIApp(sio, other_asgi_app=_health_app)`; run with `uvicorn api:app`. The ASGI **lifespan startup** calls `init_db()` then `_restore_unfinished_games()`. Multiple concurrent games via the module-global `games` dict (keyed by `gid`) with `Game`/`Player` wrapper classes and Socket.IO rooms. Three module-global dicts are the source of truth: `games`, `sessions` (sid → seat), `accounts` (sid → logged-in identity). **Never trust a client-supplied player number** — the seat is derived from the connection.
|
||||
- Handlers: auth (`register_account`, `confirm_account`, `login`), lobby (`create_game`, `register_player`, `leave_game`, `start_game`, `end_game`), reconnect (`reconnect_to_game` via per-game token, `rejoin_game` via account), play (`game_status`, `player_cards`, `add_guess`, `play_card`), history (`get_player_history`, `get_game_detail`). Creating/joining/playing requires a logged-in `accounts[sid]`.
|
||||
- **`api/auth.py`** — TOTP auth (`pyotp`), passwordless. `register_account` issues a secret + `otpauth://` URI (frontend renders the QR); `confirm_account`/`login` verify the 6-digit code and return a session token stored on `Player.auth_token`; `player_by_token` resolves the token sent in the Socket.IO `auth` handshake on `connect`.
|
||||
- **`api/history.py`** — persistence orchestration over `db/`, reading values from the engine (never mutating it): `record_game_started`, `record_completed_rounds` (also tracks `series`/`round` position and sets `ended_at`), `mark_game_ended`, `get_standings` (DB-backed standings so the score survives a restart), `get_player_history`, `get_game_detail`. **Restore**: `rebuild_core(series, round)` reconstructs a `Bridzik` to a position (cards re-dealt fresh — the only non-deterministic part); `restore_game_core` / `get_unfinished_games` drive restore-on-startup.
|
||||
|
||||
### Frontend — `frontend/`
|
||||
|
||||
React + Vite PWA (zustand store, react-router, socket.io-client, `qrcode.react`). Talks to the backend purely over Socket.IO; the socket `auth` token enables auto-login on reconnect. Runs only inside Docker (the `frontend` compose service does `npm install && npm run dev`). The Vite proxy target is `VITE_BACKEND_URL` (compose) or `http://localhost:5000` (default).
|
||||
|
||||
### Config & infra
|
||||
|
||||
- `config.py` holds a `Config` class with a dev `SECRET_KEY`; note the active `api/__init__.py` sets its own `SECRET_KEY` inline rather than loading this.
|
||||
- `Dockerfile` targets Python 3.9, runs `python -m app`, and bundles `debugpy` for remote debugging on port 5678.
|
||||
- `requirements.txt` pins `Flask-SocketIO` + `eventlet` (the chosen `async_mode`); the trailing `socket-cli` is marked for removal.
|
||||
- `Dockerfile` targets **Python 3.14-slim** and runs `uvicorn api:app`.
|
||||
- `docker-compose.yaml` runs **PostgreSQL** (`db`, with a `pgdata` volume + healthcheck), the backend (`DATABASE_URL` → that Postgres), and the frontend.
|
||||
- `requirements.txt`: `python-socketio`, `uvicorn`, `SQLAlchemy[asyncio]`, `aiosqlite` (dev), `asyncpg` (prod), `pyotp`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- New game-rule logic belongs in `bridzik.py` and should come with a `unittest` case in `tests.py` — keep the engine independent of Flask/Socket.IO.
|
||||
- Raise `BridzikException` (Slovak message) for rule violations; the API layer catches it and re-emits a generic Slovak error to the client.
|
||||
- New game-rule logic belongs in `bridzik.py` with a `unittest` case in `tests.py` — keep the engine free of Flask/Socket.IO/DB.
|
||||
- Persistence/auth logic goes in `db/` (data primitives) and `api/history.py` + `api/auth.py` (logic that uses them); cover it in `tests_history.py`, not `tests.py`.
|
||||
- Raise `BridzikException` (Slovak message) for rule violations; the API catches it and re-emits a Slovak error. Auth errors use `AuthError` the same way.
|
||||
|
||||
Reference in New Issue
Block a user