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:
Tim
2026-06-23 23:09:50 +02:00
parent beaf142ee4
commit 30c32b7714
24 changed files with 1446 additions and 87 deletions
+2
View File
@@ -1,6 +1,8 @@
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/ .venv/
*.db
.idea/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
frontend/.vite/ frontend/.vite/
+38 -25
View File
@@ -4,32 +4,34 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is ## 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 ## Commands
```powershell ```powershell
# Run the app (starts on 0.0.0.0:5000) # Run the backend (ASGI Socket.IO on 0.0.0.0:5000)
python -m app # or: python app.py / python start.py — all just import `api` 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 up --build
docker-compose down -v # also drops the pg volume — needed after a schema change
# Run the full test suite (pure-engine unittest) # Tests
python -m unittest tests -v python -m unittest tests -v # pure-engine unittest (no deps)
python -m unittest tests_history -v # persistence + auth (needs sqlalchemy/aiosqlite/pyotp)
# Run a single test case / method python -m unittest tests.StashCase.test_get_winner # single method
python -m unittest tests.StashCase
python -m unittest tests.StashCase.test_get_winner
``` ```
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 ## 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`**. - **`Bridzik`** — a whole game = exactly **4 `Series`**.
- **`Series`** — exactly **8 `Round`s**; the starting player rotates per series/round. - **`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`). - **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`. - 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`. - **`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.
- Known WIP/fixme spots: `create_game` hardcodes `gid = 'a'`; `play_card` references `get_player_cards` without calling it correctly. Expect rough edges here. - **`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 & 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.14-slim** and runs `uvicorn api:app`.
- `Dockerfile` targets Python 3.9, runs `python -m app`, and bundles `debugpy` for remote debugging on port 5678. - `docker-compose.yaml` runs **PostgreSQL** (`db`, with a `pgdata` volume + healthcheck), the backend (`DATABASE_URL` → that Postgres), and the frontend.
- `requirements.txt` pins `Flask-SocketIO` + `eventlet` (the chosen `async_mode`); the trailing `socket-cli` is marked for removal. - `requirements.txt`: `python-socketio`, `uvicorn`, `SQLAlchemy[asyncio]`, `aiosqlite` (dev), `asyncpg` (prod), `pyotp`.
## Conventions ## 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. - New game-rule logic belongs in `bridzik.py` with a `unittest` case in `tests.py` — keep the engine free of Flask/Socket.IO/DB.
- Raise `BridzikException` (Slovak message) for rule violations; the API layer catches it and re-emits a generic Slovak error to the client. - 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.
+185 -10
View File
@@ -6,6 +6,9 @@ from json import JSONEncoder
import socketio import socketio
from bridzik import Bridzik, BridzikException, Card 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: def _env_bool(name: str, default: bool) -> bool:
@@ -36,6 +39,8 @@ async def _health_app(scope, receive, send):
while True: while True:
message = await receive() message = await receive()
if message["type"] == "lifespan.startup": if message["type"] == "lifespan.startup":
await init_db()
await _restore_unfinished_games()
await send({"type": "lifespan.startup.complete"}) await send({"type": "lifespan.startup.complete"})
elif message["type"] == "lifespan.shutdown": elif message["type"] == "lifespan.shutdown":
await send({"type": "lifespan.shutdown.complete"}) 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 # This is the source of truth for "who is acting" — never trust a client-supplied
# player number. # player number.
sessions: dict[str, dict] = {} 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: class Game:
@@ -85,10 +94,11 @@ class Game:
class Player: 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.sid = sid
self.name = name self.name = name # display name == account username
self.order = order 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.token = str(uuid.uuid4()) # secret token used for secure reconnect
self.connected = True self.connected = True
@@ -110,7 +120,12 @@ def public_games() -> list:
"name": g.name, "name": g.name,
"started": g.started, "started": g.started,
"players": [ "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 for p in g.players
], ],
} }
@@ -128,6 +143,10 @@ async def send_game_status(gid: str):
game = games[gid] game = games[gid]
core = game.bridzik_core core = game.bridzik_core
last_round = core.series[-1].get_last_round() 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( await sio.emit(
"game_status", "game_status",
{ {
@@ -141,7 +160,7 @@ async def send_game_status(gid: str):
"series_number": core.series[-1].series_number, "series_number": core.series[-1].series_number,
"round_number": last_round.round_number, "round_number": last_round.round_number,
"cards_in_round": 8 - last_round.round_number, # tricks == max bid "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, room=gid,
) )
@@ -184,16 +203,42 @@ def _active_game(sid: str) -> "tuple[Game, dict] | None":
return game, sess 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 ------------------------------------------------- # --- connection lifecycle -------------------------------------------------
@sio.event @sio.event
async def connect(sid, environ, auth=None): async def connect(sid, environ, auth=None):
await sio.enter_room(sid, LOBBY) 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) await sio.emit("get_games", {"games": public_games()}, to=sid)
@sio.event @sio.event
async def disconnect(sid): async def disconnect(sid):
accounts.pop(sid, None)
sess = sessions.pop(sid, None) sess = sessions.pop(sid, None)
game = games.get(sess["gid"]) if sess else None game = games.get(sess["gid"]) if sess else None
if game is not None: if game is not None:
@@ -203,10 +248,44 @@ async def disconnect(sid):
await broadcast_lobby() 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 ---------------------------------------------------------------- # --- lobby ----------------------------------------------------------------
@sio.on("create_game") @sio.on("create_game")
async def create_game(sid, name): 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()) gid = str(uuid.uuid4())
games[gid] = Game(gid, name) games[gid] = Game(gid, name)
await sio.emit("create_game", {"gid": gid}, to=sid) await sio.emit("create_game", {"gid": gid}, to=sid)
@@ -219,7 +298,10 @@ async def get_games(sid, *args):
@sio.on("register_player") @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: if sid in sessions:
return await send_error(sid, "Uz ste v hre.") return await send_error(sid, "Uz ste v hre.")
game = games.get(gid) game = games.get(gid)
@@ -229,18 +311,20 @@ async def register_player(sid, gid, player_name):
return await send_error(sid, "Hra uz zacala.") return await send_error(sid, "Hra uz zacala.")
if len(game.players) >= 4: if len(game.players) >= 4:
return await send_error(sid, "Prekroceny pocet hracov.") 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). # Lowest free seat (robust if someone left the lobby before start).
used = {p.order for p in game.players} used = {p.order for p in game.players}
order = next(o for o in range(4) if o not in used) 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) game.players.append(player)
sessions[sid] = {"gid": gid, "order": order} sessions[sid] = {"gid": gid, "order": order}
await sio.enter_room(sid, gid) await sio.enter_room(sid, gid)
# The token is private to this player and required for a secure reconnect. # The token is private to this player and required for a secure reconnect.
await sio.emit( await sio.emit(
"register_player", "register_player",
{"player": {"order": order, "name": player_name}, "token": player.token}, {"player": {"order": order, "name": player.name}, "token": player.token},
to=sid, to=sid,
) )
await broadcast_lobby() await broadcast_lobby()
@@ -287,20 +371,49 @@ async def start_game(sid, gid):
return await send_error(sid, "Nedostatocny pocet hracov.") return await send_error(sid, "Nedostatocny pocet hracov.")
game.start() 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 broadcast_lobby()
await send_game_status(gid) await send_game_status(gid)
for player in game.players: for player in game.players:
await send_player_cards(gid, player.order, player.sid) await send_player_cards(gid, player.order, player.sid)
@sio.on("reconnect_to_game") @sio.on("end_game")
async def reconnect_to_game(sid, gid, token): 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) game = games.get(gid)
if game is None: if game is None:
return await send_error(sid, "Hra neexistuje.") 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) player = game.player_by_token(token)
if player is None: if player is None:
return await send_error(sid, "Neplatny token pre pripojenie.") return
old_sid = player.sid old_sid = player.sid
if old_sid != sid: if old_sid != sid:
@@ -321,6 +434,43 @@ async def reconnect_to_game(sid, gid, token):
await broadcast_lobby() 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) - # --- in-game actions (seat derived from the connection, never the client) -
@sio.on("game_status") @sio.on("game_status")
@@ -376,6 +526,31 @@ async def play_card(sid, card_key):
core.play_card(sess["order"], hand[key]) core.play_card(sess["order"], hand[key])
except BridzikException as exc: except BridzikException as exc:
return await send_error(sid, str(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) await send_game_status(game.gid)
for player in game.players: for player in game.players:
await send_player_cards(game.gid, player.order, player.sid) 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)
+100
View File
@@ -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}
+266
View File
@@ -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
+9
View File
@@ -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"]
+35
View File
@@ -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)
+85
View File
@@ -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)
+28
View File
@@ -1,14 +1,39 @@
services: 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: backend:
build: . build: .
environment:
# Async SQLAlchemy URL -> the Postgres service above (asyncpg driver).
DATABASE_URL: postgresql+asyncpg://bridzik:bridzik@db:5432/bridzik
ports: ports:
- "5000:5000" - "5000:5000"
volumes: volumes:
- ./:/app - ./:/app
depends_on:
db:
condition: service_healthy
frontend: frontend:
image: node:22-alpine image: node:22-alpine
working_dir: /app working_dir: /app
environment:
# Inside the compose network the backend is reachable as `backend`.
VITE_BACKEND_URL: http://backend:5000
volumes: volumes:
- ./frontend:/app - ./frontend:/app
ports: ports:
@@ -16,3 +41,6 @@ services:
command: sh -c "npm install && npm run dev -- --host" command: sh -c "npm install && npm run dev -- --host"
depends_on: depends_on:
- backend - backend
volumes:
pgdata:
+10
View File
@@ -8,6 +8,7 @@
"name": "bridzik-frontend", "name": "bridzik-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"qrcode.react": "^4.1.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
@@ -5353,6 +5354,15 @@
"node": ">=6" "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": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+1
View File
@@ -8,6 +8,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"qrcode.react": "^4.1.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
+22 -6
View File
@@ -1,20 +1,24 @@
import { useEffect } from 'react'; 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 { useGameStore } from './store/gameStore';
import { socket, emit } from './lib/socket'; import { socket, emit } from './lib/socket';
import type { MyPlayer } from './types'; import type { MyPlayer } from './types';
import GameList from './pages/GameList'; import GameList from './pages/GameList';
import Lobby from './pages/Lobby'; import Lobby from './pages/Lobby';
import GameTable from './pages/GameTable'; import GameTable from './pages/GameTable';
import Auth from './pages/Auth';
import History from './pages/History';
function AppInner() { function AppInner() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const account = useGameStore((s) => s.account);
const myPlayer = useGameStore((s) => s.myPlayer); const myPlayer = useGameStore((s) => s.myPlayer);
const gameStatus = useGameStore((s) => s.gameStatus); const gameStatus = useGameStore((s) => s.gameStatus);
const error = useGameStore((s) => s.error); const error = useGameStore((s) => s.error);
const clearError = useGameStore((s) => s.clearError); 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(() => { useEffect(() => {
const doReconnect = () => { const doReconnect = () => {
const saved = localStorage.getItem('bridzik_player'); const saved = localStorage.getItem('bridzik_player');
@@ -27,10 +31,20 @@ function AppInner() {
return () => { socket.off('connect', doReconnect); }; return () => { socket.off('connect', doReconnect); };
}, []); }, []);
// Single authoritative navigation: derive target from store state // Single authoritative navigation:
const targetRoute = myPlayer // - not logged in → /auth
? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}` // - logged in & in a game → lobby/game
: null; // - 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(() => { useEffect(() => {
if (!targetRoute) return; if (!targetRoute) return;
@@ -52,7 +66,9 @@ function AppInner() {
</div> </div>
)} )}
<Routes> <Routes>
<Route path="/auth" element={<Auth />} />
<Route path="/" element={<GameList />} /> <Route path="/" element={<GameList />} />
<Route path="/history" element={<History />} />
<Route path="/lobby/:gid" element={<Lobby />} /> <Route path="/lobby/:gid" element={<Lobby />} />
<Route path="/game/:gid" element={<GameTable />} /> <Route path="/game/:gid" element={<GameTable />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
+11 -18
View File
@@ -3,16 +3,16 @@ import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket'; import { emit } from '../lib/socket';
interface Props { interface Props {
mode: 'create' | 'join';
gid?: string;
onClose: () => void; onClose: () => void;
} }
export default function NameModal({ mode, gid, onClose }: Props) { /** Prompt for a new game's name. Identity is taken from the logged-in session,
const [name, setName] = useState(localStorage.getItem('bridzik_name') ?? ''); * so no player name is asked here. */
export default function NameModal({ onClose }: Props) {
const [name, setName] = useState('');
const myPlayer = useGameStore((s) => s.myPlayer); 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(() => { useEffect(() => {
if (myPlayer) onClose(); if (myPlayer) onClose();
}, [myPlayer, onClose]); }, [myPlayer, onClose]);
@@ -21,29 +21,22 @@ export default function NameModal({ mode, gid, onClose }: Props) {
e.preventDefault(); e.preventDefault();
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) return; if (!trimmed) return;
localStorage.setItem('bridzik_name', trimmed); // Stores the name; the socket listener auto-chains register_player.
emit.createGame(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);
}
}; };
return ( return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-40 p-4"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-40 p-4">
<div className="bg-slate-800 rounded-2xl p-6 w-full max-w-sm shadow-xl"> <div className="bg-slate-800 rounded-2xl p-6 w-full max-w-sm shadow-xl">
<h2 className="text-lg font-bold mb-4 text-white">Zadaj svoje meno</h2> <h2 className="text-lg font-bold mb-4 text-white">Nazov hry</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input <input
autoFocus autoFocus
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
maxLength={20} maxLength={30}
placeholder="Tvoje meno" 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" className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500"
/> />
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
@@ -59,7 +52,7 @@ export default function NameModal({ mode, gid, onClose }: Props) {
disabled={!name.trim()} disabled={!name.trim()}
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-semibold disabled:opacity-40" className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-semibold disabled:opacity-40"
> >
Potvrdit Vytvorit
</button> </button>
</div> </div>
</form> </form>
+71 -5
View File
@@ -1,23 +1,54 @@
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { useGameStore } from '../store/gameStore'; 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 }); 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 // Module-level state for the create→register chain and gid resolution on register_player
let _pendingGid: string | null = null; let _pendingGid: string | null = null;
let _createName: string | null = null; let _createName: string | null = null;
export const emit = { 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) => { createGame: (name: string) => {
_createName = name; _createName = name;
socket.emit('create_game', name); socket.emit('create_game', name);
}, },
registerPlayer: (gid: string, name: string) => { registerPlayer: (gid: string) => {
_pendingGid = gid; _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'), leaveGame: () => socket.emit('leave_game'),
endGame: (gid: string) => socket.emit('end_game', gid),
startGame: (gid: string) => socket.emit('start_game', gid), startGame: (gid: string) => socket.emit('start_game', gid),
reconnectToGame: (gid: string, token: string) => socket.emit('reconnect_to_game', gid, token), reconnectToGame: (gid: string, token: string) => socket.emit('reconnect_to_game', gid, token),
gameStatus: () => socket.emit('game_status'), gameStatus: () => socket.emit('game_status'),
@@ -31,10 +62,34 @@ export function setupSocketListeners() {
useGameStore.getState().setGames(games); 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 }) => { 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) { if (_createName !== null) {
emit.registerPlayer(gid, _createName); emit.registerPlayer(gid);
_createName = null; _createName = null;
} }
}); });
@@ -55,6 +110,12 @@ export function setupSocketListeners() {
useGameStore.getState().setGameStatus(payload); 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 }) => { socket.on('player_cards', ({ cards }: { cards: Hand }) => {
useGameStore.getState().setHand(cards); useGameStore.getState().setHand(cards);
}); });
@@ -66,4 +127,9 @@ export function setupSocketListeners() {
socket.on('error', ({ error }: { error: string }) => { socket.on('error', ({ error }: { error: string }) => {
useGameStore.getState().setError(error); 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}`);
});
} }
+3 -1
View File
@@ -2,9 +2,11 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import './index.css'; import './index.css';
import { setupSocketListeners, socket } from './lib/socket'; import { setupSocketListeners, socket, setAuthToken } from './lib/socket';
setupSocketListeners(); setupSocketListeners();
// Carry the stored session token into the handshake so the server auto-logs us in.
setAuthToken(localStorage.getItem('bridzik_token'));
socket.connect(); socket.connect();
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
+157
View File
@@ -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<Mode>('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 (
<div className="max-w-sm mx-auto p-4 pt-10">
<h1 className="text-2xl font-bold text-center mb-2 tracking-wide">Bridzik</h1>
<p className="text-center text-gray-400 text-sm mb-6">
Prihlas sa kodom z aplikacie (napr. Google Authenticator).
</p>
<div className="flex mb-6 rounded-xl overflow-hidden border border-slate-700">
<button
onClick={() => switchMode('login')}
className={`flex-1 py-2 text-sm font-semibold ${
mode === 'login' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-gray-400'
}`}
>
Prihlasenie
</button>
<button
onClick={() => switchMode('register')}
className={`flex-1 py-2 text-sm font-semibold ${
mode === 'register' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-gray-400'
}`}
>
Registracia
</button>
</div>
{mode === 'login' && (
<form onSubmit={handleLogin} className="flex flex-col gap-4">
<input
autoFocus
type="text"
value={username}
onChange={(e) => 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"
/>
<input
type="text"
inputMode="numeric"
value={code}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={!username.trim() || code.trim().length < 6}
className="py-3 rounded-xl bg-blue-600 hover:bg-blue-500 font-bold disabled:opacity-40"
>
Prihlasit
</button>
</form>
)}
{mode === 'register' && !registration && (
<form onSubmit={handleRegisterStart} className="flex flex-col gap-4">
<input
autoFocus
type="text"
value={username}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={!username.trim()}
className="py-3 rounded-xl bg-green-700 hover:bg-green-600 font-bold disabled:opacity-40"
>
Vytvorit ucet
</button>
</form>
)}
{mode === 'register' && registration && (
<div className="flex flex-col gap-4">
<p className="text-sm text-gray-300">
Naskenuj QR kod do autentifikacnej aplikacie a opis aktualny kod.
</p>
<div className="bg-white rounded-xl p-4 flex justify-center">
<QRCodeSVG value={registration.otpauth_uri} size={176} />
</div>
<div className="text-xs text-gray-400 text-center">
Alebo zadaj rucne kluc:
<span className="block font-mono text-gray-200 break-all mt-1">
{registration.secret}
</span>
</div>
<form onSubmit={handleConfirm} className="flex flex-col gap-3">
<input
autoFocus
type="text"
inputMode="numeric"
value={code}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={code.trim().length < 6}
className="py-3 rounded-xl bg-blue-600 hover:bg-blue-500 font-bold disabled:opacity-40"
>
Potvrdit a prihlasit
</button>
</form>
</div>
)}
</div>
);
}
+41 -17
View File
@@ -1,18 +1,42 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useGameStore } from '../store/gameStore'; import { useGameStore } from '../store/gameStore';
import { emit, socket, setAuthToken } from '../lib/socket';
import NameModal from '../components/NameModal'; import NameModal from '../components/NameModal';
import RulesModal from '../components/RulesModal'; import RulesModal from '../components/RulesModal';
type ModalState = { mode: 'create' } | { mode: 'join'; gid: string } | null;
export default function GameList() { export default function GameList() {
const navigate = useNavigate();
const games = useGameStore((s) => s.games); const games = useGameStore((s) => s.games);
const [modal, setModal] = useState<ModalState>(null); const account = useGameStore((s) => s.account);
const [showCreate, setShowCreate] = useState(false);
const [showRules, setShowRules] = 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 ( return (
<div className="max-w-md mx-auto p-4 pt-8"> <div className="max-w-md mx-auto p-4 pt-8">
<h1 className="text-2xl font-bold text-center mb-6 tracking-wide">Bridzik</h1> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold tracking-wide">Bridzik</h1>
<div className="flex items-center gap-3 text-sm">
<span className="text-gray-400">{account?.username}</span>
<button onClick={() => navigate('/history')} className="text-blue-400 hover:text-blue-300">
Historia
</button>
<button onClick={handleLogout} className="text-gray-400 hover:text-white">
Odhlasit
</button>
</div>
</div>
<div className="flex flex-col gap-3 mb-6"> <div className="flex flex-col gap-3 mb-6">
{games.length === 0 && ( {games.length === 0 && (
@@ -20,7 +44,11 @@ export default function GameList() {
)} )}
{games.map((g) => { {games.map((g) => {
const full = g.players.length >= 4; 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 ( return (
<div <div
key={g.gid} key={g.gid}
@@ -35,10 +63,12 @@ export default function GameList() {
</div> </div>
<button <button
disabled={unavailable} disabled={unavailable}
onClick={() => setModal({ mode: 'join', gid: g.gid })} onClick={() => (canResume ? emit.rejoinGame(g.gid) : emit.registerPlayer(g.gid))}
className="px-4 py-1.5 rounded-lg text-sm font-semibold bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-default" className={`px-4 py-1.5 rounded-lg text-sm font-semibold disabled:opacity-40 disabled:cursor-default ${
canResume ? 'bg-green-700 hover:bg-green-600' : 'bg-blue-600 hover:bg-blue-500'
}`}
> >
{full ? 'Plna' : g.started ? 'Zacata' : 'Vstup'} {label}
</button> </button>
</div> </div>
); );
@@ -46,8 +76,8 @@ export default function GameList() {
</div> </div>
<button <button
onClick={() => setModal({ mode: 'create' })} onClick={() => setShowCreate(true)}
className="w-full py-3 rounded-xl bg-green-700 hover:bg-green-600 font-bold text-lg" className="w-full py-3 rounded-xl bg-green-700 hover:bg-green-600 font-bold text-lg mb-3"
> >
+ Vytvorit novu hru + Vytvorit novu hru
</button> </button>
@@ -61,13 +91,7 @@ export default function GameList() {
{showRules && <RulesModal onClose={() => setShowRules(false)} />} {showRules && <RulesModal onClose={() => setShowRules(false)} />}
{modal && ( {showCreate && <NameModal onClose={() => setShowCreate(false)} />}
<NameModal
mode={modal.mode}
gid={modal.mode === 'join' ? modal.gid : undefined}
onClose={() => setModal(null)}
/>
)}
</div> </div>
); );
} }
+16 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useGameStore } from '../store/gameStore'; import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket';
import { leaveGame } from '../lib/leaveGame'; import { leaveGame } from '../lib/leaveGame';
import { computePlayable } from '../lib/gameRules'; import { computePlayable } from '../lib/gameRules';
import Hand from '../components/Hand'; import Hand from '../components/Hand';
@@ -68,6 +69,11 @@ export default function GameTable() {
const activePlayerName = players.find((p) => p.order === active_player)?.name ?? ''; const activePlayerName = players.find((p) => p.order === active_player)?.name ?? '';
const handleLeave = () => leaveGame(navigate); const handleLeave = () => leaveGame(navigate);
const handleEnd = () => {
if (window.confirm('Naozaj ukoncit celu hru pre vsetkych?')) {
emit.endGame(gameStatus.gid);
}
};
return ( return (
<div className="max-w-lg mx-auto p-3 flex flex-col gap-3 pb-6"> <div className="max-w-lg mx-auto p-3 flex flex-col gap-3 pb-6">
@@ -77,9 +83,16 @@ export default function GameTable() {
<span>Seria {series_number} / Kolo {round_number + 1}</span> <span>Seria {series_number} / Kolo {round_number + 1}</span>
<span className="ml-2 text-gray-500">({cards_in_round} kopok)</span> <span className="ml-2 text-gray-500">({cards_in_round} kopok)</span>
</div> </div>
<button onClick={handleLeave} className="text-xs text-gray-500 hover:text-gray-300"> <div className="flex gap-3">
Odist {myOrder === 0 && (
</button> <button onClick={handleEnd} className="text-xs text-red-400 hover:text-red-300">
Ukoncit hru
</button>
)}
<button onClick={handleLeave} className="text-xs text-gray-500 hover:text-gray-300">
Odist
</button>
</div>
</div> </div>
{/* Turn indicator */} {/* Turn indicator */}
+97
View File
@@ -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 (
<div className="max-w-md mx-auto p-4 pt-8">
<button
onClick={() => setGameDetail(null)}
className="text-sm text-gray-400 hover:text-white mb-4"
>
Spat na zoznam
</button>
<h1 className="text-xl font-bold mb-1">Detail hry</h1>
<p className="text-xs text-gray-400 mb-4">{fmtDate(detail.created_at)}</p>
<div className="bg-slate-800 rounded-xl overflow-hidden text-sm">
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-2 px-3 py-2 text-xs text-gray-400 border-b border-slate-700">
<span>S/K</span>
<span>Hrac</span>
<span className="text-right">Tip</span>
<span className="text-right">Body</span>
</div>
{detail.rounds.map((r, i) => (
<div
key={i}
className="grid grid-cols-[auto_1fr_auto_auto] gap-2 px-3 py-1.5 border-b border-slate-700/50 last:border-0"
>
<span className="text-gray-500">
{r.series_number}/{r.round_number}
</span>
<span className="truncate">{r.username ?? `#${r.player_id}`}</span>
<span className="text-right font-mono">{r.guess}</span>
<span className={`text-right font-mono ${r.won ? 'text-green-400' : 'text-gray-500'}`}>
{r.points}
</span>
</div>
))}
</div>
</div>
);
}
// --- list view ---
return (
<div className="max-w-md mx-auto p-4 pt-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold">Moja historia</h1>
<button onClick={() => navigate('/')} className="text-sm text-gray-400 hover:text-white">
Spat
</button>
</div>
{history.length === 0 && (
<p className="text-center text-gray-500 py-6">Zatial ziadne odohrane hry.</p>
)}
<div className="flex flex-col gap-3">
{history.map((g) => (
<button
key={g.gid}
onClick={() => emit.getGameDetail(g.gid)}
className="text-left bg-slate-800 hover:bg-slate-700 rounded-xl px-4 py-3"
>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-200">{fmtDate(g.created_at)}</span>
<span className="text-sm font-semibold text-green-400">{g.my_points} b.</span>
</div>
<p className="text-xs text-gray-400 mt-1 truncate">{g.players.join(', ')}</p>
<p className="text-[11px] text-gray-500 mt-0.5">
{g.ended_at ? 'dohrana' : 'nedohrana'}
</p>
</button>
))}
</div>
</div>
);
}
+38 -1
View File
@@ -1,14 +1,31 @@
import { create } from 'zustand'; 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 { interface GameStore {
games: GameInfo[]; games: GameInfo[];
account: Account | null;
registration: Registration | null;
history: HistoryGame[];
gameDetail: GameDetail | null;
myPlayer: MyPlayer | null; myPlayer: MyPlayer | null;
gameStatus: GameStatusPayload | null; gameStatus: GameStatusPayload | null;
hand: Hand; hand: Hand;
error: string | null; error: string | null;
setGames: (games: GameInfo[]) => void; 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; setMyPlayer: (player: MyPlayer | null) => void;
setGameStatus: (status: GameStatusPayload) => void; setGameStatus: (status: GameStatusPayload) => void;
setHand: (hand: Hand) => void; setHand: (hand: Hand) => void;
@@ -16,16 +33,25 @@ interface GameStore {
clearError: () => void; clearError: () => void;
updatePlayerConnection: (order: number, connected: boolean) => void; updatePlayerConnection: (order: number, connected: boolean) => void;
reset: () => void; reset: () => void;
logout: () => void;
} }
export const useGameStore = create<GameStore>((set) => ({ export const useGameStore = create<GameStore>((set) => ({
games: [], games: [],
account: null,
registration: null,
history: [],
gameDetail: null,
myPlayer: null, myPlayer: null,
gameStatus: null, gameStatus: null,
hand: {}, hand: {},
error: null, error: null,
setGames: (games) => set({ games }), setGames: (games) => set({ games }),
setAccount: (account) => set({ account }),
setRegistration: (registration) => set({ registration }),
setHistory: (history) => set({ history }),
setGameDetail: (gameDetail) => set({ gameDetail }),
setMyPlayer: (myPlayer) => set({ myPlayer }), setMyPlayer: (myPlayer) => set({ myPlayer }),
setGameStatus: (gameStatus) => set({ gameStatus }), setGameStatus: (gameStatus) => set({ gameStatus }),
setHand: (hand) => set({ hand }), setHand: (hand) => set({ hand }),
@@ -43,4 +69,15 @@ export const useGameStore = create<GameStore>((set) => ({
: null, : null,
})), })),
reset: () => set({ myPlayer: null, gameStatus: null, hand: {}, error: 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,
}),
})); }));
+42
View File
@@ -10,6 +10,7 @@ export interface PlayerInfo {
order: number; order: number;
name: string; name: string;
connected: boolean; connected: boolean;
player_id?: number;
} }
export interface MyPlayer { export interface MyPlayer {
@@ -51,3 +52,44 @@ export interface GameInfo {
} }
export type Hand = Record<string, Card>; export type Hand = Record<string, Card>;
// --- 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[];
}
+3 -1
View File
@@ -27,7 +27,9 @@ export default defineConfig({
host: true, host: true,
proxy: { proxy: {
'/socket.io': { '/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, ws: true,
changeOrigin: true, changeOrigin: true,
}, },
+6
View File
@@ -3,6 +3,12 @@ python-socketio>=5.11
python-engineio>=4.9 python-engineio>=4.9
uvicorn>=0.30 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, # NOTE: the legacy Flask/Jinja HTTP UI (api/routes.py, api/forms.py,
# api/templates/) is dormant and its dependencies (Flask, Flask-WTF, etc.) # 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. # were removed during the ASGI migration. Re-add them only if that UI is revived.
+180
View File
@@ -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()