From 30c32b77141c8ce0f9615c7c1bf665414ee7bbe9 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 23 Jun 2026 23:09:50 +0200 Subject: [PATCH] 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 --- .gitignore | 2 + CLAUDE.md | 63 +++--- api/__init__.py | 195 ++++++++++++++++++- api/auth.py | 100 ++++++++++ api/history.py | 266 ++++++++++++++++++++++++++ db/__init__.py | 9 + db/db.py | 35 ++++ db/models.py | 85 ++++++++ docker-compose.yaml | 28 +++ frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/src/App.tsx | 28 ++- frontend/src/components/NameModal.tsx | 29 ++- frontend/src/lib/socket.ts | 76 +++++++- frontend/src/main.tsx | 4 +- frontend/src/pages/Auth.tsx | 157 +++++++++++++++ frontend/src/pages/GameList.tsx | 58 ++++-- frontend/src/pages/GameTable.tsx | 19 +- frontend/src/pages/History.tsx | 97 ++++++++++ frontend/src/store/gameStore.ts | 39 +++- frontend/src/types.ts | 42 ++++ frontend/vite.config.ts | 4 +- requirements.txt | 6 + tests_history.py | 180 +++++++++++++++++ 24 files changed, 1446 insertions(+), 87 deletions(-) create mode 100644 api/auth.py create mode 100644 api/history.py create mode 100644 db/__init__.py create mode 100644 db/db.py create mode 100644 db/models.py create mode 100644 frontend/src/pages/Auth.tsx create mode 100644 frontend/src/pages/History.tsx create mode 100644 tests_history.py diff --git a/.gitignore b/.gitignore index ccc8bb7..8441f08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ __pycache__/ *.pyc .venv/ +*.db +.idea/ frontend/node_modules/ frontend/dist/ frontend/.vite/ diff --git a/CLAUDE.md b/CLAUDE.md index 30d42c7..317ae19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/api/__init__.py b/api/__init__.py index dc0ae2d..5ed2178 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -6,6 +6,9 @@ from json import JSONEncoder import socketio from bridzik import Bridzik, BridzikException, Card +from db.db import init_db +from api import auth as auth_module, history +from api.auth import AuthError def _env_bool(name: str, default: bool) -> bool: @@ -36,6 +39,8 @@ async def _health_app(scope, receive, send): while True: message = await receive() if message["type"] == "lifespan.startup": + await init_db() + await _restore_unfinished_games() await send({"type": "lifespan.startup.complete"}) elif message["type"] == "lifespan.shutdown": await send({"type": "lifespan.shutdown.complete"}) @@ -60,6 +65,10 @@ games: dict[str, "Game"] = {} # This is the source of truth for "who is acting" — never trust a client-supplied # player number. sessions: dict[str, dict] = {} +# Maps a live connection (sid) to its authenticated account: {"player_id": int, +# "username": str}. Set on login/confirm or on connect via the auth token. +# Required before a connection may create or join a game. +accounts: dict[str, dict] = {} class Game: @@ -85,10 +94,11 @@ class Game: class Player: - def __init__(self, sid: str, name: str, order: int): + def __init__(self, sid: str, name: str, order: int, player_id: int): self.sid = sid - self.name = name + self.name = name # display name == account username self.order = order + self.player_id = player_id # persistent account id (db.models.Player.id) self.token = str(uuid.uuid4()) # secret token used for secure reconnect self.connected = True @@ -110,7 +120,12 @@ def public_games() -> list: "name": g.name, "started": g.started, "players": [ - {"order": p.order, "name": p.name, "connected": p.connected} + { + "order": p.order, + "name": p.name, + "connected": p.connected, + "player_id": p.player_id, + } for p in g.players ], } @@ -128,6 +143,10 @@ async def send_game_status(gid: str): game = games[gid] core = game.bridzik_core last_round = core.series[-1].get_last_round() + status = json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder)) + # Use DB-backed standings so the score is correct even after a server restart + # (the engine only knows rounds completed since restart). + status["standings"] = await history.get_standings(gid) await sio.emit( "game_status", { @@ -141,7 +160,7 @@ async def send_game_status(gid: str): "series_number": core.series[-1].series_number, "round_number": last_round.round_number, "cards_in_round": 8 - last_round.round_number, # tricks == max bid - "status": json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder)), + "status": status, }, room=gid, ) @@ -184,16 +203,42 @@ def _active_game(sid: str) -> "tuple[Game, dict] | None": return game, sess +async def _restore_unfinished_games(): + """Po starte servera obnov rozohrate hry z DB do pamate (hraci offline). + + Karty su rozdane nanovo (pozicia z `series`/`round`); hraci sa vratia cez + `rejoin_game` podla svojej trvalej identity (per-hra tokeny restart neprezili). + """ + for info in await history.get_unfinished_games(): + if info["gid"] in games: + continue + game = Game(info["gid"], info["name"]) + game.bridzik_core = info["core"] + game.started = True + for seat, (pid, uname) in enumerate(info["seats"]): + player = Player(None, uname, seat, pid) + player.connected = False + game.players.append(player) + games[info["gid"]] = game + + # --- connection lifecycle ------------------------------------------------- @sio.event async def connect(sid, environ, auth=None): await sio.enter_room(sid, LOBBY) + # Auto-login via the session token the client stored after a previous login. + token = auth.get("token") if isinstance(auth, dict) else None + identity = await auth_module.player_by_token(token) + if identity is not None: + accounts[sid] = identity + await sio.emit("login", {"player": identity}, to=sid) await sio.emit("get_games", {"games": public_games()}, to=sid) @sio.event async def disconnect(sid): + accounts.pop(sid, None) sess = sessions.pop(sid, None) game = games.get(sess["gid"]) if sess else None if game is not None: @@ -203,10 +248,44 @@ async def disconnect(sid): await broadcast_lobby() +# --- authentication (TOTP) ------------------------------------------------ + +@sio.on("register_account") +async def register_account(sid, username): + try: + data = await auth_module.register_account(username) + except AuthError as exc: + return await send_error(sid, str(exc)) + # otpauth_uri -> the client renders it as a QR code to scan into the app. + await sio.emit("register_account", data, to=sid) + + +@sio.on("confirm_account") +async def confirm_account(sid, username, code): + try: + identity = await auth_module.confirm_account(username, code) + except AuthError as exc: + return await send_error(sid, str(exc)) + accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]} + await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid) + + +@sio.on("login") +async def login(sid, username, code): + try: + identity = await auth_module.login(username, code) + except AuthError as exc: + return await send_error(sid, str(exc)) + accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]} + await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid) + + # --- lobby ---------------------------------------------------------------- @sio.on("create_game") async def create_game(sid, name): + if sid not in accounts: + return await send_error(sid, "Musíte byť prihlásený.") gid = str(uuid.uuid4()) games[gid] = Game(gid, name) await sio.emit("create_game", {"gid": gid}, to=sid) @@ -219,7 +298,10 @@ async def get_games(sid, *args): @sio.on("register_player") -async def register_player(sid, gid, player_name): +async def register_player(sid, gid): + account = accounts.get(sid) + if account is None: + return await send_error(sid, "Musíte byť prihlásený.") if sid in sessions: return await send_error(sid, "Uz ste v hre.") game = games.get(gid) @@ -229,18 +311,20 @@ async def register_player(sid, gid, player_name): return await send_error(sid, "Hra uz zacala.") if len(game.players) >= 4: return await send_error(sid, "Prekroceny pocet hracov.") + if any(p.player_id == account["player_id"] for p in game.players): + return await send_error(sid, "Uz ste v tejto hre.") # Lowest free seat (robust if someone left the lobby before start). used = {p.order for p in game.players} order = next(o for o in range(4) if o not in used) - player = Player(sid, player_name, order) + player = Player(sid, account["username"], order, account["player_id"]) game.players.append(player) sessions[sid] = {"gid": gid, "order": order} await sio.enter_room(sid, gid) # The token is private to this player and required for a secure reconnect. await sio.emit( "register_player", - {"player": {"order": order, "name": player_name}, "token": player.token}, + {"player": {"order": order, "name": player.name}, "token": player.token}, to=sid, ) await broadcast_lobby() @@ -287,20 +371,49 @@ async def start_game(sid, gid): return await send_error(sid, "Nedostatocny pocet hracov.") game.start() + # Persist the game with its 4 seats (ordered 0..3) so history can attribute guesses. + seated = sorted(game.players, key=lambda p: p.order) + await history.record_game_started(gid, game.name, [p.player_id for p in seated]) await broadcast_lobby() await send_game_status(gid) for player in game.players: await send_player_cards(gid, player.order, player.sid) -@sio.on("reconnect_to_game") -async def reconnect_to_game(sid, gid, token): +@sio.on("end_game") +async def end_game(sid, gid): + """Host (seat 0) permanently ends a game that won't be finished. Marks it + ended in the DB (so it won't be restored) and sends everyone back to the lobby.""" + sess = sessions.get(sid) + if sess is None or sess["gid"] != gid: + return await send_error(sid, "Nie ste v tejto hre.") + if sess["order"] != 0: + return await send_error(sid, "Iba hostitel moze ukoncit hru.") game = games.get(gid) if game is None: return await send_error(sid, "Hra neexistuje.") + + await history.mark_game_ended(gid) + # Notify the room first (while players are still in it), then tear it down. + await sio.emit("game_ended", {"gid": gid}, room=gid) + for player in game.players: + if player.sid: + sessions.pop(player.sid, None) + await sio.leave_room(player.sid, gid) + del games[gid] + await broadcast_lobby() + + +@sio.on("reconnect_to_game") +async def reconnect_to_game(sid, gid, token): + # Best-effort background reconnect: fail silently (no error toast). After a + # server restart the old token is gone -> the user rejoins from the lobby. + game = games.get(gid) + if game is None: + return player = game.player_by_token(token) if player is None: - return await send_error(sid, "Neplatny token pre pripojenie.") + return old_sid = player.sid if old_sid != sid: @@ -321,6 +434,43 @@ async def reconnect_to_game(sid, gid, token): await broadcast_lobby() +@sio.on("rejoin_game") +async def rejoin_game(sid, gid): + """Re-seat into a game via the logged-in account (used after a server restart, + when per-game reconnect tokens are gone). Identity comes from the session.""" + account = accounts.get(sid) + if account is None: + return await send_error(sid, "Musíte byť prihlásený.") + if sid in sessions: + return await send_error(sid, "Uz ste v hre.") + game = games.get(gid) + if game is None: + return await send_error(sid, "Hra neexistuje.") + player = next( + (p for p in game.players if p.player_id == account["player_id"]), None + ) + if player is None: + return await send_error(sid, "Nie ste hracom tejto hry.") + + old_sid = player.sid + if old_sid and old_sid != sid: + sessions.pop(old_sid, None) + player.sid = sid + player.connected = True + sessions[sid] = {"gid": gid, "order": player.order} + await sio.enter_room(sid, gid) + await sio.emit( + "register_player", + {"player": {"order": player.order, "name": player.name}, "token": player.token}, + to=sid, + ) + if game.started: + await send_game_status(gid) + await send_player_cards(gid, player.order, sid) + await sio.emit("player_connection", {"order": player.order, "connected": True}, room=gid) + await broadcast_lobby() + + # --- in-game actions (seat derived from the connection, never the client) - @sio.on("game_status") @@ -376,6 +526,31 @@ async def play_card(sid, card_key): core.play_card(sess["order"], hand[key]) except BridzikException as exc: return await send_error(sid, str(exc)) + # Persist completed rounds first so the DB-backed standings in game_status are + # up to date (idempotent; also marks the game ended). + await history.record_completed_rounds(game.gid, core) await send_game_status(game.gid) for player in game.players: await send_player_cards(game.gid, player.order, player.sid) + + +# --- history (read-only) -------------------------------------------------- + +@sio.on("get_player_history") +async def get_player_history(sid, *args): + account = accounts.get(sid) + if account is None: + return await send_error(sid, "Musíte byť prihlásený.") + rows = await history.get_player_history(account["player_id"]) + await sio.emit("get_player_history", {"games": rows}, to=sid) + + +@sio.on("get_game_detail") +async def get_game_detail(sid, gid): + account = accounts.get(sid) + if account is None: + return await send_error(sid, "Musíte byť prihlásený.") + detail = await history.get_game_detail(gid) + if detail is None: + return await send_error(sid, "Hra neexistuje.") + await sio.emit("get_game_detail", detail, to=sid) diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..1853673 --- /dev/null +++ b/api/auth.py @@ -0,0 +1,100 @@ +"""Bezheslova autentifikacia cez TOTP (Google Authenticator a pod.). + +Domenova logika nad `db/` -- tenke socket handlery v api/__init__.py ju volaju. +Hodnoty sa overuju cez pyotp; replay sa bloku pomocou Player.totp_last_step. +""" + +import secrets +import time + +import pyotp +from sqlalchemy import select + +from db.db import async_session +from db.models import Player + +ISSUER = "Bridžik" +TOTP_PERIOD = 30 # sekund -- default pyotp + + +class AuthError(Exception): + """Chyba prihlasenia/registracie (slovenska sprava pre klienta).""" + + +def _new_token() -> str: + return secrets.token_urlsafe(48) + + +def _current_step() -> int: + return int(time.time()) // TOTP_PERIOD + + +def _verify_code(player: Player, code: str) -> None: + """Overi TOTP kod a posunie totp_last_step. Pri neuspechu vyhodi AuthError. + + Akceptuje +-1 casovy krok (tolerancia hodin) a odmietne uz pouzity krok. + """ + totp = pyotp.TOTP(player.totp_secret) + current = _current_step() + for step in (current - 1, current, current + 1): + if step <= player.totp_last_step: + continue + if totp.verify(code, for_time=step * TOTP_PERIOD): + player.totp_last_step = step + return + raise AuthError("Nesprávny alebo už použitý kód.") + + +async def register_account(username: str) -> dict: + """Zaregistruje meno a vygeneruje TOTP secret. Vrati otpauth URI pre QR.""" + username = (username or "").strip() + if not username: + raise AuthError("Zadajte meno.") + secret = pyotp.random_base32() + async with async_session() as session: + existing = await session.scalar( + select(Player).where(Player.username == username) + ) + if existing is not None: + raise AuthError("Toto meno je už obsadené.") + session.add(Player(username=username, totp_secret=secret)) + await session.commit() + otpauth_uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=ISSUER) + return {"username": username, "secret": secret, "otpauth_uri": otpauth_uri} + + +async def confirm_account(username: str, code: str) -> dict: + """Potvrdi registraciu prvym kodom z aplikacie a vrati session token.""" + return await _verify_and_issue_token(username, code) + + +async def login(username: str, code: str) -> dict: + """Prihlasi existujuci ucet a vrati session token.""" + return await _verify_and_issue_token(username, code) + + +async def _verify_and_issue_token(username: str, code: str) -> dict: + async with async_session() as session: + player = await session.scalar( + select(Player).where(Player.username == (username or "").strip()) + ) + if player is None: + raise AuthError("Účet neexistuje.") + _verify_code(player, (code or "").strip()) + token = _new_token() + player.auth_token = token + await session.commit() + return {"player_id": player.id, "username": player.username, "token": token} + + +async def player_by_token(token: str) -> dict | None: + """Overi session token (z Socket.IO `auth`). Vrati identitu alebo None.""" + if not token: + return None + async with async_session() as session: + player = await session.scalar( + select(Player).where(Player.auth_token == token) + ) + if player is None: + return None + return {"player_id": player.id, "username": player.username} diff --git a/api/history.py b/api/history.py new file mode 100644 index 0000000..a0c677d --- /dev/null +++ b/api/history.py @@ -0,0 +1,266 @@ +"""Zapis a citanie historie hier nad `db/`. + +Cita hodnoty z ciste-Python enginu (bridzik.Bridzik) a uklada ich do DB. +Engine sa neupravuje -- pouzivame len jeho existujuce metody. +""" + +from datetime import datetime, timezone +from random import shuffle + +from sqlalchemy import func, or_, select + +from bridzik import Bridzik, Round, Series +from db.db import async_session +from db.models import Game, Guess, Player + + +async def record_game_started(gid: str, name: str, player_ids: list[int]) -> None: + """Zapise riadok Game so 4 ID hracov (podla sedadla). Idempotentne.""" + async with async_session() as session: + if await session.get(Game, gid) is not None: + return + session.add( + Game( + id=gid, + name=name, + player0_id=player_ids[0], + player1_id=player_ids[1], + player2_id=player_ids[2], + player3_id=player_ids[3], + ) + ) + await session.commit() + + +async def record_completed_rounds(gid: str, core) -> None: + """Zapise 4 Guess-y za kazde nove dohrate kolo; po dohrani hry vyplni ended_at. + + Idempotentne: kola uz zapisane v DB sa preskakuju. + """ + async with async_session() as session: + game = await session.get(Game, gid) + if game is None: + return + + rows = await session.execute( + select(Guess.series_number, Guess.round_number) + .where(Guess.game_id == gid) + .distinct() + ) + already = {(r.series_number, r.round_number) for r in rows} + + for series in core.series: + for rnd in series.rounds: + if not rnd.is_completed(): + continue + key = (series.series_number, rnd.round_number) + if key in already: + continue + points = rnd.get_points_summary() # list[4], body za kolo + for seat in range(4): + session.add( + Guess( + game_id=gid, + player_id=game.player_id_for_seat(seat), + series_number=series.series_number, + round_number=rnd.round_number, + guess=rnd.guesses[seat], + points=points[seat], + ) + ) + + # Priebezne uloz aktualnu poziciu hry (na restore). + last_series = core.series[-1] + game.series = last_series.series_number + game.round = last_series.get_last_round().round_number + + if core.is_completed() and game.ended_at is None: + game.ended_at = datetime.now(timezone.utc) + + await session.commit() + + +async def get_standings(gid: str) -> list[list[list[int]]]: + """Body po seriach/kolach z DB v tvare, ktory caka frontend: standings[serie][kolo] + = [body sedadiel 0..3]. Pouziva sa v game_status, aby skore sedelo aj po restarte. + """ + async with async_session() as session: + game = await session.get(Game, gid) + if game is None: + return [] + seat_of = { + game.player0_id: 0, + game.player1_id: 1, + game.player2_id: 2, + game.player3_id: 3, + } + rows = ( + await session.scalars( + select(Guess) + .where(Guess.game_id == gid) + .order_by(Guess.series_number, Guess.round_number) + ) + ).all() + + series_map: dict[int, dict[int, list[int]]] = {} + for gz in rows: + rounds = series_map.setdefault(gz.series_number, {}) + points = rounds.setdefault(gz.round_number, [0, 0, 0, 0]) + seat = seat_of.get(gz.player_id) + if seat is not None: + points[seat] = gz.points + + return [ + [series_map[s][r] for r in sorted(series_map[s])] + for s in sorted(series_map) + ] + + +async def get_player_history(player_id: int) -> list[dict]: + """Zoznam hier daneho hraca (najnovsie prve) so sumarom jeho bodov.""" + async with async_session() as session: + stmt = ( + select(Game) + .where( + or_( + Game.player0_id == player_id, + Game.player1_id == player_id, + Game.player2_id == player_id, + Game.player3_id == player_id, + ) + ) + .order_by(Game.created_at.desc()) + ) + games = (await session.scalars(stmt)).all() + + result = [] + for g in games: + seat_ids = [g.player0_id, g.player1_id, g.player2_id, g.player3_id] + usernames = await _usernames_for(session, seat_ids) + total = await session.scalar( + select(func.coalesce(func.sum(Guess.points), 0)).where( + Guess.game_id == g.id, Guess.player_id == player_id + ) + ) + result.append( + { + "gid": g.id, + "created_at": g.created_at.isoformat() if g.created_at else None, + "ended_at": g.ended_at.isoformat() if g.ended_at else None, + "players": [usernames[pid] for pid in seat_ids], + "my_points": int(total or 0), + } + ) + return result + + +async def get_game_detail(gid: str) -> dict | None: + """Detail hry: tipy a body po kolach (won = points > 0).""" + async with async_session() as session: + game = await session.get(Game, gid) + if game is None: + return None + seat_ids = [game.player0_id, game.player1_id, game.player2_id, game.player3_id] + usernames = await _usernames_for(session, seat_ids) + + guesses = ( + await session.scalars( + select(Guess) + .where(Guess.game_id == gid) + .order_by(Guess.series_number, Guess.round_number, Guess.player_id) + ) + ).all() + + return { + "gid": game.id, + "created_at": game.created_at.isoformat() if game.created_at else None, + "ended_at": game.ended_at.isoformat() if game.ended_at else None, + "players": [ + {"player_id": pid, "username": usernames[pid]} for pid in seat_ids + ], + "rounds": [ + { + "series_number": gz.series_number, + "round_number": gz.round_number, + "player_id": gz.player_id, + "username": usernames.get(gz.player_id), + "guess": gz.guess, + "points": gz.points, + "won": gz.points > 0, + } + for gz in guesses + ], + } + + +async def _usernames_for(session, player_ids: list[int]) -> dict[int, str]: + rows = await session.scalars( + select(Player).where(Player.id.in_(set(player_ids))) + ) + return {p.id: p.username for p in rows} + + +# --- restore --------------------------------------------------------------- + +def rebuild_core(series_number: int, round_number: int, shuffler=shuffle) -> Bridzik: + """Postavi Bridzik na danu poziciu (zaciatok kola), karty rozda nanovo. + + Vsetko ostatne (rotacia first_player, pocet kariet) je deterministicke z + dvojice (series_number, round_number), takze tieto dve cisla staci na obnovu + hracej kostry. Tipy a rozohrate kopky aktualneho kola sa NEobnovuju -- kolo + sa zacne odznova; historicke body si vola get_game_detail z tabulky Guess. + """ + core = Bridzik(shuffler=shuffler) + # Doplnaj serie az po cielovu (kazda nova zacne svojim kolom 0). + while core.series[-1].series_number < series_number: + core.series.append(Series(len(core.series), shuffler=shuffler)) + # V poslednej serii doplnaj kola az po cielove (Round rozda karty nanovo). + last = core.series[-1] + while last.get_last_round().round_number < round_number: + rn = len(last.rounds) + last.rounds.append(Round(rn, (last.first_player + rn) % 4, shuffler=shuffler)) + return core + + +async def restore_game_core(gid: str, shuffler=shuffle) -> Bridzik | None: + """Nacita poziciu hry z DB a vrati obnoveny Bridzik (alebo None).""" + async with async_session() as session: + game = await session.get(Game, gid) + if game is None: + return None + return rebuild_core(game.series, game.round, shuffler=shuffler) + + +async def mark_game_ended(gid: str) -> None: + """Natrvalo ukonci hru (host ju zrusil, ked sa nedohra). Uz sa neobnovi.""" + async with async_session() as session: + game = await session.get(Game, gid) + if game is not None and game.ended_at is None: + game.ended_at = datetime.now(timezone.utc) + await session.commit() + + +async def get_unfinished_games() -> list[dict]: + """Nedohrate hry (ended_at IS NULL) aj s obnovenym jadrom -- na obnovu pri starte. + + Vracia per hru: gid, name, sedadla (player_id + username podla poradia 0..3) + a uz postaveny Bridzik na ulozenej pozicii. + """ + async with async_session() as session: + games = ( + await session.scalars(select(Game).where(Game.ended_at.is_(None))) + ).all() + + result = [] + for g in games: + seat_ids = [g.player0_id, g.player1_id, g.player2_id, g.player3_id] + usernames = await _usernames_for(session, seat_ids) + result.append( + { + "gid": g.id, + "name": g.name, + "seats": [(pid, usernames.get(pid, "?")) for pid in seat_ids], + "core": rebuild_core(g.series, g.round), + } + ) + return result diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..53cbe3a --- /dev/null +++ b/db/__init__.py @@ -0,0 +1,9 @@ +"""Datova vrstva (persistencia). Nezavisla od Socket.IO/Flask, ako engine. + +Exportuje pripojenie a ORM modely; aplikacna logika (api/) ich pouziva. +""" + +from db.db import Base, engine, async_session, init_db +from db import models + +__all__ = ["Base", "engine", "async_session", "init_db", "models"] diff --git a/db/db.py b/db/db.py new file mode 100644 index 0000000..6036984 --- /dev/null +++ b/db/db.py @@ -0,0 +1,35 @@ +"""Async SQLAlchemy pripojenie a inicializacia schemy. + +Connection string z env DATABASE_URL. Default je lokalny SQLite subor (dev); +v produkcii staci nastavit DATABASE_URL na PostgreSQL (asyncpg), kod sa nemeni. +""" + +import os + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase + +DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///bridzik.db") + + +class Base(DeclarativeBase): + pass + + +engine = create_async_engine(DATABASE_URL, echo=False) +async_session: async_sessionmaker[AsyncSession] = async_sessionmaker( + engine, expire_on_commit=False +) + + +async def init_db() -> None: + """Vytvori tabulky, ak este neexistuju. Vola sa pri starte servera.""" + # Import modelov registruje tabulky na Base.metadata. + from db import models # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/db/models.py b/db/models.py new file mode 100644 index 0000000..8a313bf --- /dev/null +++ b/db/models.py @@ -0,0 +1,85 @@ +"""ORM modely: Player, Game, Guess. + +Zamerne minimalne (3 tabulky). `won` sa neuklada -- vyplyva z `points > 0` +(trafeny tip = 10 + tip, inak 0; pozri Round.get_points_summary v bridzik.py). +""" + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from db.db import Base + + +class Player(Base): + """Trvaly ucet hraca + autentifikacia (TOTP).""" + + __tablename__ = "players" + + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(String(40), unique=True, index=True) + totp_secret: Mapped[str] = mapped_column(String(32)) + # Posledny pouzity TOTP casovy krok -- ochrana proti replay v ramci okna. + totp_last_step: Mapped[int] = mapped_column(Integer, default=0) + # Session token pre auto-reconnect (poslany v Socket.IO `auth`). + auth_token: Mapped[str | None] = mapped_column( + String(64), unique=True, nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now() + ) + + +class Game(Base): + """Jedna partia. Drzi priamo 4 ID hracov podla sedadla (0-3).""" + + __tablename__ = "games" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + name: Mapped[str] = mapped_column(String(40), default="") + player0_id: Mapped[int] = mapped_column(ForeignKey("players.id")) + player1_id: Mapped[int] = mapped_column(ForeignKey("players.id")) + player2_id: Mapped[int] = mapped_column(ForeignKey("players.id")) + player3_id: Mapped[int] = mapped_column(ForeignKey("players.id")) + # Aktualna pozicia hry. Spolu s rozdanim kariet nanovo plne urcuju stav hry + # po zaciatok kola -> staci na restore (pozri api/history.rebuild_core). + series: Mapped[int] = mapped_column(Integer, default=0) + round: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now() + ) + ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + def player_id_for_seat(self, seat: int) -> int: + return (self.player0_id, self.player1_id, self.player2_id, self.player3_id)[seat] + + +class Guess(Base): + """Tip a vysledok jedneho hraca v jednom kole. + + Unikat (game_id, series_number, round_number, player_id) robi zapis + idempotentnym -- opakovany `record_completed_rounds` nezaklada duplikaty. + """ + + __tablename__ = "guesses" + __table_args__ = ( + UniqueConstraint( + "game_id", + "series_number", + "round_number", + "player_id", + name="uq_guess_round_player", + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + game_id: Mapped[str] = mapped_column(ForeignKey("games.id"), index=True) + player_id: Mapped[int] = mapped_column(ForeignKey("players.id"), index=True) + series_number: Mapped[int] = mapped_column(Integer) + round_number: Mapped[int] = mapped_column(Integer) + guess: Mapped[int] = mapped_column(Integer) + points: Mapped[int] = mapped_column(Integer) diff --git a/docker-compose.yaml b/docker-compose.yaml index 617c5e1..55f456d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,14 +1,39 @@ services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: bridzik + POSTGRES_PASSWORD: bridzik + POSTGRES_DB: bridzik + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U bridzik -d bridzik"] + interval: 5s + timeout: 5s + retries: 5 + backend: build: . + environment: + # Async SQLAlchemy URL -> the Postgres service above (asyncpg driver). + DATABASE_URL: postgresql+asyncpg://bridzik:bridzik@db:5432/bridzik ports: - "5000:5000" volumes: - ./:/app + depends_on: + db: + condition: service_healthy frontend: image: node:22-alpine working_dir: /app + environment: + # Inside the compose network the backend is reachable as `backend`. + VITE_BACKEND_URL: http://backend:5000 volumes: - ./frontend:/app ports: @@ -16,3 +41,6 @@ services: command: sh -c "npm install && npm run dev -- --host" depends_on: - backend + +volumes: + pgdata: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d57a639..b88ffba 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "bridzik-frontend", "version": "0.1.0", "dependencies": { + "qrcode.react": "^4.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0", @@ -5353,6 +5354,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9750b7d..898bb40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "preview": "vite preview" }, "dependencies": { + "qrcode.react": "^4.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 248f47e..81a475e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,20 +1,24 @@ import { useEffect } from 'react'; -import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'; import { useGameStore } from './store/gameStore'; import { socket, emit } from './lib/socket'; import type { MyPlayer } from './types'; import GameList from './pages/GameList'; import Lobby from './pages/Lobby'; import GameTable from './pages/GameTable'; +import Auth from './pages/Auth'; +import History from './pages/History'; function AppInner() { const navigate = useNavigate(); + const location = useLocation(); + const account = useGameStore((s) => s.account); const myPlayer = useGameStore((s) => s.myPlayer); const gameStatus = useGameStore((s) => s.gameStatus); const error = useGameStore((s) => s.error); const clearError = useGameStore((s) => s.clearError); - // Reconnect on every socket connect event (handles auto-reconnects after network drops) + // Reconnect into an in-progress game on every socket connect (after network drops). useEffect(() => { const doReconnect = () => { const saved = localStorage.getItem('bridzik_player'); @@ -27,10 +31,20 @@ function AppInner() { return () => { socket.off('connect', doReconnect); }; }, []); - // Single authoritative navigation: derive target from store state - const targetRoute = myPlayer - ? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}` - : null; + // Single authoritative navigation: + // - not logged in → /auth + // - logged in & in a game → lobby/game + // - logged in, no game, on /auth (just logged in) → leave for the game list + // - logged in, no game, elsewhere → null (let the user navigate: list/history) + const onGameRoute = + location.pathname === '/auth' || + location.pathname.startsWith('/game') || + location.pathname.startsWith('/lobby'); + const targetRoute = !account + ? '/auth' + : myPlayer + ? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}` + : onGameRoute ? '/' : null; useEffect(() => { if (!targetRoute) return; @@ -52,7 +66,9 @@ function AppInner() { )} + } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/NameModal.tsx b/frontend/src/components/NameModal.tsx index f4c207d..fa114f3 100644 --- a/frontend/src/components/NameModal.tsx +++ b/frontend/src/components/NameModal.tsx @@ -3,16 +3,16 @@ import { useGameStore } from '../store/gameStore'; import { emit } from '../lib/socket'; interface Props { - mode: 'create' | 'join'; - gid?: string; onClose: () => void; } -export default function NameModal({ mode, gid, onClose }: Props) { - const [name, setName] = useState(localStorage.getItem('bridzik_name') ?? ''); +/** Prompt for a new game's name. Identity is taken from the logged-in session, + * so no player name is asked here. */ +export default function NameModal({ onClose }: Props) { + const [name, setName] = useState(''); const myPlayer = useGameStore((s) => s.myPlayer); - // Close modal once we have a player identity + // Close once we've joined the just-created game (create → register chain done). useEffect(() => { if (myPlayer) onClose(); }, [myPlayer, onClose]); @@ -21,29 +21,22 @@ export default function NameModal({ mode, gid, onClose }: Props) { e.preventDefault(); const trimmed = name.trim(); if (!trimmed) return; - localStorage.setItem('bridzik_name', trimmed); - - if (mode === 'join' && gid) { - // emit.registerPlayer sets _pendingGid so the listener can resolve gid - emit.registerPlayer(gid, trimmed); - } else { - // emit.createGame stores the name; the socket listener auto-chains registerPlayer - emit.createGame(trimmed); - } + // Stores the name; the socket listener auto-chains register_player. + emit.createGame(trimmed); }; return (
-

