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:
@@ -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/
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
|
|||||||
Generated
+10
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+21
-5
@@ -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
|
||||||
|
// - 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}`
|
? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}`
|
||||||
: null;
|
: 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 />} />
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
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);
|
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>
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +83,17 @@ 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>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{myOrder === 0 && (
|
||||||
|
<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">
|
<button onClick={handleLeave} className="text-xs text-gray-500 hover:text-gray-300">
|
||||||
Odist
|
Odist
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Turn indicator */}
|
{/* Turn indicator */}
|
||||||
<div className="bg-slate-800/60 rounded-lg px-3 py-2 text-sm text-center">
|
<div className="bg-slate-800/60 rounded-lg px-3 py-2 text-sm text-center">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user