Zadaj svoje meno

+

Nazov hry

setName(e.target.value)} - maxLength={20} - placeholder="Tvoje meno" + maxLength={30} + placeholder="Napr. Vecerna partia" className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500" />
@@ -59,7 +52,7 @@ export default function NameModal({ mode, gid, onClose }: Props) { disabled={!name.trim()} className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-semibold disabled:opacity-40" > - Potvrdit + Vytvorit
diff --git a/frontend/src/lib/socket.ts b/frontend/src/lib/socket.ts index bccebb2..6490d23 100644 --- a/frontend/src/lib/socket.ts +++ b/frontend/src/lib/socket.ts @@ -1,23 +1,54 @@ import { io } from 'socket.io-client'; import { useGameStore } from '../store/gameStore'; -import type { GameInfo, GameStatusPayload, Hand, MyPlayer } from '../types'; +import type { + Account, + GameDetail, + GameInfo, + GameStatusPayload, + Hand, + HistoryGame, + MyPlayer, + Registration, +} from '../types'; export const socket = io({ autoConnect: false }); +/** Attach (or clear) the session token sent in the Socket.IO `auth` handshake + * so the server can auto-login this connection (now and on every reconnect). */ +export function setAuthToken(token: string | null) { + socket.auth = token ? { token } : {}; +} + // Module-level state for the create→register chain and gid resolution on register_player let _pendingGid: string | null = null; let _createName: string | null = null; export const emit = { + // auth + registerAccount: (username: string) => socket.emit('register_account', username), + confirmAccount: (username: string, code: string) => + socket.emit('confirm_account', username, code), + login: (username: string, code: string) => socket.emit('login', username, code), + // history + getPlayerHistory: () => socket.emit('get_player_history'), + getGameDetail: (gid: string) => socket.emit('get_game_detail', gid), + // lobby / game createGame: (name: string) => { _createName = name; socket.emit('create_game', name); }, - registerPlayer: (gid: string, name: string) => { + registerPlayer: (gid: string) => { _pendingGid = gid; - socket.emit('register_player', gid, name); + socket.emit('register_player', gid); + }, + // Re-seat into an already-started game via the logged-in account (e.g. after + // a server restart). The server replies with register_player + game_status. + rejoinGame: (gid: string) => { + _pendingGid = gid; + socket.emit('rejoin_game', gid); }, leaveGame: () => socket.emit('leave_game'), + endGame: (gid: string) => socket.emit('end_game', gid), startGame: (gid: string) => socket.emit('start_game', gid), reconnectToGame: (gid: string, token: string) => socket.emit('reconnect_to_game', gid, token), gameStatus: () => socket.emit('game_status'), @@ -31,10 +62,34 @@ export function setupSocketListeners() { useGameStore.getState().setGames(games); }); + // Registration step 1: server returns the otpauth URI to render as a QR code. + socket.on('register_account', (data: Registration) => { + useGameStore.getState().setRegistration(data); + }); + + // Login / confirm / auto-login. A token is present on explicit login (persist it); + // auto-login on connect omits it (we keep the stored one). + socket.on('login', ({ player, token }: { player: Account; token?: string }) => { + if (token) { + localStorage.setItem('bridzik_token', token); + setAuthToken(token); + } + useGameStore.getState().setAccount(player); + useGameStore.getState().setRegistration(null); + }); + + socket.on('get_player_history', ({ games }: { games: HistoryGame[] }) => { + useGameStore.getState().setHistory(games); + }); + + socket.on('get_game_detail', (detail: GameDetail) => { + useGameStore.getState().setGameDetail(detail); + }); + socket.on('create_game', ({ gid }: { gid: string }) => { - // Auto-chain: register into the just-created game using the stored name + // Auto-chain: register into the just-created game (identity comes from the session). if (_createName !== null) { - emit.registerPlayer(gid, _createName); + emit.registerPlayer(gid); _createName = null; } }); @@ -55,6 +110,12 @@ export function setupSocketListeners() { useGameStore.getState().setGameStatus(payload); }); + // Host ended the game permanently -> drop local game state, back to lobby. + socket.on('game_ended', () => { + useGameStore.getState().reset(); + localStorage.removeItem('bridzik_player'); + }); + socket.on('player_cards', ({ cards }: { cards: Hand }) => { useGameStore.getState().setHand(cards); }); @@ -66,4 +127,9 @@ export function setupSocketListeners() { socket.on('error', ({ error }: { error: string }) => { useGameStore.getState().setError(error); }); + + // Surface connection failures instead of silently buffering emits. + socket.on('connect_error', (err: Error) => { + useGameStore.getState().setError(`Spojenie so serverom zlyhalo: ${err.message}`); + }); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 83c92f1..a002937 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,9 +2,11 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import './index.css'; -import { setupSocketListeners, socket } from './lib/socket'; +import { setupSocketListeners, socket, setAuthToken } from './lib/socket'; setupSocketListeners(); +// Carry the stored session token into the handshake so the server auto-logs us in. +setAuthToken(localStorage.getItem('bridzik_token')); socket.connect(); ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx new file mode 100644 index 0000000..20945c0 --- /dev/null +++ b/frontend/src/pages/Auth.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react'; +import { QRCodeSVG } from 'qrcode.react'; +import { useGameStore } from '../store/gameStore'; +import { emit } from '../lib/socket'; + +type Mode = 'login' | 'register'; + +export default function Auth() { + const [mode, setMode] = useState('login'); + const [username, setUsername] = useState(localStorage.getItem('bridzik_name') ?? ''); + const [code, setCode] = useState(''); + const registration = useGameStore((s) => s.registration); + const setRegistration = useGameStore((s) => s.setRegistration); + + const remember = (name: string) => localStorage.setItem('bridzik_name', name.trim()); + + const handleLogin = (e: React.FormEvent) => { + e.preventDefault(); + if (!username.trim() || code.trim().length < 6) return; + remember(username); + emit.login(username.trim(), code.trim()); + }; + + const handleRegisterStart = (e: React.FormEvent) => { + e.preventDefault(); + if (!username.trim()) return; + remember(username); + emit.registerAccount(username.trim()); + }; + + const handleConfirm = (e: React.FormEvent) => { + e.preventDefault(); + if (code.trim().length < 6) return; + emit.confirmAccount(username.trim(), code.trim()); + }; + + const switchMode = (m: Mode) => { + setMode(m); + setCode(''); + setRegistration(null); + }; + + return ( +
+

Bridzik

+

+ Prihlas sa kodom z aplikacie (napr. Google Authenticator). +

+ +
+ + +
+ + {mode === 'login' && ( +
+ setUsername(e.target.value)} + maxLength={40} + placeholder="Pouzivatelske meno" + className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500" + /> + setCode(e.target.value.replace(/\D/g, ''))} + maxLength={6} + placeholder="6-miestny kod" + className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500 tracking-widest font-mono" + /> + +
+ )} + + {mode === 'register' && !registration && ( +
+ setUsername(e.target.value)} + maxLength={40} + placeholder="Zvol si pouzivatelske meno" + className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+ )} + + {mode === 'register' && registration && ( +
+

+ Naskenuj QR kod do autentifikacnej aplikacie a opis aktualny kod. +

+
+ +
+
+ Alebo zadaj rucne kluc: + + {registration.secret} + +
+
+ setCode(e.target.value.replace(/\D/g, ''))} + maxLength={6} + placeholder="6-miestny kod" + className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500 tracking-widest font-mono" + /> + +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/GameList.tsx b/frontend/src/pages/GameList.tsx index 577d02a..ea9ade8 100644 --- a/frontend/src/pages/GameList.tsx +++ b/frontend/src/pages/GameList.tsx @@ -1,18 +1,42 @@ import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useGameStore } from '../store/gameStore'; +import { emit, socket, setAuthToken } from '../lib/socket'; import NameModal from '../components/NameModal'; import RulesModal from '../components/RulesModal'; -type ModalState = { mode: 'create' } | { mode: 'join'; gid: string } | null; - export default function GameList() { + const navigate = useNavigate(); const games = useGameStore((s) => s.games); - const [modal, setModal] = useState(null); + const account = useGameStore((s) => s.account); + const [showCreate, setShowCreate] = useState(false); const [showRules, setShowRules] = useState(false); + const handleLogout = () => { + localStorage.removeItem('bridzik_token'); + localStorage.removeItem('bridzik_player'); + setAuthToken(null); + useGameStore.getState().logout(); + // Drop the authenticated server-side session for this connection. + socket.disconnect(); + socket.connect(); + navigate('/auth', { replace: true }); + }; + return (
-

Bridzik

+
+

Bridzik

+
+ {account?.username} + + +
+
{games.length === 0 && ( @@ -20,7 +44,11 @@ export default function GameList() { )} {games.map((g) => { const full = g.players.length >= 4; - const unavailable = full || g.started; + const isMember = + !!account && g.players.some((p) => p.player_id === account.player_id); + const canResume = g.started && isMember; + const unavailable = !canResume && (full || g.started); + const label = canResume ? 'Pokracovat' : full ? 'Plna' : g.started ? 'Zacata' : 'Vstup'; return (
); @@ -46,8 +76,8 @@ export default function GameList() {
@@ -61,13 +91,7 @@ export default function GameList() { {showRules && setShowRules(false)} />} - {modal && ( - setModal(null)} - /> - )} + {showCreate && setShowCreate(false)} />}
); } diff --git a/frontend/src/pages/GameTable.tsx b/frontend/src/pages/GameTable.tsx index 2f2caa1..bcae8c6 100644 --- a/frontend/src/pages/GameTable.tsx +++ b/frontend/src/pages/GameTable.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useGameStore } from '../store/gameStore'; +import { emit } from '../lib/socket'; import { leaveGame } from '../lib/leaveGame'; import { computePlayable } from '../lib/gameRules'; import Hand from '../components/Hand'; @@ -68,6 +69,11 @@ export default function GameTable() { const activePlayerName = players.find((p) => p.order === active_player)?.name ?? ''; const handleLeave = () => leaveGame(navigate); + const handleEnd = () => { + if (window.confirm('Naozaj ukoncit celu hru pre vsetkych?')) { + emit.endGame(gameStatus.gid); + } + }; return (
@@ -77,9 +83,16 @@ export default function GameTable() { Seria {series_number} / Kolo {round_number + 1} ({cards_in_round} kopok)
- +
+ {myOrder === 0 && ( + + )} + +
{/* Turn indicator */} diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx new file mode 100644 index 0000000..8ac3daf --- /dev/null +++ b/frontend/src/pages/History.tsx @@ -0,0 +1,97 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useGameStore } from '../store/gameStore'; +import { emit } from '../lib/socket'; + +function fmtDate(iso: string | null): string { + if (!iso) return '—'; + const d = new Date(iso); + return isNaN(d.getTime()) ? '—' : d.toLocaleString('sk-SK'); +} + +export default function History() { + const navigate = useNavigate(); + const history = useGameStore((s) => s.history); + const detail = useGameStore((s) => s.gameDetail); + const setGameDetail = useGameStore((s) => s.setGameDetail); + + useEffect(() => { + emit.getPlayerHistory(); + return () => setGameDetail(null); + }, [setGameDetail]); + + // --- detail view --- + if (detail) { + return ( +
+ +

Detail hry

+

{fmtDate(detail.created_at)}

+ +
+
+ S/K + Hrac + Tip + Body +
+ {detail.rounds.map((r, i) => ( +
+ + {r.series_number}/{r.round_number} + + {r.username ?? `#${r.player_id}`} + {r.guess} + + {r.points} + +
+ ))} +
+
+ ); + } + + // --- list view --- + return ( +
+
+

Moja historia

+ +
+ + {history.length === 0 && ( +

Zatial ziadne odohrane hry.

+ )} + +
+ {history.map((g) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/store/gameStore.ts b/frontend/src/store/gameStore.ts index 920b06e..ba0a2ba 100644 --- a/frontend/src/store/gameStore.ts +++ b/frontend/src/store/gameStore.ts @@ -1,14 +1,31 @@ import { create } from 'zustand'; -import type { GameInfo, GameStatusPayload, Hand, MyPlayer } from '../types'; +import type { + Account, + GameDetail, + GameInfo, + GameStatusPayload, + Hand, + HistoryGame, + MyPlayer, + Registration, +} from '../types'; interface GameStore { games: GameInfo[]; + account: Account | null; + registration: Registration | null; + history: HistoryGame[]; + gameDetail: GameDetail | null; myPlayer: MyPlayer | null; gameStatus: GameStatusPayload | null; hand: Hand; error: string | null; setGames: (games: GameInfo[]) => void; + setAccount: (account: Account | null) => void; + setRegistration: (registration: Registration | null) => void; + setHistory: (history: HistoryGame[]) => void; + setGameDetail: (detail: GameDetail | null) => void; setMyPlayer: (player: MyPlayer | null) => void; setGameStatus: (status: GameStatusPayload) => void; setHand: (hand: Hand) => void; @@ -16,16 +33,25 @@ interface GameStore { clearError: () => void; updatePlayerConnection: (order: number, connected: boolean) => void; reset: () => void; + logout: () => void; } export const useGameStore = create((set) => ({ games: [], + account: null, + registration: null, + history: [], + gameDetail: null, myPlayer: null, gameStatus: null, hand: {}, error: null, setGames: (games) => set({ games }), + setAccount: (account) => set({ account }), + setRegistration: (registration) => set({ registration }), + setHistory: (history) => set({ history }), + setGameDetail: (gameDetail) => set({ gameDetail }), setMyPlayer: (myPlayer) => set({ myPlayer }), setGameStatus: (gameStatus) => set({ gameStatus }), setHand: (hand) => set({ hand }), @@ -43,4 +69,15 @@ export const useGameStore = create((set) => ({ : null, })), reset: () => set({ myPlayer: null, gameStatus: null, hand: {}, error: null }), + logout: () => + set({ + account: null, + registration: null, + history: [], + gameDetail: null, + myPlayer: null, + gameStatus: null, + hand: {}, + error: null, + }), })); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7c66b83..a3aacda 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -10,6 +10,7 @@ export interface PlayerInfo { order: number; name: string; connected: boolean; + player_id?: number; } export interface MyPlayer { @@ -51,3 +52,44 @@ export interface GameInfo { } export type Hand = Record; + +// --- authentication (TOTP) --- + +export interface Account { + player_id: number; + username: string; +} + +export interface Registration { + username: string; + secret: string; + otpauth_uri: string; +} + +// --- history --- + +export interface HistoryGame { + gid: string; + created_at: string | null; + ended_at: string | null; + players: string[]; + my_points: number; +} + +export interface GameDetailRound { + series_number: number; + round_number: number; + player_id: number; + username: string | null; + guess: number; + points: number; + won: boolean; +} + +export interface GameDetail { + gid: string; + created_at: string | null; + ended_at: string | null; + players: { player_id: number; username: string }[]; + rounds: GameDetailRound[]; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7dff6e0..ba68e27 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -27,7 +27,9 @@ export default defineConfig({ host: true, proxy: { '/socket.io': { - target: 'http://backend:5000', + // Local dev defaults to localhost; docker-compose sets VITE_BACKEND_URL + // to the backend service (http://backend:5000). + target: process.env.VITE_BACKEND_URL ?? 'http://localhost:5000', ws: true, changeOrigin: true, }, diff --git a/requirements.txt b/requirements.txt index 7d6fbbf..6226888 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,12 @@ python-socketio>=5.11 python-engineio>=4.9 uvicorn>=0.30 +# Persistence layer (history + TOTP auth). +SQLAlchemy[asyncio]>=2.0 +aiosqlite>=0.20 # dev / default DATABASE_URL +asyncpg>=0.29 # production (PostgreSQL) +pyotp>=2.9 # TOTP login + # NOTE: the legacy Flask/Jinja HTTP UI (api/routes.py, api/forms.py, # api/templates/) is dormant and its dependencies (Flask, Flask-WTF, etc.) # were removed during the ASGI migration. Re-add them only if that UI is revived. diff --git a/tests_history.py b/tests_history.py new file mode 100644 index 0000000..53d408b --- /dev/null +++ b/tests_history.py @@ -0,0 +1,180 @@ +"""Testy persistentnej vrstvy (db/ + api/history.py + api/auth.py). + +Bezia na docasnom SQLite subore. Engine sa nepouziva priamo -- pre zapisovu +logiku staci lahky stub, ktory zrkadli rozhranie bridzik.Bridzik (series -> +rounds -> guesses/get_points_summary). Cisty engine ma vlastne testy v tests.py. +""" + +import asyncio +import os +import tempfile +import unittest +import uuid +from types import SimpleNamespace + +# Nastav DB PRED importom db/api modulov -- engine sa vytvara pri importe. +_DB_FILE = os.path.join(tempfile.gettempdir(), f"bridzik_test_{uuid.uuid4().hex}.db") +os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///" + _DB_FILE.replace("\\", "/") + +import pyotp # noqa: E402 + +from api import auth, history # noqa: E402 +from db.db import init_db # noqa: E402 + + +def run(coro): + return asyncio.run(coro) + + +def make_core(completed=True): + """Stub jednej hry s jednym dohratym kolom (seria 0, kolo 0).""" + rnd = SimpleNamespace( + round_number=0, + guesses={0: 2, 1: 1, 2: 0, 3: 1}, + is_completed=lambda: True, + get_points_summary=lambda: [12, 0, 10, 11], + ) + series = SimpleNamespace( + series_number=0, rounds=[rnd], get_last_round=lambda: rnd + ) + return SimpleNamespace(series=[series], is_completed=lambda: completed) + + +class HistoryCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + run(init_db()) + + def _make_players(self, n=4): + ids = [] + for _ in range(n): + username = "u_" + uuid.uuid4().hex[:8] + data = run(auth.register_account(username)) + ident = run(auth.login(username, pyotp.TOTP(data["secret"]).now())) + ids.append(ident["player_id"]) + return ids + + def test_register_login_token(self): + username = "alice_" + uuid.uuid4().hex[:6] + data = run(auth.register_account(username)) + self.assertIn("otpauth_uri", data) + + # Zle meno je obsadene + with self.assertRaises(auth.AuthError): + run(auth.register_account(username)) + + code = pyotp.TOTP(data["secret"]).now() + ident = run(auth.login(username, code)) + self.assertEqual(ident["username"], username) + self.assertTrue(ident["token"]) + + # Token sa da spatne rozlustit na identitu + resolved = run(auth.player_by_token(ident["token"])) + self.assertEqual(resolved["player_id"], ident["player_id"]) + + # Zly kod neprejde + with self.assertRaises(auth.AuthError): + run(auth.login(username, "000000")) + + def test_record_rounds_and_idempotency(self): + ids = self._make_players() + gid = str(uuid.uuid4()) + core = make_core() + + run(history.record_game_started(gid, "Test", ids)) + run(history.record_completed_rounds(gid, core)) + # Opakovany zapis nesmie zalozit duplikaty. + run(history.record_completed_rounds(gid, core)) + + detail = run(history.get_game_detail(gid)) + self.assertIsNotNone(detail) + self.assertEqual(len(detail["rounds"]), 4) # 4 hraci x 1 kolo + + by_seat = {r["player_id"]: r for r in detail["rounds"]} + # Body podla get_points_summary; won = points > 0. + self.assertEqual(by_seat[ids[0]]["points"], 12) + self.assertTrue(by_seat[ids[0]]["won"]) + self.assertEqual(by_seat[ids[1]]["points"], 0) + self.assertFalse(by_seat[ids[1]]["won"]) + self.assertIsNotNone(detail["ended_at"]) # core.is_completed() -> ended + + def test_rebuild_core_position(self): + # Z dvoch cisel sa obnovi spravna pozicia a karty sa rozdaju nanovo. + core = history.rebuild_core(2, 3) + self.assertEqual(core.series[-1].series_number, 2) + last_round = core.series[-1].get_last_round() + self.assertEqual(last_round.round_number, 3) + # V kole 3 ma kazdy hrac 8 - 3 = 5 kariet. + self.assertEqual(len(core.get_player_cards(0)), 5) + + def test_restore_game_from_db(self): + ids = self._make_players() + gid = str(uuid.uuid4()) + run(history.record_game_started(gid, "Test", ids)) + run(history.record_completed_rounds(gid, make_core())) # zapise poziciu + + restored = run(history.restore_game_core(gid)) + self.assertIsNotNone(restored) + # make_core() je seria 0, kolo 0 -> pozicia 0/0 + self.assertEqual(restored.series[-1].series_number, 0) + self.assertEqual(restored.series[-1].get_last_round().round_number, 0) + + def test_unfinished_games_listed(self): + ids = self._make_players() + gid = str(uuid.uuid4()) + run(history.record_game_started(gid, "Nedohrata", ids)) + run(history.record_completed_rounds(gid, make_core(completed=False))) + + rows = run(history.get_unfinished_games()) + mine = next((g for g in rows if g["gid"] == gid), None) + self.assertIsNotNone(mine) + self.assertEqual(mine["name"], "Nedohrata") + self.assertEqual(len(mine["seats"]), 4) + self.assertEqual(mine["seats"][0][0], ids[0]) # player_id na sedadle 0 + self.assertIsNotNone(mine["core"]) # uz postaveny Bridzik + + def test_mark_game_ended_removes_from_unfinished(self): + ids = self._make_players() + gid = str(uuid.uuid4()) + run(history.record_game_started(gid, "Vzdana", ids)) + run(history.record_completed_rounds(gid, make_core(completed=False))) + self.assertTrue(any(g["gid"] == gid for g in run(history.get_unfinished_games()))) + + run(history.mark_game_ended(gid)) + self.assertFalse(any(g["gid"] == gid for g in run(history.get_unfinished_games()))) + + def test_standings_from_db(self): + ids = self._make_players() + gid = str(uuid.uuid4()) + run(history.record_game_started(gid, "Test", ids)) + run(history.record_completed_rounds(gid, make_core())) + + standings = run(history.get_standings(gid)) + # 1 seria, 1 kolo, body podla sedadiel zo stubu [12, 0, 10, 11]. + self.assertEqual(standings, [[[12, 0, 10, 11]]]) + + def test_player_history(self): + ids = self._make_players() + gid = str(uuid.uuid4()) + run(history.record_game_started(gid, "Test", ids)) + run(history.record_completed_rounds(gid, make_core())) + + rows = run(history.get_player_history(ids[0])) + self.assertTrue(any(g["gid"] == gid for g in rows)) + mine = next(g for g in rows if g["gid"] == gid) + self.assertEqual(mine["my_points"], 12) + self.assertEqual(len(mine["players"]), 4) + + +def tearDownModule(): + from db.db import engine + + run(engine.dispose()) + try: + os.remove(_DB_FILE) + except OSError: + pass + + +if __name__ == "__main__": + unittest.main()