Compare commits

...

10 Commits

Author SHA1 Message Date
tim 2c2f07c2ec Apply velvet-table redesign, fix game lifecycle and history bugs
Frontend:
- Dark green/gold "velvet table" visual redesign across the whole app
  (Auth, Lobby, GameList, GameTable, History, GameOver, modals), with
  Playfair Display/DM Sans typography and a centralized Tailwind palette.
- Desktop game table fit-scales to fill the window; mobile gets
  overlapping hand/trick layouts and larger touch-friendly cards.
- Standings sidebar now groups completed rounds by series with a
  per-series subtotal row, struck-through tips on missed bids.
- History page rewritten into a scoreboard-style detail view (player
  totals beside names, series grouped 2-up on desktop / stacked on
  mobile) and gained game names, completed/abandoned status, and a
  button to reopen a prematurely-ended game back into the lobby.

Backend:
- Fix started games being deleted from memory (and vanishing from
  everyone's lobby) when all players disconnect; only `end_game` tears
  down a started game now.
- Fix a crash writing a timezone-aware datetime into the naive
  `ended_at` Postgres column.
- Add `reopen_game`/`restore_game` to un-end a prematurely-ended game
  from history and resume it from the lobby.
- Let any seated player end an abandoned game once the host is
  offline, not just the host, so the game isn't stuck forever.
- Expose SERIES_PER_GAME/ROUNDS_PER_SERIES as named constants on the
  engine so the persistence layer derives game-completion rules from
  bridzik.py instead of re-encoding them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 00:11:42 +02:00
tim 30c32b7714 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>
2026-06-23 23:09:50 +02:00
tim beaf142ee4 Add React frontend and clean up legacy HTTP backend 2026-06-15 22:20:56 +02:00
tim b8e2d15e27 Add leave_game and enrich game_status for frontend integration
- leave_game: explicit exit from the lobby/game. Before start it frees
  the seat; after start it keeps the seat (token reconnect still works)
  and marks the player offline, dropping the game once empty.
- Seat assignment now picks the lowest free order, so leaving a lobby
  before start no longer collides order numbers on the next join.
- game_status is now self-contained: adds players roster
  (order/name/connected), series_number, round_number and cards_in_round
  (= trick count / max bid), so the game view no longer has to stitch the
  lobby snapshot.
- Add Game.player_by_order helper.

Tests: +5 (roster/round meta, leave-before-start frees seat, leave drops
empty game, leave started game keeps seat offline, leave noop). Suite: 34.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:56:02 +02:00
tim 821c7e81ce Add CLAUDE.md and Slovak game-rules doc
CLAUDE.md documents the engine/web architecture and dev commands for
future Claude Code sessions. PRAVIDLA.md captures the full Bridzik rules
(extracted from the engine) in Slovak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:40:42 +02:00
tim d47eb03bce Migrate Socket.IO server to ASGI, fix bugs, harden, add socket tests
Replace Flask-SocketIO + eventlet with python-socketio AsyncServer on an
ASGI app served by uvicorn (Python 3.14). The server is no longer started
as an import side-effect; `python -m app` runs uvicorn for dev and the
Docker image runs `uvicorn api:app`.

Bug fixes:
- create_game now mints a real uuid gid and returns it to the creator
  (was hardcoded 'a').
- play_card resolves the player's hand and plays the selected Card (was
  indexing a method and crashing).

Hardening:
- Identity binding: every action derives the seat from the connection
  (sid -> {gid, order}); clients no longer pass a player number, closing
  the hidden-cards cheat where any client could request any hand.
- Secure token-based reconnect (per-player secret token).
- disconnect handler marks players offline and drops empty games (no
  more leaked games), notifying the room via player_connection.
- Guards for unknown gid, double start_game, and bad input; engine
  exception messages are forwarded instead of swallowed.
- Lobby payload is public-only (no sids/tokens); game_status carries a
  completed flag.
- /health endpoint via other_asgi_app; env-driven CORS and logging.

Infra:
- Dockerfile -> python:3.14-slim, uvicorn CMD, drop dead venv lines.
- requirements.txt -> python-socketio/engineio + uvicorn; drop eventlet,
  Flask-SocketIO, Flask-Session.
- docker-compose: drop unused debugpy port and obsolete version key.
- Remove redundant start.py; gitignore /.venv.

Tests: test_socket.py drives the handlers (identity binding, lobby
privacy, reconnect, disconnect cleanup, error handling, play flow).
Full suite: 29 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:40:32 +02:00
tim aa1b037c1a Fix guess-validation tests for high round numbers
test_get_active_player and test_is_guessing_completed built a round 6
(only 8-6=2 tricks) but bid 4, which the engine correctly rejects
(guess must be <= number of tricks). Adjust the bids to legal values
while preserving each test's intent (player 0 stays the unique high
bidder so it leads the first stash).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:40:13 +02:00
tim 00937aa89f Add Bridzik.get_player_cards and make get_status player-agnostic
Engine support for the per-connection Socket.IO API: hands are fetched
explicitly via get_player_cards(player) and the shared status no longer
embeds a single player's cards.
2026-06-13 23:40:04 +02:00
Frantisek F cd3d84319e Add main socket communication 2022-07-05 21:28:05 +02:00
Frantisek F 3053040da9 Add docker and sockets 2022-07-05 15:58:23 +02:00
62 changed files with 11625 additions and 464 deletions
+32
View File
@@ -0,0 +1,32 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/docker-existing-dockerfile
{
"name": "Existing Dockerfile",
// Sets the run context to one level up instead of the .devcontainer folder.
"context": "..",
// Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename.
"dockerFile": "../Dockerfile",
// Set *default* container specific settings.json values on container create.
"settings": {},
// Add the IDs of extensions you want installed when the container is created.
"extensions": []
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Uncomment the next line to run commands after the container is created - for example installing curl.
// "postCreateCommand": "apt-get update && apt-get install -y curl",
// Uncomment when using a ptrace-based debugger like C++, Go, and Rust
// "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ],
// Uncomment to use the Docker CLI from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker.
// "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" ],
// Uncomment to connect as a non-root user if you've added one. See https://aka.ms/vscode-remote/containers/non-root.
// "remoteUser": "vscode"
}
+7 -5
View File
@@ -1,6 +1,8 @@
__pycache__/ __pycache__/
.flaskenv *.pyc
/.vscode .venv/
/logs *.db
/env-bridzik-dev .idea/
*.png frontend/node_modules/
frontend/dist/
frontend/.vite/
+80
View File
@@ -0,0 +1,80 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 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. Stack: a pure-Python game engine, a realtime **Socket.IO ASGI** server, an async **SQLAlchemy** persistence layer, and a **React PWA** frontend. Code, comments, and exception messages are mostly in **Slovak** — keep new strings consistent with that.
## Commands
```powershell
# Run the backend (ASGI Socket.IO on 0.0.0.0:5000)
python -m app # uvicorn with --reload (dev entrypoint)
uvicorn api:app --host 0.0.0.0 --port 5000 # what Docker/prod runs
# Run the whole stack (Postgres + backend :5000 + frontend :5173)
docker-compose up --build
docker-compose down -v # also drops the pg volume — needed after a schema change
# Tests
python -m unittest tests -v # pure-engine unittest (no deps)
python -m unittest tests_history -v # persistence + auth (needs sqlalchemy/aiosqlite/pyotp)
python -m unittest tests.StashCase.test_get_winner # single method
```
There is no linter or build step configured. The frontend is **only ever run via Docker** — never `npm install`/`npm run dev` on the host.
## Architecture
Three independent layers, each usable without the one above it: **engine** (`bridzik.py`) ← **persistence** (`db/`) ← **app/transport** (`api/`).
### Game engine — `bridzik.py`
Pure Python, **no Flask/Socket.IO/DB dependency**. All game rules live here and are exercised directly by `tests.py`. State is a strict nested hierarchy, each level enforcing turn order and completion before delegating down:
- **`Bridzik`** — a whole game = exactly **4 `Series`**.
- **`Series`** — exactly **8 `Round`s**; the starting player rotates per series/round.
- **`Round`** — a bidding (`guess`/"tip") phase then a play phase of `8 - round_number` **`Stash`es**. Each round deals fewer cards as `round_number` grows.
- **`Stash`** (a "kopka" = one trick) — 4 cards, one per player; `get_winner()` resolves it.
Key rules encoded in the engine:
- **`Card_colors.HEARTS` (červeň) is the permanent trump** — any heart beats any non-heart in `Stash.get_winner()`, and follow-suit logic in `Round.play_card` forces playing a heart when you can't follow the led suit.
- **`Card_values` are ordered** C7 < C8 < C9 < C10 < LOWER < UPPER < KING < ACE via custom comparison dunders.
- **Scoring**: a player who exactly matches their guess scores `10 + guess`, else 0 (`Round.get_points_summary`).
- The total of the 4 guesses may **not** equal the number of tricks (the last bidder is constrained) — see `Round.add_player_guess`.
Serialization: `Card.JSONEncoder` flattens a `Card` to `{color, value}` name strings. The double `json.loads(json.dumps(...))` pattern in the API strips escaped slashes after custom encoding.
### Persistence layer — `db/`
Async SQLAlchemy 2.0, independent of Socket.IO (mirrors how the engine is kept clean).
- **`db/db.py`** — async `engine` + `async_sessionmaker`, declarative `Base`, and `init_db()` (`create_all`). Connection string from env **`DATABASE_URL`** (default `sqlite+aiosqlite:///bridzik.db`; Docker sets PostgreSQL via `asyncpg`). There are **no migrations**`create_all` only adds new tables, so a changed column needs a fresh DB.
- **`db/models.py`** — 3 tables:
- **`Player`** — account + auth: `username` (unique login), `totp_secret`, `totp_last_step` (TOTP replay guard), `auth_token` (session token for reconnect).
- **`Game`** — one match: `id` (gid), 4 `playerN_id` seats, `name`, `series`/`round` (current position, used for restore), `created_at`, `ended_at`.
- **`Guess`** — one player's bid+result in a round: `series_number`, `round_number`, `guess`, `points`. `won` is derived (`points > 0`). Unique on (game, series, round, player) → idempotent writes.
### App / transport layer — `api/`
- **`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
- `Dockerfile` targets **Python 3.14-slim** and runs `uvicorn api:app`.
- `docker-compose.yaml` runs **PostgreSQL** (`db`, with a `pgdata` volume + healthcheck), the backend (`DATABASE_URL` → that Postgres), and the frontend.
- `requirements.txt`: `python-socketio`, `uvicorn`, `SQLAlchemy[asyncio]`, `aiosqlite` (dev), `asyncpg` (prod), `pyotp`.
## Conventions
- New game-rule logic belongs in `bridzik.py` with a `unittest` case in `tests.py` — keep the engine free of Flask/Socket.IO/DB.
- Persistence/auth logic goes in `db/` (data primitives) and `api/history.py` + `api/auth.py` (logic that uses them); cover it in `tests_history.py`, not `tests.py`.
- Raise `BridzikException` (Slovak message) for rule violations; the API catches it and re-emits a Slovak error. Auth errors use `AuthError` the same way.
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.14-slim
WORKDIR /app
# Keeps Python from generating .pyc files and turns off output buffering.
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
# Serve the ASGI Socket.IO app with uvicorn.
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "5000"]
+129
View File
@@ -0,0 +1,129 @@
# Pravidlá hry Bridžik
Tento dokument popisuje pravidlá hry tak, ako sú implementované v hernom jadre
(`bridzik.py`). Slúži ako referencia pre hráčov aj vývojárov.
## Karty
Hrá sa s **32-kartovým balíčkom** sedmových (slovenských/nemeckých) kariet.
### Farby (4)
| Názov v kóde | Slovenský názov |
| ------------ | --------------- |
| `HEARTS` | červeň |
| `LEAVES` | zeleň |
| `ACORNS` | žaluď |
| `BELLS` | guľa |
**Červeň je vždy tromf (adut).** Červeň prebíja každú inú farbu.
### Hodnoty (8) — od najnižšej po najvyššiu
`sedmička (7) < osmička (8) < deviatka (9) < desiatka (10) < dolník < horník < kráľ < eso`
V kóde: `C7 < C8 < C9 < C10 < LOWER < UPPER < KING < ACE`.
## Štruktúra hry
Hru hrajú **4 hráči** (očíslovaní 0, 1, 2, 3). Celá hra má pevnú štruktúru:
- **Hra** = 4 **série** (séria 0 až 3).
- **Séria** = 8 **kôl** (kolo 0 až 7).
- **Kolo** = `8 číslo_kola` **kopiek** (zdvihov).
V každom kole dostane každý hráč `8 číslo_kola` kariet, takže počet kariet
postupne klesá:
| Číslo kola | Kariet na hráča | Počet kopiek v kole |
| ---------: | --------------: | ------------------: |
| 0 | 8 | 8 |
| 1 | 7 | 7 |
| 2 | 6 | 6 |
| 3 | 5 | 5 |
| 4 | 4 | 4 |
| 5 | 3 | 3 |
| 6 | 2 | 2 |
| 7 | 1 | 1 |
Celá hra teda obsahuje 4 × 8 = 32 kôl.
## Rozdávanie
Na začiatku každého kola sa balíček zamieša. Z vrchu sa odloží `4 × číslo_kola`
kariet a zvyšok sa rozdá: každý zo 4 hráčov dostane `8 číslo_kola` kariet.
## Kto začína
- **Sériu** otvára hráč s rovnakým číslom, ako je číslo série (séria 0 → hráč 0,
séria 1 → hráč 1, …).
- **Kolo** otvára hráč `(prvý_hráč_série + číslo_kola) mod 4`, čiže začínajúci
hráč sa medzi kolami posúva.
## Priebeh kola
Každé kolo má dve fázy: **tipovanie** a následne **hranie kariet**.
### 1. Tipovanie (bridžik)
Hráči postupne (počnúc začínajúcim hráčom kola, v smere poradia) zadávajú **tip**
koľko kopiek (zdvihov) v tomto kole získajú.
- Tip musí byť v rozsahu `0``počet kopiek v kole` (`8 číslo_kola`).
- **Pravidlo bridžika:** posledný (štvrtý) tipujúci hráč nesmie zadať taký tip,
pri ktorom by sa **súčet všetkých štyroch tipov rovnal počtu kopiek** v kole.
Inými slovami, súčet tipov sa nikdy nesmie presne rovnať počtu zdvihov — vždy
musí niekto „prebrať" alebo „nedobrať".
- Každý hráč zadáva tip iba raz a iba keď je na rade.
### 2. Hranie kariet
Po dokončení tipovania sa hrá `8 číslo_kola` kopiek (zdvihov).
- **Prvú kopku** otvára hráč s **najvyšším tipom**. Pri zhode tipov začína ten,
kto je skôr v poradí (počítané od začínajúceho hráča kola).
- **Každú ďalšiu kopku** otvára **víťaz predchádzajúcej kopky**.
#### Povinnosť priznať farbu
Keď je na stole vynesená (prvá) karta kopky, ďalší hráči musia dodržať:
1. Ak má hráč farbu vynesenej karty → **musí priznať farbu** (zahrať kartu tej
istej farby).
2. Ak farbu vynesenej karty nemá, ale má **červeň****musí zahrať červeň**
(tromf).
3. Ak nemá ani vynesenú farbu, ani červeň → môže zahrať **ľubovoľnú** kartu.
Hráč môže zahrať len kartu, ktorú má v ruke, a iba keď je na rade.
#### Vyhodnotenie kopky
Po zahraní všetkých 4 kariet sa určí víťaz kopky:
- **Červeň prebíja** každú inú farbu. Ak v kopke padla aspoň jedna červeň,
vyhráva **najvyššia červeň**.
- Ak nepadla žiadna červeň, vyhráva **najvyššia karta vynesenej farby**.
- Karty iných farieb (ktoré nie sú ani vynesená farba, ani červeň) kopku
vyhrať nemôžu.
Víťaz kopky vynáša do nasledujúcej kopky.
## Bodovanie
Body sa počítajú po každom dokončenom kole:
- Ak sa hráčov **tip presne zhoduje** s počtom kopiek, ktoré v kole získal,
dostane **`10 + tip`** bodov.
- Ak sa tip nezhoduje (získal viac alebo menej kopiek), dostane **0 bodov**.
Príklad: hráč tipoval 3 a získal presne 3 kopky → 13 bodov. Ak by získal 2 alebo
4 kopky → 0 bodov.
Celkové skóre hráča je súčet bodov zo všetkých kôl všetkých sérií. Vyhráva hráč
s najvyšším celkovým súčtom po dohraní všetkých 4 sérií.
## Ukončenie
- **Kolo** je ukončené, keď sú odohrané všetky kopky.
- **Séria** je ukončená po 8 kolách.
- **Hra** je ukončená po 4 sériách.
+581 -18
View File
@@ -1,23 +1,586 @@
from flask import Flask import json
import os import os
import logging import uuid
from logging.handlers import RotatingFileHandler from json import JSONEncoder
from config import Config
app = Flask(__name__) import socketio
app.config.from_object(Config)
if not app.debug: from bridzik import Bridzik, BridzikException, Card
if not os.path.exists('logs'): from db.db import init_db
os.mkdir('logs') from api import auth as auth_module, history
file_handler = RotatingFileHandler('logs/bridzik_api.log', maxBytes=10240, backupCount=10) from api.auth import AuthError
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
))
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('Bridzik_API startup')
from api import routes def _env_bool(name: str, default: bool) -> bool:
val = os.environ.get(name)
if val is None:
return default
return val.lower() in ("1", "true", "yes", "on")
# --- configuration (env-driven, dev-friendly defaults) --------------------
_cors = os.environ.get("CORS_ALLOWED_ORIGINS", "*")
CORS_ALLOWED_ORIGINS = "*" if _cors == "*" else [o.strip() for o in _cors.split(",")]
SIO_LOGGER = _env_bool("SOCKETIO_LOGGER", False)
LOBBY = "lobby" # room every connection joins to receive the public game list
sio = socketio.AsyncServer(
async_mode="asgi",
cors_allowed_origins=CORS_ALLOWED_ORIGINS,
logger=SIO_LOGGER,
engineio_logger=SIO_LOGGER,
)
async def _health_app(scope, receive, send):
"""Minimal ASGI handler for non-socket.io HTTP routes (liveness checks)."""
if scope["type"] == "lifespan":
while True:
message = await receive()
if message["type"] == "lifespan.startup":
await init_db()
await _restore_unfinished_games()
await send({"type": "lifespan.startup.complete"})
elif message["type"] == "lifespan.shutdown":
await send({"type": "lifespan.shutdown.complete"})
return
if scope["type"] == "http":
ok = scope.get("path", "") in ("/health", "/healthz")
status = 200 if ok else 404
body = b"ok" if ok else b"not found"
await send({"type": "http.response.start", "status": status,
"headers": [(b"content-type", b"text/plain")]})
await send({"type": "http.response.body", "body": body})
# Run with: uvicorn api:app --host 0.0.0.0 --port 5000
app = socketio.ASGIApp(sio, other_asgi_app=_health_app)
# --- in-memory state ------------------------------------------------------
# Single-process only. For multi-worker deployments this moves to Redis
# (socketio.AsyncRedisManager) plus a shared game store.
games: dict[str, "Game"] = {}
# Maps a live connection (sid) to the seat it controls: {"gid": str, "order": int}.
# This is the source of truth for "who is acting" — never trust a client-supplied
# player number.
sessions: dict[str, dict] = {}
# Maps a live connection (sid) to its authenticated account: {"player_id": int,
# "username": str}. Set on login/confirm or on connect via the auth token.
# Required before a connection may create or join a game.
accounts: dict[str, dict] = {}
class Game:
def __init__(self, gid: str, name: str):
self.gid = gid
self.name = name
self.players: list["Player"] = []
self.started = False
self.bridzik_core: Bridzik | None = None
def start(self):
self.bridzik_core = Bridzik()
self.started = True
def player_by_token(self, token: str) -> "Player | None":
return next((p for p in self.players if p.token == token), None)
def player_by_sid(self, sid: str) -> "Player | None":
return next((p for p in self.players if p.sid == sid), None)
def player_by_order(self, order: int) -> "Player | None":
return next((p for p in self.players if p.order == order), None)
class Player:
def __init__(self, sid: str, name: str, order: int, player_id: int):
self.sid = sid
self.name = name # display name == account username
self.order = order
self.player_id = player_id # persistent account id (db.models.Player.id)
self.token = str(uuid.uuid4()) # secret token used for secure reconnect
self.connected = True
class CardStatusEncoder(JSONEncoder):
"""Serializes the engine status, which may contain Card objects."""
def default(self, obj):
if isinstance(obj, Card):
return {"color": obj.color.name, "value": obj.value.name}
return JSONEncoder.default(self, obj)
def public_games() -> list:
"""Public lobby view — no sids, no reconnect tokens."""
return [
{
"gid": g.gid,
"name": g.name,
"started": g.started,
"players": [
{
"order": p.order,
"name": p.name,
"connected": p.connected,
"player_id": p.player_id,
}
for p in g.players
],
}
for g in games.values()
]
# --- emit helpers ---------------------------------------------------------
async def broadcast_lobby():
await sio.emit("get_games", {"games": public_games()}, room=LOBBY)
async def send_game_status(gid: str):
game = games[gid]
core = game.bridzik_core
last_round = core.series[-1].get_last_round()
status = json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder))
# Use DB-backed standings so the score is correct even after a server restart
# (the engine only knows rounds completed since restart).
status["standings"], status["standings_guesses"] = await history.get_standings(gid)
await sio.emit(
"game_status",
{
"gid": gid,
"completed": core.is_completed(),
# Self-contained roster so the game view doesn't depend on the lobby snapshot.
"players": [
{"order": p.order, "name": p.name, "connected": p.connected}
for p in sorted(game.players, key=lambda p: p.order)
],
"series_number": core.series[-1].series_number,
"round_number": last_round.round_number,
"cards_in_round": 8 - last_round.round_number, # tricks == max bid
"status": status,
},
room=gid,
)
async def send_player_cards(gid: str, order: int, to: str):
core = games[gid].bridzik_core
await sio.emit(
"player_cards",
{"cards": json.loads(json.dumps(core.get_player_cards(int(order)), cls=Card.JSONEncoder))},
to=to,
)
async def send_error(sid: str, message: str):
await sio.emit("error", {"error": message}, to=sid)
async def _mark_player_offline(game: "Game", player: "Player"):
"""Mark player disconnected. An unstarted game with nobody left is cleaned
up; a started game is kept in memory so it stays in the lobby and can be
resumed (it's torn down only by end_game)."""
player.connected = False
if not any(p.connected for p in game.players) and not game.started:
del games[game.gid]
else:
await sio.emit(
"player_connection",
{"order": player.order, "connected": False},
room=game.gid,
)
def _active_game(sid: str) -> "tuple[Game, dict] | None":
"""Resolve the started game and seat for a connection, or None."""
sess = sessions.get(sid)
if sess is None:
return None
game = games.get(sess["gid"])
if game is None or not game.started:
return None
return game, sess
async def _restore_unfinished_games():
"""Po starte servera obnov rozohrate hry z DB do pamate (hraci offline).
Karty su rozdane nanovo (pozicia z `series`/`round`); hraci sa vratia cez
`rejoin_game` podla svojej trvalej identity (per-hra tokeny restart neprezili).
"""
for info in await history.get_unfinished_games():
if info["gid"] in games:
continue
_load_game_into_memory(info)
def _load_game_into_memory(info: dict) -> "Game":
"""Postav in-memory Game z restore-info (gid/name/seats/core), hraci offline,
a vlozi ju do `games`. Pouzite pri starte aj pri obnove hry z historie."""
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
return game
# --- connection lifecycle -------------------------------------------------
@sio.event
async def connect(sid, environ, auth=None):
await sio.enter_room(sid, LOBBY)
# Auto-login via the session token the client stored after a previous login.
token = auth.get("token") if isinstance(auth, dict) else None
identity = await auth_module.player_by_token(token)
if identity is not None:
accounts[sid] = identity
await sio.emit("login", {"player": identity}, to=sid)
await sio.emit("get_games", {"games": public_games()}, to=sid)
@sio.event
async def disconnect(sid):
accounts.pop(sid, None)
sess = sessions.pop(sid, None)
game = games.get(sess["gid"]) if sess else None
if game is not None:
player = game.player_by_sid(sid)
if player is not None:
await _mark_player_offline(game, player)
await broadcast_lobby()
# --- authentication (TOTP) ------------------------------------------------
@sio.on("register_account")
async def register_account(sid, username):
try:
data = await auth_module.register_account(username)
except AuthError as exc:
return await send_error(sid, str(exc))
# otpauth_uri -> the client renders it as a QR code to scan into the app.
await sio.emit("register_account", data, to=sid)
@sio.on("confirm_account")
async def confirm_account(sid, username, code):
try:
identity = await auth_module.confirm_account(username, code)
except AuthError as exc:
return await send_error(sid, str(exc))
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid)
@sio.on("login")
async def login(sid, username, code):
try:
identity = await auth_module.login(username, code)
except AuthError as exc:
return await send_error(sid, str(exc))
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid)
# --- lobby ----------------------------------------------------------------
@sio.on("create_game")
async def create_game(sid, name):
if sid not in accounts:
return await send_error(sid, "Musíte byť prihlásený.")
gid = str(uuid.uuid4())
games[gid] = Game(gid, name)
await sio.emit("create_game", {"gid": gid}, to=sid)
await broadcast_lobby()
@sio.on("get_games")
async def get_games(sid, *args):
await sio.emit("get_games", {"games": public_games()}, to=sid)
@sio.on("register_player")
async def register_player(sid, gid):
account = accounts.get(sid)
if account is None:
return await send_error(sid, "Musíte byť prihlásený.")
if sid in sessions:
return await send_error(sid, "Uz ste v hre.")
game = games.get(gid)
if game is None:
return await send_error(sid, "Hra neexistuje.")
if game.started:
return await send_error(sid, "Hra uz zacala.")
if len(game.players) >= 4:
return await send_error(sid, "Prekroceny pocet hracov.")
if any(p.player_id == account["player_id"] for p in game.players):
return await send_error(sid, "Uz ste v tejto hre.")
# Lowest free seat (robust if someone left the lobby before start).
used = {p.order for p in game.players}
order = next(o for o in range(4) if o not in used)
player = Player(sid, account["username"], order, account["player_id"])
game.players.append(player)
sessions[sid] = {"gid": gid, "order": order}
await sio.enter_room(sid, gid)
# The token is private to this player and required for a secure reconnect.
await sio.emit(
"register_player",
{"player": {"order": order, "name": player.name}, "token": player.token},
to=sid,
)
await broadcast_lobby()
@sio.on("leave_game")
async def leave_game(sid):
"""Explicit exit (e.g. a 'Back to lobby' button). The socket stays
connected and remains in the lobby room."""
sess = sessions.pop(sid, None)
if sess is None:
return # not in a game; nothing to do
game = games.get(sess["gid"])
if game is not None:
await sio.leave_room(sid, game.gid)
player = game.player_by_sid(sid)
if game.started:
# Game in progress: keep the seat (reconnect via token still works),
# just mark the player offline.
if player is not None:
await _mark_player_offline(game, player)
else:
# Not started yet: free the seat entirely.
if player is not None:
game.players.remove(player)
if not game.players:
del games[game.gid]
await broadcast_lobby()
@sio.on("start_game")
async def start_game(sid, gid):
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 spustit hru.")
game = games.get(gid)
if game is None:
return await send_error(sid, "Hra neexistuje.")
if game.started:
return await send_error(sid, "Hra uz zacala.")
if len(game.players) != 4:
return await send_error(sid, "Nedostatocny pocet hracov.")
game.start()
# Persist the game with its 4 seats (ordered 0..3) so history can attribute guesses.
seated = sorted(game.players, key=lambda p: p.order)
await history.record_game_started(gid, game.name, [p.player_id for p in seated])
await broadcast_lobby()
await send_game_status(gid)
for player in game.players:
await send_player_cards(gid, player.order, player.sid)
@sio.on("end_game")
async def end_game(sid, gid):
"""Any seated player can permanently end a game that won't be finished --
not just the host, so the other players aren't stuck forever if the host
abandons the game. 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.")
game = games.get(gid)
if game is None:
return await send_error(sid, "Hra neexistuje.")
await history.mark_game_ended(gid)
# Notify the room first (while players are still in it), then tear it down.
await sio.emit("game_ended", {"gid": gid}, room=gid)
for player in game.players:
if player.sid:
sessions.pop(player.sid, None)
await sio.leave_room(player.sid, gid)
del games[gid]
await broadcast_lobby()
@sio.on("reconnect_to_game")
async def reconnect_to_game(sid, gid, token):
# Best-effort background reconnect: fail silently (no error toast). After a
# server restart the old token is gone -> the user rejoins from the lobby.
game = games.get(gid)
if game is None:
return
player = game.player_by_token(token)
if player is None:
return
old_sid = player.sid
if 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()
@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()
@sio.on("restore_game")
async def restore_game(sid, gid):
"""Obnov predcasne ukoncenu hru z historie spat do lobby. Smie ju vyvolat
iba hrac danej hry; v lobby sa potom objavi ako rozohrata a clenovia sa
pripoja cez `rejoin_game`."""
account = accounts.get(sid)
if account is None:
return await send_error(sid, "Musíte byť prihlásený.")
if gid in games:
# Uz je v pamati (lobby) -- staci obnovit zoznam hier u klienta.
await sio.emit("game_restored", {"gid": gid}, to=sid)
return await sio.emit("get_games", {"games": public_games()}, to=sid)
info = await history.reopen_game(gid, account["player_id"])
if info is None:
return await send_error(sid, "Hru sa nepodarilo obnovit.")
_load_game_into_memory(info)
await sio.emit("game_restored", {"gid": gid}, to=sid)
await broadcast_lobby()
# --- in-game actions (seat derived from the connection, never the client) -
@sio.on("game_status")
async def game_status(sid, *args):
resolved = _active_game(sid)
if resolved is None:
return await send_error(sid, "Nie ste v rozohratej hre.")
game, _ = resolved
await send_game_status(game.gid)
@sio.on("player_cards")
async def player_cards(sid, *args):
resolved = _active_game(sid)
if resolved is None:
return await send_error(sid, "Nie ste v rozohratej hre.")
game, sess = resolved
await send_player_cards(game.gid, sess["order"], sid)
@sio.on("add_guess")
async def add_guess(sid, guess):
resolved = _active_game(sid)
if resolved is None:
return await send_error(sid, "Nie ste v rozohratej hre.")
game, sess = resolved
try:
value = int(guess)
except (TypeError, ValueError):
return await send_error(sid, "Neplatny tip.")
try:
game.bridzik_core.add_player_guess(sess["order"], value)
except BridzikException as exc:
return await send_error(sid, str(exc))
await send_game_status(game.gid)
@sio.on("play_card")
async def play_card(sid, card_key):
resolved = _active_game(sid)
if resolved is None:
return await send_error(sid, "Nie ste v rozohratej hre.")
game, sess = resolved
core = game.bridzik_core
hand = core.get_player_cards(sess["order"])
try:
key = int(card_key)
except (TypeError, ValueError):
return await send_error(sid, "Neplatna karta.")
if key not in hand:
return await send_error(sid, "Neplatna karta.")
try:
core.play_card(sess["order"], hand[key])
except BridzikException as exc:
return await send_error(sid, str(exc))
# Persist completed rounds first so the DB-backed standings in game_status are
# up to date (idempotent; also marks the game ended).
await history.record_completed_rounds(game.gid, core)
await send_game_status(game.gid)
for player in game.players:
await send_player_cards(game.gid, player.order, player.sid)
# --- history (read-only) --------------------------------------------------
@sio.on("get_player_history")
async def get_player_history(sid, *args):
account = accounts.get(sid)
if account is None:
return await send_error(sid, "Musíte byť prihlásený.")
rows = await history.get_player_history(account["player_id"])
await sio.emit("get_player_history", {"games": rows}, to=sid)
@sio.on("get_game_detail")
async def get_game_detail(sid, gid):
account = accounts.get(sid)
if account is None:
return await send_error(sid, "Musíte byť prihlásený.")
detail = await history.get_game_detail(gid)
if detail is None:
return await send_error(sid, "Hra neexistuje.")
await sio.emit("get_game_detail", detail, to=sid)
+100
View File
@@ -0,0 +1,100 @@
"""Bezheslova autentifikacia cez TOTP (Google Authenticator a pod.).
Domenova logika nad `db/` -- tenke socket handlery v api/__init__.py ju volaju.
Hodnoty sa overuju cez pyotp; replay sa bloku pomocou Player.totp_last_step.
"""
import secrets
import time
import pyotp
from sqlalchemy import select
from db.db import async_session
from db.models import Player
ISSUER = "Bridžik"
TOTP_PERIOD = 30 # sekund -- default pyotp
class AuthError(Exception):
"""Chyba prihlasenia/registracie (slovenska sprava pre klienta)."""
def _new_token() -> str:
return secrets.token_urlsafe(48)
def _current_step() -> int:
return int(time.time()) // TOTP_PERIOD
def _verify_code(player: Player, code: str) -> None:
"""Overi TOTP kod a posunie totp_last_step. Pri neuspechu vyhodi AuthError.
Akceptuje +-1 casovy krok (tolerancia hodin) a odmietne uz pouzity krok.
"""
totp = pyotp.TOTP(player.totp_secret)
current = _current_step()
for step in (current - 1, current, current + 1):
if step <= player.totp_last_step:
continue
if totp.verify(code, for_time=step * TOTP_PERIOD):
player.totp_last_step = step
return
raise AuthError("Nesprávny alebo už použitý kód.")
async def register_account(username: str) -> dict:
"""Zaregistruje meno a vygeneruje TOTP secret. Vrati otpauth URI pre QR."""
username = (username or "").strip()
if not username:
raise AuthError("Zadajte meno.")
secret = pyotp.random_base32()
async with async_session() as session:
existing = await session.scalar(
select(Player).where(Player.username == username)
)
if existing is not None:
raise AuthError("Toto meno je už obsadené.")
session.add(Player(username=username, totp_secret=secret))
await session.commit()
otpauth_uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=ISSUER)
return {"username": username, "secret": secret, "otpauth_uri": otpauth_uri}
async def confirm_account(username: str, code: str) -> dict:
"""Potvrdi registraciu prvym kodom z aplikacie a vrati session token."""
return await _verify_and_issue_token(username, code)
async def login(username: str, code: str) -> dict:
"""Prihlasi existujuci ucet a vrati session token."""
return await _verify_and_issue_token(username, code)
async def _verify_and_issue_token(username: str, code: str) -> dict:
async with async_session() as session:
player = await session.scalar(
select(Player).where(Player.username == (username or "").strip())
)
if player is None:
raise AuthError("Účet neexistuje.")
_verify_code(player, (code or "").strip())
token = _new_token()
player.auth_token = token
await session.commit()
return {"player_id": player.id, "username": player.username, "token": token}
async def player_by_token(token: str) -> dict | None:
"""Overi session token (z Socket.IO `auth`). Vrati identitu alebo None."""
if not token:
return None
async with async_session() as session:
player = await session.scalar(
select(Player).where(Player.auth_token == token)
)
if player is None:
return None
return {"player_id": player.id, "username": player.username}
-22
View File
@@ -1,22 +0,0 @@
from flask_wtf import FlaskForm
from wtforms import SubmitField, IntegerField, RadioField, StringField
from wtforms.validators import DataRequired, NumberRange
class GuessForm(FlaskForm):
guess = IntegerField('Tip', validators=[DataRequired(message='Zadaj tip'), NumberRange(min=0, max=8)])
submit = SubmitField('Zadaj tip')
def __init__(self, max_guess: int = 8, *args, **kwargs):
super(GuessForm, self).__init__(*args, **kwargs)
self.max_guess = max_guess
class PlayForm(FlaskForm):
card = RadioField('Vyber kartu', validators=[DataRequired(message='Musíš vybrať kartu')])
submit = SubmitField('Zahraj')
class AdminForm(FlaskForm):
player0 = StringField('0', validators=[DataRequired()])
player1 = StringField('1', validators=[DataRequired()])
player2 = StringField('2', validators=[DataRequired()])
player3 = StringField('3', validators=[DataRequired()])
submit = SubmitField()
+331
View File
@@ -0,0 +1,331 @@
"""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, ROUNDS_PER_SERIES, Round, SERIES_PER_GAME, Series
from db.db import async_session
from db.models import Game, Guess, Player
# Naplno dohrana hra ma zapisanych SERIES_PER_GAME * ROUNDS_PER_SERIES
# dokoncenych kol -- tvar hry je definovany v bridzik.py, tu sa len cita.
FULL_GAME_ROUNDS = SERIES_PER_GAME * ROUNDS_PER_SERIES
def _utcnow_naive() -> datetime:
"""Naive UTC `datetime` na zapis do `ended_at`. Stlpec je TIMESTAMP WITHOUT
TIME ZONE (ako `created_at`), tz-aware hodnotu by asyncpg/Postgres odmietol."""
return datetime.now(timezone.utc).replace(tzinfo=None)
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 = _utcnow_naive()
await session.commit()
async def get_standings(gid: str) -> tuple[list[list[list[int]]], list[list[list[int]]]]:
"""Body aj tipy po seriach/kolach z DB, oboje v tvare ktory caka frontend:
`[serie][kolo][sedadlo 0..3]`. Vracia dvojicu `(points, guesses)` -- tipy
su tam, aby frontend pri 0 bodoch ukazal preskrtnuty tip namiesto nuly.
Citaju sa z tych istych `Guess` riadkov, takze jeden dotaz staci.
"""
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[serie][kolo] = ([body sedadiel], [tipy sedadiel])
series_map: dict[int, dict[int, tuple[list[int], list[int]]]] = {}
for gz in rows:
rounds = series_map.setdefault(gz.series_number, {})
points, tips = rounds.setdefault(gz.round_number, ([0, 0, 0, 0], [0, 0, 0, 0]))
seat = seat_of.get(gz.player_id)
if seat is not None:
points[seat] = gz.points
tips[seat] = gz.guess
points_table: list[list[list[int]]] = []
guesses_table: list[list[list[int]]] = []
for s in sorted(series_map):
round_nums = sorted(series_map[s])
points_table.append([series_map[s][r][0] for r in round_nums])
guesses_table.append([series_map[s][r][1] for r in round_nums])
return points_table, guesses_table
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(
Game.ended_at.is_not(None), # iba ukoncene hry
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()
# Pocet dokoncenych kol na hru (na rozlisenie naplno dohranej hry od
# predcasne ukoncenej) -- jeden batch dotaz pre vsetky hry hraca.
completed_rounds = await _completed_rounds_per_game(
session, [g.id for g in games]
)
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,
"name": g.name,
"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),
# True = dohrana naplno; False = predcasne ukoncena (da sa obnovit).
"completed": completed_rounds.get(g.id, 0) >= FULL_GAME_ROUNDS,
}
)
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,
"name": game.name,
"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}
async def _completed_rounds_per_game(session, gids: list[str]) -> dict[str, int]:
"""Pocet dokoncenych (series, round) kol na hru. Guess sa zapisuje len za
dohrate kola, takze pocet unikatnych dvojic = pocet dokoncenych kol."""
if not gids:
return {}
rows = await session.execute(
select(Guess.game_id, Guess.series_number, Guess.round_number)
.where(Guess.game_id.in_(gids))
.distinct()
)
counts: dict[str, int] = {}
for r in rows:
counts[r.game_id] = counts.get(r.game_id, 0) + 1
return counts
async def _restore_info(session, game: Game) -> dict:
"""Postavi restore-payload pre jednu hru: gid, name, sedadla (player_id +
username podla poradia 0..3) a uz postaveny Bridzik na ulozenej pozicii.
Spolocny tvar pre `reopen_game` aj `get_unfinished_games`."""
seat_ids = [game.player0_id, game.player1_id, game.player2_id, game.player3_id]
usernames = await _usernames_for(session, seat_ids)
return {
"gid": game.id,
"name": game.name,
"seats": [(pid, usernames.get(pid, "?")) for pid in seat_ids],
"core": rebuild_core(game.series, game.round),
}
async def reopen_game(gid: str, player_id: int) -> dict | None:
"""Znovu otvori predcasne ukoncenu hru: vymaze `ended_at` a vrati info na
obnovu do pamate (rovnaky tvar ako polozka z `get_unfinished_games`).
Vrati None, ak hra neexistuje, hrac v nej nie je, alebo uz bola dohrana
naplno (vtedy nie je co pokracovat).
"""
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]
if player_id not in seat_ids:
return None
counts = await _completed_rounds_per_game(session, [gid])
if counts.get(gid, 0) >= FULL_GAME_ROUNDS:
return None # naplno dohrana hra sa neobnovuje
game.ended_at = None
info = await _restore_info(session, game)
await session.commit()
return info
# --- 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 = _utcnow_naive()
await session.commit()
async def get_unfinished_games() -> list[dict]:
"""Nedohrate hry (ended_at IS NULL) aj s obnovenym jadrom -- na obnovu pri starte."""
async with async_session() as session:
games = (
await session.scalars(select(Game).where(Game.ended_at.is_(None)))
).all()
return [await _restore_info(session, g) for g in games]
-87
View File
@@ -1,87 +0,0 @@
from api import app
from bridzik import Bridzik, Card, Card_colors, Card_values, BridzikException
import json
from flask import render_template, url_for, flash, redirect
from api.forms import GuessForm, PlayForm, AdminForm
from api.utils import get_points_sums, sort_card_list
b = Bridzik()
players = [
'Jakub',
'Timo',
'Katka',
'Ondrej'
]
@app.route('/bridzik_api/get_status/<id>')
def get_status(id: int):
return json.dumps(b.get_status(int(id)), cls=Card.JSONEncoder)
@app.route('/bridzik/<player>/status')
def status(player):
player = int(player)
game_status = b.get_status(player)
action = None
form = None
player_cards = sort_card_list(b.series[-1].get_last_round().player_cards[player])
game_status['player_cards'] = [str(c) for c in player_cards]
points_sums = get_points_sums(game_status['standings'])
if b.is_completed() or b.series[-1].get_last_round().get_active_player() != player:
pass
elif not b.series[-1].get_last_round().is_guessing_completed():
form = GuessForm(max_guess= 8 - b.series[-1].get_last_round().round_number)
action = 'guess'
else:
form = PlayForm()
form.card.choices = [(str(c), str(c)) for c in player_cards]
action = 'play'
return render_template(
'status.html', status=game_status, player=player, action=action,
form=form, players=players, points_sums=points_sums
)
@app.route('/bridzik/<player>/guess', methods=['POST'])
def guess(player):
player = int(player)
form = GuessForm()
try:
b.add_player_guess(player, int(form.guess.data))
except BridzikException:
flash('Nie je možné zadať tip.')
return redirect(url_for('status', player=player))
@app.route('/bridzik/<player>/play_card', methods=['POST'])
def play_card(player):
player = int(player)
player_cards = b.series[-1].get_last_round().player_cards[player]
form = PlayForm()
form.card.choices = [(str(c), str(c)) for c in player_cards]
color, value = form.card.data.split('_')
try:
card = Card(Card_colors[color], Card_values[value])
except KeyError:
flash('Chyba. Opakuj pokus znovu.')
try:
b.play_card(player, card)
except BridzikException:
flash('Nie je možné zahrať kartu.')
return redirect(url_for('status', player=player))
@app.route('/bridzik/admin', methods=['GET', 'POST'])
def admin():
form = AdminForm()
if form.validate_on_submit():
players[0] = form.player0.data
players[1] = form.player1.data
players[2] = form.player2.data
players[3] = form.player3.data
return redirect(url_for('admin'))
else:
form.player0.data = players[0]
form.player1.data = players[1]
form.player2.data = players[2]
form.player3.data = players[3]
return render_template('admin.html', form=form)
-51
View File
@@ -1,51 +0,0 @@
#header {
display: flex;
}
#header>div {
padding: 10px;
border: 2px solid grey;
}
.state_row_header {
white-space: nowrap;
text-align: right;
}
.points {
text-align: center;
}
#wrapper {
display: grid;
grid-template-columns: 1fr 250px;
grid-template-rows: 1fr;
grid-gap: 10px;
}
.card_display {
width: 82px;
height: 150px;
}
#points_summary_row {
border-top: 4px double black;
}
table {
border-collapse: collapse;
}
.active_player_highlight {
background: grey;
color: white;
}
.player_name_highlight {
background: grey;
color: white;
}
.standings_round_final_row {
border-bottom: 1px solid black;
}
-11
View File
@@ -1,11 +0,0 @@
<form action="{{ url_for('guess', player=player) }}" method="post">
{{ form.hidden_tag() }}
<p>
{{ form.guess.label }}:
{{ form.guess(size=15, autocomplete="off") }}
{% for error in form.guess.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
{{ form.submit() }}
</p>
</form>
-39
View File
@@ -1,39 +0,0 @@
<html>
<head>
<title>Bridžik admin</title>
</head>
<body>
<form action="" method="post">
{{ form.hidden_tag() }}
<p>
{{ form.player0.label }}:
{{ form.player0(size=15) }}
{% for error in form.player0.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form.player1.label }}:
{{ form.player1(size=15) }}
{% for error in form.player1.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form.player2.label }}:
{{ form.player2(size=15) }}
{% for error in form.player2.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form.player3.label }}:
{{ form.player3(size=15) }}
{% for error in form.player3.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>{{ form.submit() }}</p>
</form>
</body>
</html>
-176
View File
@@ -1,176 +0,0 @@
<html>
<head>
<title>Bridžik</title>
<link href="/static/style.css" rel="stylesheet">
{% if not action%}
<meta http-equiv="refresh" content="2">
{% endif %}
</head>
<body>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
<hr>
{% endif %}
{% endwith %}
{% if 'active_player' not in status %}
<h1>Hra sa skončila.</h1>
{% else %}
<div id="wrapper">
<div id="table">
<div id="header">
<div {% if player == status['active_player'] %}class="active_player_highlight"{% endif %}>
<p id="active_player" >Na ťahu je: {{ players[status['active_player']] }}</p>
</div>
<div id="active_round_guesses">
<p></p>
<table style="width: auto;">
<thead>
<th></th>
<th class="state_column_header">{{ players[0] }}</th>
<th class="state_column_header">{{ players[1] }}</th>
<th class="state_column_header">{{ players[2] }}</th>
<th class="state_column_header">{{ players[3] }}</th>
</thead>
<tbody>
<tr>
<th class="state_row_header">Tipy v tomto kole:</th>
{% for player in range(4) %}
<td class="points">{{ status['active_round_guesses'][player] }}</td>
{% endfor %}
</tr>
{% if status['active_round_stashes'] %}
<tr>
<th class="state_row_header">Kôpky v tomto kole:</th>
{% for player in status['active_round_stashes'] %}
<td class="points">{{ player }}</td>
{% endfor %}
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<hr>
{% if action == 'guess' %}
<h1>Zadaj tip:</h1>
{% include "_guess_form.html" %}
{% endif %}
{% if status['active_stash'] %}
<h1>Aktuálna kôpka:</h1>
<table style="width: auto;">
{% for player in range(status['active_stash']['first_player'], status['active_stash']['first_player'] + 4) %}
<th>{{ players[player % 4] }}</th>
{% endfor %}
<tr>
{% for player in range(status['active_stash']['first_player'], status['active_stash']['first_player'] + 4) %}
<td class="card_display">
{% if status['active_stash']['cards'][player % 4] %}
<img src="/static/cards/{{ status['active_stash']['cards'][player % 4] }}.png" width="80" height="auto">
{% endif %}
</td>
{% endfor %}
</tr>
</table>
{% endif %}
<hr>
{% if action != 'play' %}
<h1>Moje karty:</h1>
<table id="player_hand">
{% for card in status['player_cards'] %}
<td>
<img src="/static/cards/{{ card }}.png" width="80" height="auto">
</td>
{% endfor %}
</table>
{% else %}
<h1>{{ form.card.label }}:</h1>
<form action="{{ url_for('play_card', player=player) }}" method="post">
{{ form.hidden_tag() }}
<p>
<table style="width: auto">
{% for card in status['player_cards'] %}
<td>
<img src="/static/cards/{{ card }}.png" width="80" height="auto">
</td>
{% endfor %}
<tr>
{% for card in status['player_cards'] %}
<td>
<input id="card-{{ loop.index }}" name="card" type="radio" value="{{ card }}">
</td>
{% endfor %}
</tr>
</table>
{% for error in form.card.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
{{ form.submit() }}
</p>
</form>
{% endif %}
{% endif %}
{% if status['previous_stash'] %}
<h1>Predchádzajúca kôpka:</h1>
<table style="width: auto;">
{% for player in range(status['previous_stash']['first_player'], status['previous_stash']['first_player'] + 4) %}
<th>{{ players[player % 4] }}</th>
{% endfor %}
<tr>
{% for player in range(status['previous_stash']['first_player'], status['previous_stash']['first_player'] + 4) %}
<td class="card_display">
{% if status['previous_stash']['cards'][player % 4] %}
<img src="/static/cards/{{ status['previous_stash']['cards'][player % 4] }}.png" width="80" height="auto">
{% endif %}
</td>
{% endfor %}
</tr>
</table>
{% endif %}
</div>
<div id="standings">
<h1>Výsledky</h1>
<table style="width: auto;">
<tr>
<th>{{ players[0] }}</th>
<th>{{ players[1] }}</th>
<th>{{ players[2] }}</th>
<th>{{ players[3] }}</th>
</tr>
{% for series in status['standings'] %}
{% if series %}
{% for round in series %}
{% if round %}
<tr {% if loop.index == 7 %}class="standings_round_final_row"{% endif %} >
{% for player in round %}
<td class="points">{{ player }}</td>
{% endfor %}
</tr>
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
<tr id="points_summary_row">
{% for player in points_sums %}
<td class="points">{{ player }}</td>
{% endfor %}
</tr>
</table>
</div>
</div>
</body>
</html>
-23
View File
@@ -1,23 +0,0 @@
from bridzik import Card_colors
def get_points_sums(standings: []):
sums = [0] * 4
for series in standings:
for round in series:
for player, points in enumerate(round):
sums[player] += points
return sums
def sort_card_list(input_card_set: []) -> []:
color_paritions = [
[c for c in input_card_set if c.color == Card_colors['HEARTS']],
[c for c in input_card_set if c.color == Card_colors['LEAVES']],
[c for c in input_card_set if c.color == Card_colors['ACORNS']],
[c for c in input_card_set if c.color == Card_colors['BELLS']]
]
output_list = []
for color_list in color_paritions:
color_list.sort(key=lambda a : a.value)
output_list.extend(color_list)
return output_list
+6
View File
@@ -0,0 +1,6 @@
import uvicorn
if __name__ == "__main__":
# Local dev entrypoint: `python -m app`
# (production / Docker runs `uvicorn api:app` directly).
uvicorn.run("api:app", host="0.0.0.0", port=5000, reload=True)
+19 -9
View File
@@ -82,8 +82,15 @@ class Card():
cards = [Card(color, value) for value in Card_values for color in Card_colors] cards = [Card(color, value) for value in Card_values for color in Card_colors]
# Sturktura hry: kazda hra ma SERIES_PER_GAME serii, kazda seria ma
# ROUNDS_PER_SERIES kol. Jediny zdroj pravdy pre tieto cisla -- ina vrstva
# (napr. api/history.py) ich odvodzuje odtialto, nikdy si ich nevymysla sama.
SERIES_PER_GAME = 4
ROUNDS_PER_SERIES = 8
class Bridzik(): class Bridzik():
def __init__(self, shuffler = shuffle): def __init__(self, shuffler=shuffle):
self.shuffler = shuffler self.shuffler = shuffler
self.series = [Series(0, 0, shuffler=self.shuffler)] self.series = [Series(0, 0, shuffler=self.shuffler)]
@@ -99,13 +106,16 @@ class Bridzik():
raise BridzikException('Hra je ukoncena.') raise BridzikException('Hra je ukoncena.')
self.series[-1].add_player_guess(player, guess) self.series[-1].add_player_guess(player, guess)
def get_status(self, player: int): def get_player_cards(self, player: int):
player_cards = self.series[-1].get_last_round().player_cards[player]
return {k: v for k, v in enumerate(player_cards)}
def get_status(self):
status = {} status = {}
last_series = self.series[-1] last_series = self.series[-1]
if not self.is_completed(): if not self.is_completed():
status['active_player'] = last_series.get_last_round().get_active_player() status['active_player'] = last_series.get_last_round().get_active_player()
status['active_round_guesses'] = last_series.get_last_round().guesses status['active_round_guesses'] = last_series.get_last_round().guesses
status['player_cards'] = last_series.get_last_round().player_cards[player]
if last_series.get_last_round().is_guessing_completed(): if last_series.get_last_round().is_guessing_completed():
status['active_round_stashes'] = last_series.get_last_round().get_stashes_winner_summary() status['active_round_stashes'] = last_series.get_last_round().get_stashes_winner_summary()
status['active_stash'] = { status['active_stash'] = {
@@ -121,7 +131,7 @@ class Bridzik():
return status return status
def is_completed(self): def is_completed(self):
return len(self.series) == 4 and self.series[-1].is_completed() return len(self.series) == SERIES_PER_GAME and self.series[-1].is_completed()
def get_previous_stash(self): def get_previous_stash(self):
if len(self.series[-1].get_last_round().stashes) > 1: if len(self.series[-1].get_last_round().stashes) > 1:
@@ -166,7 +176,7 @@ class Series():
self.start_new_round() self.start_new_round()
def is_completed(self): def is_completed(self):
return len(self.rounds) == 8 and self.get_last_round().is_completed() return len(self.rounds) == ROUNDS_PER_SERIES and self.get_last_round().is_completed()
def get_standings(self): def get_standings(self):
return [r.get_points_summary() for r in self.rounds if r.is_completed()] return [r.get_points_summary() for r in self.rounds if r.is_completed()]
@@ -186,9 +196,9 @@ class Series():
class Round(): class Round():
def __init__(self, round_number: int, first_player: int, cards: []=cards, shuffler = shuffle): def __init__(self, round_number: int, first_player: int, cards: []=cards, shuffler=shuffle):
# vyrob kopku pre toto kolo a priprav prazdne objekty # vyrob kopku pre toto kolo a priprav prazdne objekty
if round_number not in [i for i in range(8)]: if round_number not in range(ROUNDS_PER_SERIES):
raise BridzikException('Neplatne cislo kola.') raise BridzikException('Neplatne cislo kola.')
if first_player not in [0, 1, 2, 3]: if first_player not in [0, 1, 2, 3]:
raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.') raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.')
@@ -285,7 +295,7 @@ class Round():
def get_last_stash(self): def get_last_stash(self):
return self.stashes[-1] if self.stashes else None return self.stashes[-1] if self.stashes else None
def deal_starting_cards(self, cards: [], shuffler = shuffle): def deal_starting_cards(self, cards: [], shuffler=shuffle):
self.round_cards = cards.copy() self.round_cards = cards.copy()
shuffler(self.round_cards) shuffler(self.round_cards)
self.round_cards = self.round_cards[(4*self.round_number):] self.round_cards = self.round_cards[(4*self.round_number):]
@@ -308,7 +318,7 @@ class Round():
self.stashes.append(Stash(self.get_last_stash().get_winner())) self.stashes.append(Stash(self.get_last_stash().get_winner()))
class Stash(): class Stash:
def __init__(self, first_player: int): def __init__(self, first_player: int):
if first_player not in [0, 1, 2, 3]: if first_player not in [0, 1, 2, 3]:
raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.') raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.')
+9
View File
@@ -0,0 +1,9 @@
"""Datova vrstva (persistencia). Nezavisla od Socket.IO/Flask, ako engine.
Exportuje pripojenie a ORM modely; aplikacna logika (api/) ich pouziva.
"""
from db.db import Base, engine, async_session, init_db
from db import models
__all__ = ["Base", "engine", "async_session", "init_db", "models"]
+35
View File
@@ -0,0 +1,35 @@
"""Async SQLAlchemy pripojenie a inicializacia schemy.
Connection string z env DATABASE_URL. Default je lokalny SQLite subor (dev);
v produkcii staci nastavit DATABASE_URL na PostgreSQL (asyncpg), kod sa nemeni.
"""
import os
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///bridzik.db")
class Base(DeclarativeBase):
pass
engine = create_async_engine(DATABASE_URL, echo=False)
async_session: async_sessionmaker[AsyncSession] = async_sessionmaker(
engine, expire_on_commit=False
)
async def init_db() -> None:
"""Vytvori tabulky, ak este neexistuju. Vola sa pri starte servera."""
# Import modelov registruje tabulky na Base.metadata.
from db import models # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+85
View File
@@ -0,0 +1,85 @@
"""ORM modely: Player, Game, Guess.
Zamerne minimalne (3 tabulky). `won` sa neuklada -- vyplyva z `points > 0`
(trafeny tip = 10 + tip, inak 0; pozri Round.get_points_summary v bridzik.py).
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from db.db import Base
class Player(Base):
"""Trvaly ucet hraca + autentifikacia (TOTP)."""
__tablename__ = "players"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(40), unique=True, index=True)
totp_secret: Mapped[str] = mapped_column(String(32))
# Posledny pouzity TOTP casovy krok -- ochrana proti replay v ramci okna.
totp_last_step: Mapped[int] = mapped_column(Integer, default=0)
# Session token pre auto-reconnect (poslany v Socket.IO `auth`).
auth_token: Mapped[str | None] = mapped_column(
String(64), unique=True, nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
class Game(Base):
"""Jedna partia. Drzi priamo 4 ID hracov podla sedadla (0-3)."""
__tablename__ = "games"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
name: Mapped[str] = mapped_column(String(40), default="")
player0_id: Mapped[int] = mapped_column(ForeignKey("players.id"))
player1_id: Mapped[int] = mapped_column(ForeignKey("players.id"))
player2_id: Mapped[int] = mapped_column(ForeignKey("players.id"))
player3_id: Mapped[int] = mapped_column(ForeignKey("players.id"))
# Aktualna pozicia hry. Spolu s rozdanim kariet nanovo plne urcuju stav hry
# po zaciatok kola -> staci na restore (pozri api/history.rebuild_core).
series: Mapped[int] = mapped_column(Integer, default=0)
round: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
def player_id_for_seat(self, seat: int) -> int:
return (self.player0_id, self.player1_id, self.player2_id, self.player3_id)[seat]
class Guess(Base):
"""Tip a vysledok jedneho hraca v jednom kole.
Unikat (game_id, series_number, round_number, player_id) robi zapis
idempotentnym -- opakovany `record_completed_rounds` nezaklada duplikaty.
"""
__tablename__ = "guesses"
__table_args__ = (
UniqueConstraint(
"game_id",
"series_number",
"round_number",
"player_id",
name="uq_guess_round_player",
),
)
id: Mapped[int] = mapped_column(primary_key=True)
game_id: Mapped[str] = mapped_column(ForeignKey("games.id"), index=True)
player_id: Mapped[int] = mapped_column(ForeignKey("players.id"), index=True)
series_number: Mapped[int] = mapped_column(Integer)
round_number: Mapped[int] = mapped_column(Integer)
guess: Mapped[int] = mapped_column(Integer)
points: Mapped[int] = mapped_column(Integer)
+46
View File
@@ -0,0 +1,46 @@
services:
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: bridzik
POSTGRES_PASSWORD: bridzik
POSTGRES_DB: bridzik
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bridzik -d bridzik"]
interval: 5s
timeout: 5s
retries: 5
backend:
build: .
environment:
# Async SQLAlchemy URL -> the Postgres service above (asyncpg driver).
DATABASE_URL: postgresql+asyncpg://bridzik:bridzik@db:5432/bridzik
ports:
- "5000:5000"
volumes:
- ./:/app
depends_on:
db:
condition: service_healthy
frontend:
image: node:22-alpine
working_dir: /app
environment:
# Inside the compose network the backend is reachable as `backend`.
VITE_BACKEND_URL: http://backend:5000
volumes:
- ./frontend:/app
ports:
- "5173:5173"
command: sh -c "npm install && npm run dev -- --host"
depends_on:
- backend
volumes:
pgdata:
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="sk">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#090e0b" />
<title>Bridžik</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;1,400&family=DM+Sans:wght@300;400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+7125
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "bridzik-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"qrcode.react": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"socket.io-client": "^4.7.5",
"zustand": "^4.5.4"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.3",
"vite": "^5.4.0",
"vite-plugin-pwa": "^0.20.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 852 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+86
View File
@@ -0,0 +1,86 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { useGameStore } from './store/gameStore';
import { socket, emit } from './lib/socket';
import type { MyPlayer } from './types';
import GameList from './pages/GameList';
import Lobby from './pages/Lobby';
import GameTable from './pages/GameTable';
import Auth from './pages/Auth';
import History from './pages/History';
function AppInner() {
const navigate = useNavigate();
const location = useLocation();
const account = useGameStore((s) => s.account);
const myPlayer = useGameStore((s) => s.myPlayer);
const gameStatus = useGameStore((s) => s.gameStatus);
const error = useGameStore((s) => s.error);
const clearError = useGameStore((s) => s.clearError);
// Reconnect into an in-progress game on every socket connect (after network drops).
useEffect(() => {
const doReconnect = () => {
const saved = localStorage.getItem('bridzik_player');
if (!saved) return;
const player = JSON.parse(saved) as MyPlayer;
emit.reconnectToGame(player.gid, player.token);
};
socket.on('connect', doReconnect);
if (socket.connected) doReconnect();
return () => { socket.off('connect', doReconnect); };
}, []);
// Single authoritative navigation:
// - not logged in → /auth
// - logged in & in a game → lobby/game
// - logged in, no game, on /auth (just logged in) → leave for the game list
// - logged in, no game, elsewhere → null (let the user navigate: list/history)
const onGameRoute =
location.pathname === '/auth' ||
location.pathname.startsWith('/game') ||
location.pathname.startsWith('/lobby');
const targetRoute = !account
? '/auth'
: myPlayer
? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}`
: onGameRoute ? '/' : null;
useEffect(() => {
if (!targetRoute) return;
navigate(targetRoute, { replace: true });
}, [targetRoute, navigate]);
// Auto-dismiss errors after 4 s
useEffect(() => {
if (!error) return;
const t = setTimeout(clearError, 4000);
return () => clearTimeout(t);
}, [error, clearError]);
return (
<>
{error && (
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 bg-red-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm max-w-xs text-center">
{error}
</div>
)}
<Routes>
<Route path="/auth" element={<Auth />} />
<Route path="/" element={<GameList />} />
<Route path="/history" element={<History />} />
<Route path="/lobby/:gid" element={<Lobby />} />
<Route path="/game/:gid" element={<GameTable />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</>
);
}
export default function App() {
return (
<BrowserRouter>
<AppInner />
</BrowserRouter>
);
}
+84
View File
@@ -0,0 +1,84 @@
import type { Card } from '../types';
const SUIT_SYMBOL: Record<string, string> = {
HEARTS: '♥',
LEAVES: '♠',
ACORNS: '♣',
BELLS: '♦',
};
const SUIT_COLOR: Record<string, string> = {
HEARTS: '#c40000',
LEAVES: '#1e7a1e',
ACORNS: '#b87a00',
BELLS: '#0087b8',
};
const VALUE_LABEL: Record<string, string> = {
C7: 'VII', C8: 'VIII', C9: 'IX', C10: 'X',
LOWER: 'J', UPPER: 'Q', KING: 'K', ACE: 'A',
};
// Per-size geometry. sm/md sit on the table, lg/xl are hand cards.
const DIMS = {
sm: { w: 38, h: 54, radius: 4, inset: 3, label: 9, suitSm: 7, suitLg: 18 },
md: { w: 56, h: 80, radius: 6, inset: 4, label: 12, suitSm: 10, suitLg: 30 },
lg: { w: 60, h: 84, radius: 7, inset: 5, label: 12, suitSm: 9, suitLg: 30 },
xl: { w: 72, h: 100, radius: 8, inset: 6, label: 14, suitSm: 11, suitLg: 38 },
} as const;
interface Props {
card: Card;
onClick?: () => void;
disabled?: boolean;
/** Playable card on your turn — gold glow border + lift. */
highlight?: boolean;
size?: keyof typeof DIMS;
}
export default function CardView({ card, onClick, disabled = false, highlight = false, size = 'md' }: Props) {
const symbol = SUIT_SYMBOL[card.color];
const color = SUIT_COLOR[card.color];
const label = VALUE_LABEL[card.value];
const d = DIMS[size];
const interactive = !disabled && !!onClick;
const corner = (rotated: boolean) => (
<span
className="absolute flex flex-col items-center leading-none"
style={
rotated
? { bottom: d.inset, right: d.inset, transform: 'rotate(180deg)' }
: { top: d.inset, left: d.inset }
}
>
<span style={{ color, fontSize: d.label, fontFamily: 'Georgia,serif', fontWeight: 700, lineHeight: 1 }}>
{label}
</span>
<span style={{ color, fontSize: d.suitSm, lineHeight: 1.2 }}>{symbol}</span>
</span>
);
return (
<button
onClick={onClick}
disabled={disabled || !onClick}
style={{ width: d.w, height: d.h, borderRadius: d.radius }}
className={[
'relative bg-white overflow-hidden flex-none transition-transform',
highlight
? 'border-2 border-gold animate-g1 -translate-y-2'
: 'border border-[#ddd8d0] shadow-[0_2px_8px_rgba(0,0,0,.35)]',
disabled && !highlight ? 'opacity-[.35]' : '',
interactive ? 'cursor-pointer hover:-translate-y-1 active:scale-95' : 'cursor-default',
].join(' ')}
>
{corner(false)}
<span className="absolute inset-0 flex items-center justify-center">
<span style={{ color, fontSize: d.suitLg, lineHeight: 1 }}>{symbol}</span>
</span>
{corner(true)}
</button>
);
}
+43
View File
@@ -0,0 +1,43 @@
interface Props {
/** Exact number of cards the player still holds. */
count: number;
/** Row (top player) or column (side players). */
direction: 'row' | 'col';
desktop?: boolean;
}
/** Overlapping fan of face-down cards next to an opponent's circle — one card
* per card still in their hand, so the stack shrinks as they play. */
export default function FaceDownCards({ count, direction, desktop = false }: Props) {
const n = Math.max(0, Math.min(count, 8));
if (n === 0) return null;
const row = direction === 'row';
const w = desktop ? 40 : 25;
const h = desktop ? 56 : 36;
const overlap = row ? Math.round(w * 0.45) : Math.round(h * 0.5);
return (
<div className="flex" style={{ flexDirection: row ? 'row' : 'column' }}>
{Array.from({ length: n }).map((_, i) => (
<div
key={i}
style={{
width: w,
height: h,
marginLeft: row && i > 0 ? -overlap : 0,
marginTop: !row && i > 0 ? -overlap : 0,
zIndex: i,
background:
i % 2 === 0
? 'linear-gradient(150deg,#1d4a28,#0e2818)'
: 'linear-gradient(150deg,#1b4424,#0d2616)',
borderRadius: 3,
border: '1px solid rgba(201,168,76,.18)',
boxShadow: '0 2px 6px rgba(0,0,0,.5)',
}}
/>
))}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { emit } from '../lib/socket';
interface Props {
cardsInRound: number;
guesses: Record<string, number>;
myOrder: number;
activePlayer: number;
activePlayerName: string;
}
export default function GuessControls({ cardsInRound, guesses, myOrder, activePlayer, activePlayerName }: Props) {
const guessCount = Object.keys(guesses).length;
const isMyTurn = activePlayer === myOrder;
const isLastToGuess = guessCount === 3;
const alreadySum = Object.values(guesses).reduce((a, b) => a + b, 0);
const forbidden = isLastToGuess ? cardsInRound - alreadySum : -1;
if (!isMyTurn) {
return (
<p className="text-center text-sm text-green-dim py-3">
Čaká sa na tip:{' '}
<span className="font-serif text-gold">{activePlayerName}</span>
</p>
);
}
const options = Array.from({ length: cardsInRound + 1 }, (_, i) => i);
return (
<div className="flex flex-col items-center gap-3 py-3">
<p className="font-serif italic text-gold-dim text-[14px]">Zadaj svoj tip (počet kopiek)</p>
<div className="flex flex-wrap gap-2 justify-center">
{options.map((n) => {
const isForbidden = n === forbidden;
return (
<button
key={n}
disabled={isForbidden}
onClick={() => emit.addGuess(n)}
title={isForbidden ? 'Zakázaná hodnota (súčet = počet kopiek)' : undefined}
className={[
'w-11 h-11 rounded-full font-serif text-lg border-2 transition-colors',
isForbidden
? 'border-[#5a2a2a] text-[#7a4040] opacity-40 cursor-not-allowed'
: 'border-gold/50 text-gold hover:bg-gold hover:text-table hover:border-gold active:scale-95',
].join(' ')}
>
{n}
</button>
);
})}
</div>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import type { CardColor, Hand } from '../types';
import CardView from './CardView';
import { emit } from '../lib/socket';
const COLOR_ORDER: CardColor[] = ['HEARTS', 'LEAVES', 'ACORNS', 'BELLS'];
const VALUE_ORDER = ['C7', 'C8', 'C9', 'C10', 'LOWER', 'UPPER', 'KING', 'ACE'];
function groupedByColor(hand: Hand): { color: CardColor; keys: string[] }[] {
return COLOR_ORDER
.map((color) => ({
color,
keys: Object.keys(hand)
.filter((k) => hand[k].color === color)
.sort((a, b) => VALUE_ORDER.indexOf(hand[a].value) - VALUE_ORDER.indexOf(hand[b].value)),
}))
.filter((g) => g.keys.length > 0);
}
interface Props {
hand: Hand;
myTurn: boolean;
isPlayPhase: boolean;
playableKeys?: Set<string>;
desktop?: boolean;
}
export default function Hand({ hand, myTurn, isPlayPhase, playableKeys, desktop = false }: Props) {
const groups = groupedByColor(hand);
if (groups.length === 0) return null;
const canPlay = isPlayPhase && myTurn;
const cardProps = (key: string) => {
const legal = playableKeys === undefined || playableKeys.has(key);
const playable = canPlay && legal;
// Cards stay light by default; darken only the illegal ones, and only while
// it's actually your turn to play.
const dimmed = canPlay && !legal;
return {
card: hand[key],
highlight: playable,
disabled: dimmed,
onClick: playable ? () => emit.playCard(key) : undefined,
};
};
return (
<div className="bg-header border-t border-[#111a13] px-4 pt-3 pb-7">
<div className="flex items-center justify-center gap-2 mb-3">
<div className="h-px flex-1 max-w-[80px] bg-gradient-to-r from-transparent to-gold/20" />
<span className="uppercase tracking-[.13em] text-[9px] text-green-dim">Tvoje karty</span>
<div className="h-px flex-1 max-w-[80px] bg-gradient-to-l from-transparent to-gold/20" />
</div>
{desktop ? (
// Desktop has room — keep cards grouped by suit, wrap if needed.
<div className="flex flex-wrap gap-3 justify-center items-end">
{groups.map(({ color, keys }) => (
<div key={color} className="flex gap-1 items-end">
{keys.map((key) => (
<CardView key={key} size="xl" {...cardProps(key)} />
))}
</div>
))}
</div>
) : (
<MobileHand groups={groups} cardProps={cardProps} />
)}
</div>
);
}
/** All cards in a single overlapping row that always fits the mobile width:
* small gap when few cards, partial overlap when many. */
function MobileHand({
groups,
cardProps,
}: {
groups: { color: CardColor; keys: string[] }[];
cardProps: (key: string) => { card: Hand[string]; highlight: boolean; disabled: boolean; onClick?: () => void };
}) {
const keys = groups.flatMap((g) => g.keys);
const n = keys.length;
const CARD_W = 60; // matches CardView size "lg"
const MAX_ROW = 300; // keep within a small phone's usable width (~360px screens)
// Horizontal step between successive cards; <CARD_W means they overlap.
const step = n > 1 ? Math.min(CARD_W + 6, (MAX_ROW - CARD_W) / (n - 1)) : 0;
const margin = step - CARD_W; // negative → overlap, positive → gap
return (
<div className="flex justify-center items-end">
{keys.map((key, i) => (
<div
key={key}
className="relative"
// Playable cards lift up — keep them above their neighbours.
style={{ marginLeft: i === 0 ? 0 : margin, zIndex: cardProps(key).highlight ? 100 + i : i }}
>
<CardView size="lg" {...cardProps(key)} />
</div>
))}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket';
interface Props {
onClose: () => void;
}
/** Prompt for a new game's name. Identity is taken from the logged-in session,
* so no player name is asked here. */
export default function NameModal({ onClose }: Props) {
const [name, setName] = useState('');
const myPlayer = useGameStore((s) => s.myPlayer);
// Close once we've joined the just-created game (create → register chain done).
useEffect(() => {
if (myPlayer) onClose();
}, [myPlayer, onClose]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
// Stores the name; the socket listener auto-chains register_player.
emit.createGame(trimmed);
};
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-40 p-4">
<div className="bg-header border border-[#142018] rounded-2xl p-6 w-full max-w-sm shadow-[0_28px_88px_rgba(0,0,0,.65)]">
<h2 className="font-serif text-xl mb-4 text-gold">Názov hry</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={30}
placeholder="Napr. Večerná partia"
className="bg-circle text-green-score rounded-lg px-4 py-2 border border-gold/20 outline-none focus:border-gold/60 focus:ring-1 focus:ring-gold/30 placeholder:text-green-dim/60"
/>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg text-green-dim hover:text-gold"
>
Zrušiť
</button>
<button
type="submit"
disabled={!name.trim()}
className="px-4 py-2 rounded-lg bg-gold text-table font-serif font-semibold disabled:opacity-40 hover:bg-gold-bright transition-colors"
>
Vytvoriť
</button>
</div>
</form>
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
interface Props {
name: string;
/** Tricks won this round. */
won: number;
/** Bid for this round (null until the player has guessed). */
guess: number | null;
/** Whether it is this player's turn — the only state that highlights a circle. */
active: boolean;
size?: number;
}
export default function PlayerCircle({ name, won, guess, active, size = 52 }: Props) {
const nameFont = Math.max(9, Math.round(size * 0.17));
const valueFont = Math.round(size * 0.32);
// Oval: width = size, height a touch shorter so it reads as an ellipse.
const height = Math.round(size * 0.78);
return (
<div
// Only the active player is highlighted (gold ring + glow) — colors come
// from the velvet-table palette tokens (tailwind.config.js), not literals.
className={`flex flex-col items-center justify-center rounded-[50%] ${
active ? 'bg-circle-active border-2 border-gold' : 'bg-circle border-[1.5px] border-gold/20'
}`}
style={{
width: size,
height,
boxShadow: '0 2px 10px rgba(0,0,0,.45)',
animation: active ? 'ar 2.2s ease-in-out infinite' : undefined,
}}
>
<span
className={`uppercase leading-tight text-center ${active ? 'text-gold' : 'text-green-circle'}`}
style={{
fontFamily: '"DM Sans",sans-serif',
fontSize: nameFont,
letterSpacing: '.09em',
fontWeight: 500,
}}
>
{name}
</span>
<span
className={`leading-none ${active ? 'text-gold-bright' : 'text-gold'}`}
style={{
fontFamily: '"Playfair Display",serif',
fontSize: valueFont,
fontWeight: active ? 700 : 400,
}}
>
{won}
<span style={{ fontSize: valueFont * 0.6, color: '#b0a585' }}>/{guess ?? '?'}</span>
</span>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
interface Props {
onClose: () => void;
}
export default function RulesModal({ onClose }: Props) {
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/70 p-4 overflow-y-auto"
onClick={onClose}
>
<div
className="relative bg-header border border-[#142018] rounded-2xl w-full max-w-lg my-6 p-6 text-sm leading-relaxed text-green-score shadow-[0_28px_88px_rgba(0,0,0,.65)]"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
className="absolute top-4 right-4 text-green-dim hover:text-gold text-xl leading-none"
>
</button>
<h1 className="font-serif text-2xl text-gold mb-4">Pravidlá hry Bridžik</h1>
<Section title="Karty">
<p>Hrá sa s <b>32-kartovým balíčkom</b> sedmových (slovenských/nemeckých) kariet.</p>
<p className="mt-2"><b>Farby:</b> červeň (), zeleň (), žaluď (), guľa ()</p>
<p className="mt-1 text-red-400 font-semibold">Červeň je vždy tromf (adut) prebíja každú inú farbu.</p>
<p className="mt-2"><b>Hodnoty</b> od najnižšej: VII · VIII · IX · X · J · Q · K · A</p>
</Section>
<Section title="Štruktúra hry">
<p>4 hráči · 4 série · 8 kôl v sérii</p>
<p className="mt-1">V každom kole dostane každý hráč <b>8 číslo_kola</b> kariet (8 1).</p>
<p className="mt-1">Sériu otvára hráč s rovnakým číslom ako séria. Každé ďalšie kolo posúva začínajúceho hráča o jedného.</p>
</Section>
<Section title="Priebeh kola">
<p className="font-semibold">1. Tipovanie</p>
<p className="mt-1">Každý hráč tipuje, koľko kopiek v kole získa (0 počet kopiek).</p>
<p className="mt-1 text-gold-dim">Pravidlo bridžika: súčet tipov nesmie presne rovnať počtu kopiek v kole posledný tipujúci nemôže zadať tip, ktorý by toto spôsobil.</p>
<p className="font-semibold mt-3">2. Hranie kariet</p>
<p className="mt-1">Prvú kopku otvára hráč s <b>najvyšším tipom</b>. Každú ďalšiu otvára víťaz predchádzajúcej kopky.</p>
<p className="font-semibold mt-3">Povinnosť priznať farbu:</p>
<ol className="mt-1 list-decimal list-inside space-y-1">
<li>Máš farbu vynesenej karty <b>musíš ju zahrať.</b></li>
<li>Nemáš ju, ale máš červeň <b>musíš zahrať červeň.</b></li>
<li>Nemáš ani jedno môžeš zahrať <b>ľubovoľnú</b> kartu.</li>
</ol>
<p className="font-semibold mt-3">Víťaz kopky:</p>
<ul className="mt-1 list-disc list-inside space-y-1">
<li>Ak padla červeň vyhráva <b>najvyššia červeň.</b></li>
<li>Ak nie vyhráva <b>najvyššia karta vynesenej farby.</b></li>
</ul>
</Section>
<Section title="Bodovanie">
<p>Po každom kole: ak sa tip <b>presne zhoduje</b> s počtom získaných kopiek <b>10 + tip</b> bodov, inak <b>0</b>.</p>
<p className="mt-1 text-green-dim">Príklad: tipoval 3, získal 3 13 bodov. Tipoval 3, získal 2 0 bodov.</p>
<p className="mt-2">Vyhráva hráč s najvyšším celkovým súčtom po 4 sériách.</p>
</Section>
<button
onClick={onClose}
className="mt-4 w-full py-2.5 rounded-xl border border-gold/30 text-gold hover:bg-gold hover:text-table font-serif font-semibold transition-colors"
>
Zavrieť
</button>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-4">
<h2 className="font-serif text-base text-gold mb-1">{title}</h2>
<div className="text-green-score">{children}</div>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { useState } from 'react';
import type { PlayerInfo } from '../types';
import { computeTotal } from '../lib/standings';
interface Props {
standings: number[][][];
/** Tips per series/round/seat, same shape as standings. */
guesses?: number[][][];
players: PlayerInfo[];
myOrder: number;
/** Desktop renders an always-open sidebar; mobile a collapsible panel. */
desktop?: boolean;
}
export default function Standings({ standings, guesses = [], players, myOrder, desktop = false }: Props) {
const [open, setOpen] = useState(false);
// Player columns in seat order; the local player's column is highlighted.
const cols = [...players].sort((a, b) => a.order - b.order);
// Completed-round count before each series (running total), so each series'
// round index can be offset in a single pass instead of re-summing per row.
const seriesRoundOffsets: number[] = [];
let completedRounds = 0;
for (const s of standings) {
seriesRoundOffsets.push(completedRounds);
completedRounds += s.length;
}
// Engine: every series is exactly 8 rounds → a series with 8 entries is done,
// and gets a per-series summary row after its last round.
const ROUNDS_PER_SERIES = 8;
// Bigger, more legible type on the wide desktop sidebar; compact on mobile.
const fz = {
head: desktop ? 11 : 10,
idx: desktop ? 13 : 9,
cell: desktop ? 19 : 14,
dot: desktop ? 18 : 13,
sigma: desktop ? 13 : 9,
total: desktop ? 20 : 18,
};
const table = (
<div className="flex-1 flex flex-col px-3 pt-3 pb-4">
{/* Column headers */}
<div
className="grid items-end mb-1"
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
>
<div />
{cols.map((p) => (
<div
key={p.order}
className={`text-center uppercase tracking-[.09em] truncate ${
p.order === myOrder ? 'text-gold' : 'text-green-dim'
}`}
style={{ fontSize: fz.head }}
>
{p.name}
</div>
))}
</div>
<div className="h-px bg-gold/10 mb-1" />
{/* Completed rounds, grouped by series with a per-series summary row */}
{standings.flatMap((seriesRounds, si) => {
const priorRounds = seriesRoundOffsets[si];
const elems = seriesRounds.map((scores, lri) => (
<div
key={`r-${si}-${lri}`}
className="grid items-center py-1 border-b border-gold/[.05]"
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
>
<div className="text-center text-[#7a7252]" style={{ fontSize: fz.idx }}>
{priorRounds + lri + 1}
</div>
{cols.map((p) => {
const points = scores[p.order] ?? 0;
// Failed tip (0 points) → show the struck-through tip instead of 0.
if (points === 0) {
return (
<div
key={p.order}
className="text-center font-serif leading-none line-through"
style={{ fontSize: fz.cell, color: '#7a6e4a' }}
>
{guesses[si]?.[lri]?.[p.order] ?? 0}
</div>
);
}
return (
<div
key={p.order}
className="text-center font-serif leading-none"
style={{ fontSize: fz.cell, color: p.order === myOrder ? '#f0dca8' : '#c8bb95' }}
>
{points}
</div>
);
})}
</div>
));
// After a finished series, sum its points per player.
if (seriesRounds.length === ROUNDS_PER_SERIES) {
elems.push(
<div
key={`s-${si}`}
className="grid items-center py-1 my-0.5 rounded bg-gold/[.07]"
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
>
<div className="text-center font-serif text-gold" style={{ fontSize: fz.sigma }}>
Σ{si + 1}
</div>
{cols.map((p) => {
const sum = seriesRounds.reduce((a, r) => a + (r[p.order] ?? 0), 0);
return (
<div
key={p.order}
className={`text-center font-serif leading-none ${
p.order === myOrder ? 'text-gold-dim' : 'text-green-score'
}`}
style={{ fontSize: fz.cell, fontWeight: 600 }}
>
{sum}
</div>
);
})}
</div>,
);
}
return elems;
})}
{/* Active round placeholder */}
<div
className="grid items-center py-1 rounded mt-0.5 bg-gold/[.04]"
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
>
<div className="text-center font-medium text-gold" style={{ fontSize: fz.idx }}>{completedRounds + 1}</div>
{cols.map((p) => (
<div key={p.order} className="text-center text-[#7a7252]" style={{ fontSize: fz.dot }}>·</div>
))}
</div>
<div className="flex-1 min-h-2" />
<div className="h-px bg-gold/20 mb-2" />
{/* Totals */}
<div
className="grid items-center py-0.5"
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
>
<div className="text-center uppercase tracking-[.08em] text-green-dim" style={{ fontSize: fz.sigma }}>
Σ
</div>
{cols.map((p) => (
<div
key={p.order}
className={`text-center font-serif leading-none ${
p.order === myOrder ? 'text-gold-dim' : 'text-[#c8bb95]'
}`}
style={{ fontSize: fz.total, fontWeight: p.order === myOrder ? 700 : 600 }}
>
{computeTotal(standings, p.order)}
</div>
))}
</div>
</div>
);
if (desktop) {
return (
<aside className="w-[268px] flex-shrink-0 bg-header border-l border-[#142018] flex flex-col">
<div className="h-[58px] flex items-center gap-2 px-5 border-b border-[#14221a]">
<span className="font-serif uppercase tracking-[.12em] text-[13px] text-gold">Skóre</span>
</div>
{table}
</aside>
);
}
// Mobile: collapsible panel
return (
<div className="bg-header/80 border border-[#142018] rounded-xl overflow-hidden">
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex justify-between items-center px-4 py-2 font-serif uppercase tracking-[.12em] text-[12px] text-gold"
>
<span>Skóre</span>
<span className="text-green-dim">{open ? '▲' : '▼'}</span>
</button>
{open && table}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { PlayerInfo, StashData } from '../types';
import CardView from './CardView';
interface Props {
stash: StashData | null;
players: PlayerInfo[];
myOrder: number;
}
const ROTATIONS = [-3, 2, -1, 1];
// Entry direction by seat offset from me: 0=me(bottom) 1=left 2=top 3=right.
const FLY_BY_OFFSET = ['fly-bottom', 'fly-left', 'fly-top', 'fly-right'];
export default function Trick({ stash, players, myOrder }: Props) {
// Seat order, starting from whoever led the trick.
const playOrder = stash
? [0, 1, 2, 3].map((i) => (stash.first_player + i) % 4)
: [];
const nameFor = (order: number) =>
players.find((p) => p.order === order)?.name ?? '';
const overlap = -16;
const slotH = 80;
if (!stash) {
return <div className="flex items-center justify-center" style={{ minHeight: slotH + 14 }} />;
}
return (
<div className="flex items-center justify-center">
{playOrder.map((order, i) => {
const card = stash.cards[String(order)];
// Only render cards that have actually been played — no placeholder slot
// for players still to play this trick.
if (!card) return null;
const offset = (order - myOrder + 4) % 4;
return (
<div
key={order}
className="relative flex flex-col items-center"
style={{ marginLeft: i === 0 ? 0 : overlap, zIndex: i + 1 }}
>
<span
className="uppercase text-center"
style={{
fontSize: 8,
letterSpacing: '.05em',
marginBottom: 3,
color: 'rgba(216,203,166,.72)',
}}
>
{nameFor(order)}
</span>
{/* Outer: flies in from the player's direction. Inner: static rotation. */}
<div style={{ animation: `${FLY_BY_OFFSET[offset]} .42s cubic-bezier(.2,.7,.3,1) both` }}>
<div style={{ transform: `rotate(${ROTATIONS[i]}deg)` }}>
<CardView card={card} size="md" />
</div>
</div>
</div>
);
})}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html,
body,
#root {
@apply min-h-screen;
}
/* Single global type lever: enlarges all rem-based Tailwind text (menu/list/
auth/history screens). The game board uses fixed px + transform zoom, so it
stays pixel-precise. Bump this one value to scale the menus up or down. */
html {
font-size: 18px;
}
body {
@apply bg-table text-green-score font-sans;
background:
radial-gradient(ellipse at 50% -10%, rgba(48, 104, 69, 0.16), transparent 60%),
#090e0b;
background-attachment: fixed;
}
/* Form fields keep the velvet look across the app */
input::placeholder {
@apply text-green-dim/60;
}
}
/* Velvet-table animations (design handoff). Declared as raw CSS so they work
both via Tailwind's animate-* utilities and inline `animation:` strings. */
@keyframes tp {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes ci {
from { opacity: 0; transform: translateY(-6px) scale(0.9); }
to { opacity: 1; transform: none; }
}
@keyframes ar {
0%, 100% {
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.18), 0 0 18px rgba(201, 168, 76, 0.5), 0 0 42px rgba(201, 168, 76, 0.2);
}
50% {
box-shadow: 0 0 0 5px rgba(201, 168, 76, 0.34), 0 0 32px rgba(201, 168, 76, 0.85), 0 0 56px rgba(201, 168, 76, 0.3);
}
}
@keyframes g1 {
0%, 100% { box-shadow: 0 0 18px rgba(201, 168, 76, 0.55), 0 6px 18px rgba(0, 0, 0, 0.55); }
50% { box-shadow: 0 0 34px rgba(201, 168, 76, 0.85), 0 6px 18px rgba(0, 0, 0, 0.55); }
}
/* A played card slides into the centre from the direction of its player. */
@keyframes fly-top {
from { opacity: 0; transform: translateY(-90px) scale(0.82); }
to { opacity: 1; transform: none; }
}
@keyframes fly-bottom {
from { opacity: 0; transform: translateY(90px) scale(0.82); }
to { opacity: 1; transform: none; }
}
@keyframes fly-left {
from { opacity: 0; transform: translateX(-110px) scale(0.82); }
to { opacity: 1; transform: none; }
}
@keyframes fly-right {
from { opacity: 0; transform: translateX(110px) scale(0.82); }
to { opacity: 1; transform: none; }
}
+14
View File
@@ -0,0 +1,14 @@
import type { CardColor, Hand } from '../types';
export function computePlayable(hand: Hand, ledColor: CardColor | null): Set<string> {
const keys = Object.keys(hand);
if (!ledColor) return new Set(keys);
const ledKeys = keys.filter((k) => hand[k].color === ledColor);
if (ledKeys.length > 0) return new Set(ledKeys);
const heartKeys = keys.filter((k) => hand[k].color === 'HEARTS');
if (heartKeys.length > 0) return new Set(heartKeys);
return new Set(keys);
}
+10
View File
@@ -0,0 +1,10 @@
import { NavigateFunction } from 'react-router-dom';
import { useGameStore } from '../store/gameStore';
import { emit } from './socket';
export function leaveGame(navigate: NavigateFunction) {
emit.leaveGame();
useGameStore.getState().reset();
localStorage.removeItem('bridzik_player');
navigate('/', { replace: true });
}
+137
View File
@@ -0,0 +1,137 @@
import { io } from 'socket.io-client';
import { useGameStore } from '../store/gameStore';
import type {
Account,
GameDetail,
GameInfo,
GameStatusPayload,
Hand,
HistoryGame,
MyPlayer,
Registration,
} from '../types';
export const socket = io({ autoConnect: false });
/** Attach (or clear) the session token sent in the Socket.IO `auth` handshake
* so the server can auto-login this connection (now and on every reconnect). */
export function setAuthToken(token: string | null) {
socket.auth = token ? { token } : {};
}
// Module-level state for the create→register chain and gid resolution on register_player
let _pendingGid: string | null = null;
let _createName: string | null = null;
export const emit = {
// auth
registerAccount: (username: string) => socket.emit('register_account', username),
confirmAccount: (username: string, code: string) =>
socket.emit('confirm_account', username, code),
login: (username: string, code: string) => socket.emit('login', username, code),
// history
getPlayerHistory: () => socket.emit('get_player_history'),
getGameDetail: (gid: string) => socket.emit('get_game_detail', gid),
// lobby / game
createGame: (name: string) => {
_createName = name;
socket.emit('create_game', name);
},
registerPlayer: (gid: string) => {
_pendingGid = gid;
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);
},
// Reopen a prematurely-ended game from history back into the lobby.
restoreGame: (gid: string) => socket.emit('restore_game', gid),
leaveGame: () => socket.emit('leave_game'),
endGame: (gid: string) => socket.emit('end_game', gid),
startGame: (gid: string) => socket.emit('start_game', gid),
reconnectToGame: (gid: string, token: string) => socket.emit('reconnect_to_game', gid, token),
gameStatus: () => socket.emit('game_status'),
playerCards: () => socket.emit('player_cards'),
addGuess: (guess: number) => socket.emit('add_guess', guess),
playCard: (cardKey: string) => socket.emit('play_card', cardKey),
};
export function setupSocketListeners() {
socket.on('get_games', ({ games }: { games: GameInfo[] }) => {
useGameStore.getState().setGames(games);
});
// Registration step 1: server returns the otpauth URI to render as a QR code.
socket.on('register_account', (data: Registration) => {
useGameStore.getState().setRegistration(data);
});
// Login / confirm / auto-login. A token is present on explicit login (persist it);
// auto-login on connect omits it (we keep the stored one).
socket.on('login', ({ player, token }: { player: Account; token?: string }) => {
if (token) {
localStorage.setItem('bridzik_token', token);
setAuthToken(token);
}
useGameStore.getState().setAccount(player);
useGameStore.getState().setRegistration(null);
});
socket.on('get_player_history', ({ games }: { games: HistoryGame[] }) => {
useGameStore.getState().setHistory(games);
});
socket.on('get_game_detail', (detail: GameDetail) => {
useGameStore.getState().setGameDetail(detail);
});
socket.on('create_game', ({ gid }: { gid: string }) => {
// Auto-chain: register into the just-created game (identity comes from the session).
if (_createName !== null) {
emit.registerPlayer(gid);
_createName = null;
}
});
socket.on(
'register_player',
({ player, token }: { player: { order: number; name: string }; token: string }) => {
const saved = localStorage.getItem('bridzik_player');
const gid = _pendingGid ?? (saved ? (JSON.parse(saved) as MyPlayer).gid : '');
_pendingGid = null;
const myPlayer: MyPlayer = { ...player, token, gid };
useGameStore.getState().setMyPlayer(myPlayer);
localStorage.setItem('bridzik_player', JSON.stringify(myPlayer));
}
);
socket.on('game_status', (payload: GameStatusPayload) => {
useGameStore.getState().setGameStatus(payload);
});
// Host ended the game permanently -> drop local game state, back to lobby.
socket.on('game_ended', () => {
useGameStore.getState().reset();
localStorage.removeItem('bridzik_player');
});
socket.on('player_cards', ({ cards }: { cards: Hand }) => {
useGameStore.getState().setHand(cards);
});
socket.on('player_connection', ({ order, connected }: { order: number; connected: boolean }) => {
useGameStore.getState().updatePlayerConnection(order, connected);
});
socket.on('error', ({ error }: { error: string }) => {
useGameStore.getState().setError(error);
});
// Surface connection failures instead of silently buffering emits.
socket.on('connect_error', (err: Error) => {
useGameStore.getState().setError(`Spojenie so serverom zlyhalo: ${err.message}`);
});
}
+3
View File
@@ -0,0 +1,3 @@
export function computeTotal(standings: number[][][], playerOrder: number): number {
return standings.flat().reduce((sum, round) => sum + (round[playerOrder] ?? 0), 0);
}
+45
View File
@@ -0,0 +1,45 @@
import { useLayoutEffect, useRef, useState } from 'react';
/**
* Zooms the desktop board to fill the whole window. The board is a canvas of
* fixed height (`designHeight`) whose width is computed to span the viewport,
* and the scale is driven by height — so everything (cards, circles, text,
* header) grows and shrinks together while the felt always uses the full width.
*
* Returns the container ref (the viewport), the `scale` for `transform`, and
* `contentWidth` — the pre-scale canvas width (`viewportWidth / scale`) so that
* after scaling it exactly fills the viewport width.
*
* `deps` should change when the layout swaps (mobile↔desktop) so the observer
* re-attaches to the freshly rendered element.
*/
export function useFitScale(deps: unknown[] = [], designHeight = 860, maxScale = 2.6) {
const containerRef = useRef<HTMLDivElement>(null);
const [box, setBox] = useState({ scale: 1, contentWidth: 1280 });
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const measure = () => {
const availW = el.clientWidth;
const availH = el.clientHeight;
if (!availW || !availH) return;
const scale = Math.min(maxScale, availH / designHeight);
const contentWidth = availW / scale;
setBox((prev) =>
Math.abs(prev.scale - scale) > 0.004 || Math.abs(prev.contentWidth - contentWidth) > 1
? { scale, contentWidth }
: prev,
);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { containerRef, scale: box.scale, contentWidth: box.contentWidth };
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
/** True on viewports >= 1024px — drives the desktop GameTable layout
* (score sidebar, larger oval, bigger cards). */
export function useIsDesktop(): boolean {
const query = '(min-width: 1024px)';
const [isDesktop, setIsDesktop] = useState(
() => typeof window !== 'undefined' && window.matchMedia(query).matches,
);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mql.addEventListener('change', handler);
setIsDesktop(mql.matches);
return () => mql.removeEventListener('change', handler);
}, []);
return isDesktop;
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
import { setupSocketListeners, socket, setAuthToken } from './lib/socket';
setupSocketListeners();
// Carry the stored session token into the handshake so the server auto-logs us in.
setAuthToken(localStorage.getItem('bridzik_token'));
socket.connect();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+176
View File
@@ -0,0 +1,176 @@
import { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket';
import RulesModal from '../components/RulesModal';
type Mode = 'login' | 'register';
const inputCls =
'bg-circle text-green-score rounded-lg px-4 py-2 border border-gold/20 outline-none focus:border-gold/60 focus:ring-1 focus:ring-gold/30 placeholder:text-green-dim/60';
export default function Auth() {
const [mode, setMode] = useState<Mode>('login');
const [username, setUsername] = useState(localStorage.getItem('bridzik_name') ?? '');
const [code, setCode] = useState('');
const [showRules, setShowRules] = useState(false);
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-12 min-h-screen">
<h1 className="font-serif text-4xl text-center text-gold tracking-wide mb-1">Bridžik</h1>
<p className="text-center text-green-dim text-sm mb-7">
Prihlás sa kódom z aplikácie (napr. Google Authenticator).
</p>
<div className="flex mb-6 rounded-xl overflow-hidden border border-gold/20">
<button
onClick={() => switchMode('login')}
className={`flex-1 py-2 text-sm font-serif tracking-wide ${
mode === 'login' ? 'bg-gold text-table' : 'bg-header text-green-dim'
}`}
>
Prihlásenie
</button>
<button
onClick={() => switchMode('register')}
className={`flex-1 py-2 text-sm font-serif tracking-wide ${
mode === 'register' ? 'bg-gold text-table' : 'bg-header text-green-dim'
}`}
>
Registrácia
</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="Používateľské meno"
className={inputCls}
/>
<input
type="text"
inputMode="numeric"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
maxLength={6}
placeholder="6-miestny kód"
className={`${inputCls} tracking-widest font-mono`}
/>
<button
type="submit"
disabled={!username.trim() || code.trim().length < 6}
className="py-3 rounded-xl bg-gold text-table font-serif font-semibold hover:bg-gold-bright disabled:opacity-40 transition-colors"
>
Prihlásiť
</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="Zvoľ si používateľské meno"
className={inputCls}
/>
<button
type="submit"
disabled={!username.trim()}
className="py-3 rounded-xl bg-gold text-table font-serif font-semibold hover:bg-gold-bright disabled:opacity-40 transition-colors"
>
Vytvoriť účet
</button>
</form>
)}
{mode === 'register' && registration && (
<div className="flex flex-col gap-4">
<p className="text-sm text-green-score">
Naskenuj QR kód do autentifikačnej aplikácie a opíš aktuálny kód.
</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-green-dim text-center">
Alebo zadaj ručne kľúč:
<span className="block font-mono text-green-score 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 kód"
className={`${inputCls} tracking-widest font-mono`}
/>
<button
type="submit"
disabled={code.trim().length < 6}
className="py-3 rounded-xl bg-gold text-table font-serif font-semibold hover:bg-gold-bright disabled:opacity-40 transition-colors"
>
Potvrdiť a prihlásiť
</button>
</form>
</div>
)}
{/* Rules are reachable before login — quiet link with the velvet divider motif. */}
<div className="mt-10 flex items-center gap-3">
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-gold/20" />
<button
onClick={() => setShowRules(true)}
className="uppercase tracking-[.13em] text-[10px] text-green-dim hover:text-gold transition-colors"
>
Pravidlá hry
</button>
<div className="h-px flex-1 bg-gradient-to-l from-transparent to-gold/20" />
</div>
{showRules && <RulesModal onClose={() => setShowRules(false)} />}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useGameStore } from '../store/gameStore';
import { emit, socket, setAuthToken } from '../lib/socket';
import NameModal from '../components/NameModal';
import RulesModal from '../components/RulesModal';
export default function GameList() {
const navigate = useNavigate();
const games = useGameStore((s) => s.games);
const account = useGameStore((s) => s.account);
const [showCreate, setShowCreate] = useState(false);
const [showRules, setShowRules] = useState(false);
const handleLogout = () => {
localStorage.removeItem('bridzik_token');
localStorage.removeItem('bridzik_player');
setAuthToken(null);
useGameStore.getState().logout();
// Drop the authenticated server-side session for this connection.
socket.disconnect();
socket.connect();
navigate('/auth', { replace: true });
};
return (
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
<div className="flex items-center justify-between mb-6">
<h1 className="font-serif text-2xl text-gold tracking-wide">Bridžik</h1>
<div className="flex items-center gap-3 text-sm">
<span className="text-green-dim">{account?.username}</span>
<button onClick={() => navigate('/history')} className="text-gold hover:text-gold-bright">
História
</button>
<button onClick={handleLogout} className="text-green-dim hover:text-gold">
Odhlásiť
</button>
</div>
</div>
<div className="flex flex-col gap-3 mb-6">
{games.length === 0 && (
<p className="text-center text-green-dim py-4">Žiadne hry. Vytvor prvú!</p>
)}
{games.map((g) => {
const full = g.players.length >= 4;
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 ? 'Pokračovať' : full ? 'Plná' : g.started ? 'Začatá' : 'Vstúp';
return (
<div
key={g.gid}
className="flex items-center justify-between bg-header border border-[#142018] rounded-xl px-4 py-3"
>
<div>
<p className="font-serif text-green-score">{g.name}</p>
<p className="text-xs text-green-dim">
{g.players.length}/4 hráčov
{g.started ? ' · začatá' : ''}
</p>
</div>
<button
disabled={unavailable}
onClick={() => (canResume ? emit.rejoinGame(g.gid) : emit.registerPlayer(g.gid))}
className={`px-4 py-1.5 rounded-lg text-sm font-serif font-semibold disabled:opacity-40 disabled:cursor-default transition-colors ${
canResume
? 'bg-gold text-table hover:bg-gold-bright'
: 'border border-gold/40 text-gold hover:bg-gold hover:text-table'
}`}
>
{label}
</button>
</div>
);
})}
</div>
<button
onClick={() => setShowCreate(true)}
className="w-full py-2 rounded-xl bg-gold text-table font-serif font-semibold text-base mb-3 hover:bg-gold-bright transition-colors"
>
Vytvoriť novú hru
</button>
<button
onClick={() => setShowRules(true)}
className="w-full py-2 rounded-xl border border-gold/20 text-sm text-green-score hover:border-gold/50 transition-colors"
>
Pravidlá hry
</button>
{showRules && <RulesModal onClose={() => setShowRules(false)} />}
{showCreate && <NameModal onClose={() => setShowCreate(false)} />}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useNavigate } from 'react-router-dom';
import type { PlayerInfo } from '../types';
import { computeTotal } from '../lib/standings';
import { leaveGame } from '../lib/leaveGame';
interface Props {
players: PlayerInfo[];
standings: number[][][];
}
export default function GameOver({ players, standings }: Props) {
const navigate = useNavigate();
const totals = players
.map((p) => ({ ...p, total: computeTotal(standings, p.order) }))
.sort((a, b) => b.total - a.total);
const handleLeave = () => leaveGame(navigate);
const medals = ['🥇', '🥈', '🥉', ''];
return (
<div className="max-w-md mx-auto p-4 pt-12 flex flex-col items-center gap-6 min-h-screen">
<h1 className="font-serif text-3xl text-gold tracking-wide">Koniec hry</h1>
<div className="w-full rounded-2xl overflow-hidden bg-header border border-[#142018]">
{totals.map((p, i) => (
<div
key={p.order}
className="flex items-center justify-between px-5 py-3 border-b border-gold/[.08] last:border-0"
>
<div className="flex items-center gap-3">
<span className="text-2xl w-8">{medals[i]}</span>
<span className="font-serif text-green-score">{p.name}</span>
</div>
<span className={`font-serif text-xl ${i === 0 ? 'text-gold-bright' : 'text-gold'}`}>
{p.total}
</span>
</div>
))}
</div>
<button
onClick={handleLeave}
className="w-full py-3 rounded-xl bg-gold text-table font-serif font-semibold text-lg hover:bg-gold-bright transition-colors"
>
Domov
</button>
</div>
);
}
+346
View File
@@ -0,0 +1,346 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket';
import { leaveGame } from '../lib/leaveGame';
import { computePlayable } from '../lib/gameRules';
import { computeTotal } from '../lib/standings';
import { useIsDesktop } from '../lib/useIsDesktop';
import { useFitScale } from '../lib/useFitScale';
import Hand from '../components/Hand';
import GuessControls from '../components/GuessControls';
import Trick from '../components/Trick';
import Standings from '../components/Standings';
import PlayerCircle from '../components/PlayerCircle';
import FaceDownCards from '../components/FaceDownCards';
import GameOver from './GameOver';
import type { PlayerInfo, StashData } from '../types';
const TRICK_LINGER_MS = 3000;
export default function GameTable() {
const navigate = useNavigate();
const desktop = useIsDesktop();
// Zooms the whole desktop board to fill the window (full width + height), so
// cards, circles, text and the header all scale together. Up to 2.6×.
const { containerRef, scale, contentWidth } = useFitScale([desktop], 860, 2.6);
const myPlayer = useGameStore((s) => s.myPlayer);
const gameStatus = useGameStore((s) => s.gameStatus);
const hand = useGameStore((s) => s.hand);
// Hold the last completed trick visible for TRICK_LINGER_MS after it finishes.
const [lingeredStash, setLingeredStash] = useState<StashData | null>(null);
const lingerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const previousStash = gameStatus?.status.previous_stash ?? null;
useEffect(() => {
if (!previousStash) return;
setLingeredStash(previousStash);
if (lingerTimer.current) clearTimeout(lingerTimer.current);
lingerTimer.current = setTimeout(() => setLingeredStash(null), TRICK_LINGER_MS);
return () => {
if (lingerTimer.current) clearTimeout(lingerTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameStatus?.status.previous_stash?.first_player, JSON.stringify(gameStatus?.status.previous_stash?.cards)]);
if (!gameStatus || !myPlayer) {
return <p className="text-center text-green-dim pt-20 font-serif italic">Načítava sa</p>;
}
const { completed, players, series_number, round_number, cards_in_round, status } = gameStatus;
const {
active_player,
active_round_guesses,
active_round_stashes,
active_stash,
standings = [],
standings_guesses = [],
} = status;
if (completed) {
return <GameOver players={players} standings={standings} />;
}
const myOrder = myPlayer.order;
const isPlayPhase = active_stash !== undefined;
const myTurnToPlay = isPlayPhase && active_player === myOrder;
const activeCards = active_stash ? Object.keys(active_stash.cards).length : 0;
const displayedStash: StashData | null =
activeCards > 0 && active_stash ? active_stash : lingeredStash ?? null;
const playableKeys = myTurnToPlay && active_stash
? computePlayable(hand, active_stash.cards[String(active_stash.first_player)]?.color ?? null)
: undefined;
const activePlayerName = players.find((p) => p.order === active_player)?.name ?? '';
// Seat mapping relative to "Ty": left / across / right.
const seat = (offset: number): PlayerInfo | undefined =>
players.find((p) => p.order === (myOrder + offset) % 4);
const leftP = seat(1);
const topP = seat(2);
const rightP = seat(3);
const wonOf = (o?: number) => (o === undefined ? 0 : active_round_stashes?.[o] ?? 0);
const guessOf = (o?: number): number | null => {
if (o === undefined) return null;
const g = active_round_guesses?.[String(o)];
return g === undefined ? null : g;
};
const activeOf = (o?: number) => o !== undefined && active_player === o;
// Exact cards still in a player's hand: started with cards_in_round, lost one
// per completed trick, minus one more if they've already played this trick.
const completedTricks = (active_round_stashes ?? []).reduce((a, b) => a + b, 0);
const cardsInHandOf = (o?: number) => {
if (o === undefined) return 0;
const playedCurrent = active_stash?.cards[String(o)] ? 1 : 0;
return Math.max(0, cards_in_round - completedTricks - playedCurrent);
};
const handleLeave = () => leaveGame(navigate);
const handleEnd = () => {
if (window.confirm('Naozaj ukončiť celú hru pre všetkých?')) {
emit.endGame(gameStatus.gid);
}
};
// The host can always end the game; other players only when the host is
// currently offline, so an abandoned game isn't stuck forever waiting for
// a host who won't come back, but it isn't open to casual misuse otherwise.
const hostConnected = players.find((p) => p.order === 0)?.connected ?? false;
const canEnd = myOrder === 0 || !hostConnected;
// ── shared pieces ────────────────────────────────────────────────
const bannerText = activeOf(myOrder)
? isPlayPhase
? 'Zahraj kartu'
: 'Zadaj tip'
: `${activePlayerName} ${isPlayPhase ? 'hrá' : 'tipuje'}`;
const banner = (
<div className="flex items-center justify-center gap-2">
<span className="inline-block w-[7px] h-[7px] rounded-full bg-gold animate-tp flex-shrink-0" />
<span className="font-serif italic text-[13px] text-gold-dim tracking-[.03em]">{bannerText}</span>
</div>
);
const opponents = players
.filter((p) => p.order !== myOrder)
.sort((a, b) => a.order - b.order);
const totalsRow = (compact: boolean) => (
<div className="flex items-center justify-between gap-2">
{opponents.map((p) => (
<div key={p.order} className="text-center">
<div className="uppercase tracking-[.1em] text-green-dim mb-0.5" style={{ fontSize: 11 }}>
{p.name}
</div>
<div className="font-serif text-green-score leading-none" style={{ fontSize: compact ? 16 : 20 }}>
{computeTotal(standings, p.order)}
</div>
</div>
))}
<div className="text-center rounded-lg px-3 py-1 bg-gold/[.06] border border-gold/[.15]">
<div className="uppercase tracking-[.1em] text-gold mb-0.5" style={{ fontSize: 11 }}>
{myPlayer.name}
</div>
<div className="font-serif font-semibold text-gold-dim leading-none" style={{ fontSize: compact ? 16 : 20 }}>
{computeTotal(standings, myOrder)}
</div>
</div>
</div>
);
// Center of the oval: trick during play, guess controls during bidding.
const ovalContent = isPlayPhase ? (
<Trick stash={displayedStash} players={players} myOrder={myOrder} />
) : (
active_round_guesses !== undefined && active_player !== undefined ? (
<GuessControls
cardsInRound={cards_in_round}
guesses={active_round_guesses}
myOrder={myOrder}
activePlayer={active_player}
activePlayerName={activePlayerName}
/>
) : null
);
const topSeat = (
<div className="flex flex-col items-center gap-1.5">
<PlayerCircle
name={topP?.name ?? '—'}
won={wonOf(topP?.order)}
guess={guessOf(topP?.order)}
active={activeOf(topP?.order)}
size={desktop ? 64 : 52}
/>
<FaceDownCards count={cardsInHandOf(topP?.order)} direction="row" desktop={desktop} />
</div>
);
const sideSeat = (p?: PlayerInfo) => (
<div className="flex flex-col items-center gap-1.5">
<PlayerCircle
name={p?.name ?? '—'}
won={wonOf(p?.order)}
guess={guessOf(p?.order)}
active={activeOf(p?.order)}
size={desktop ? 60 : 48}
/>
<FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} />
</div>
);
const meSeat = (
<div className="flex justify-center">
<PlayerCircle
name={myPlayer.name}
won={wonOf(myOrder)}
guess={guessOf(myOrder)}
active={activeOf(myOrder)}
size={desktop ? 70 : 58}
/>
</div>
);
const handArea = (
<Hand hand={hand} myTurn={myTurnToPlay} isPlayPhase={isPlayPhase} playableKeys={playableKeys} desktop={desktop} />
);
// ── DESKTOP LAYOUT ───────────────────────────────────────────────
if (desktop) {
return (
<div ref={containerRef} className="h-[100dvh] w-full overflow-hidden bg-table">
{/* Design canvas — fixed height, width spans the viewport; scaled as one
unit so the whole board (and header) zooms with the window. */}
<div
className="flex"
style={{ width: contentWidth, height: 860, transform: `scale(${scale})`, transformOrigin: 'top left' }}
>
{/* main */}
<div className="flex-1 min-w-0 flex flex-col">
{/* header */}
<div className="shrink-0 h-[58px] bg-header flex items-center gap-4 px-6 border-b border-[#14221a]">
<span className="font-serif uppercase tracking-[.14em] text-[15px] text-gold whitespace-nowrap">
Bridžik
</span>
<div className="w-px h-[22px] bg-[#1a3a22]" />
<span className="font-serif text-[12px] text-green-dim tracking-[.06em] whitespace-nowrap">
Séria {series_number + 1} · Kolo {round_number + 1}
</span>
<div className="flex-1 flex items-center justify-center">{banner}</div>
{totalsRow(true)}
<div className="w-px h-[22px] bg-[#1a3a22]" />
{canEnd && (
<button onClick={handleEnd} className="text-[11px] text-[#8a8064] hover:text-gold whitespace-nowrap">
Ukončiť
</button>
)}
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold whitespace-nowrap">
Odísť
</button>
</div>
{/* game content — players hug the edges so the felt uses full width */}
<div className="flex-1 min-h-0 flex flex-col justify-center gap-3 px-16 py-4">
{topSeat}
<div className="flex items-center justify-center gap-16">
{sideSeat(leftP)}
<div
className="flex items-center justify-center rounded-full"
style={{
width: 620,
height: 372,
background:
'radial-gradient(ellipse at 42% 38%,#306845 0%,#1e5030 38%,#122e1c 72%,#091e12 100%)',
boxShadow:
'inset 0 10px 48px rgba(0,0,0,.72),0 0 0 3px rgba(0,0,0,.55),0 0 0 6px rgba(201,168,76,.1)',
}}
>
{ovalContent}
</div>
{sideSeat(rightP)}
</div>
{meSeat}
</div>
{handArea}
</div>
{/* sidebar */}
<Standings standings={standings} guesses={standings_guesses} players={players} myOrder={myOrder} desktop />
</div>
</div>
);
}
// ── MOBILE LAYOUT ────────────────────────────────────────────────
return (
<div className="max-w-lg mx-auto min-h-screen flex flex-col">
{/* header */}
<div className="bg-header px-[18px] pt-[14px] pb-3 border-b border-[#14221a]">
<div className="flex items-center justify-between mb-2.5">
<span className="font-serif uppercase tracking-[.1em] text-[11px] text-gold">
Séria {series_number + 1} · Kolo {round_number + 1}
</span>
<div className="flex items-center gap-3">
{canEnd && (
<button onClick={handleEnd} className="text-[11px] text-[#6a3030] hover:text-red-400">
Ukončiť
</button>
)}
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold">
Odísť
</button>
</div>
</div>
{totalsRow(false)}
</div>
{/* turn banner */}
<div
className="py-[9px] px-4 border-b border-[#152a1a]"
style={{ background: 'linear-gradient(90deg,#09190d,#14301e,#09190d)' }}
>
{banner}
</div>
{/* game area */}
<div className="flex-1 bg-table px-2.5 pt-2.5 pb-1.5 flex flex-col">
<div className="flex flex-col items-center mb-1.5">{topSeat}</div>
<div className="flex items-center gap-1.5 mb-2">
<div className="w-[54px] flex-shrink-0 flex justify-center">{sideSeat(leftP)}</div>
<div
className="flex-1 flex items-center justify-center rounded-full"
style={{
minHeight: 192,
background:
'radial-gradient(ellipse at 42% 38%,#306845 0%,#1e5030 38%,#122e1c 72%,#091e12 100%)',
boxShadow:
'inset 0 6px 32px rgba(0,0,0,.7),0 0 0 2px rgba(0,0,0,.5),0 0 0 4px rgba(201,168,76,.1)',
}}
>
{ovalContent}
</div>
<div className="w-[54px] flex-shrink-0 flex justify-center">{sideSeat(rightP)}</div>
</div>
<div className="mb-1.5">{meSeat}</div>
</div>
{handArea}
{/* score */}
<div className="bg-table px-3 pb-4 pt-1">
<Standings standings={standings} guesses={standings_guesses} players={players} myOrder={myOrder} />
</div>
</div>
);
}
+292
View File
@@ -0,0 +1,292 @@
import { Fragment, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useGameStore } from '../store/gameStore';
import { emit, socket } from '../lib/socket';
import { useIsDesktop } from '../lib/useIsDesktop';
import type { GameDetail, GameDetailRound } from '../types';
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]);
// After a prematurely-ended game is reopened, the server confirms with
// `game_restored`; jump to the lobby where it now shows as resumable.
useEffect(() => {
const onRestored = () => navigate('/');
socket.on('game_restored', onRestored);
return () => {
socket.off('game_restored', onRestored);
};
}, [navigate]);
// --- detail view ---
if (detail) {
return <GameDetailView detail={detail} onBack={() => setGameDetail(null)} />;
}
// --- list view ---
return (
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
<div className="flex items-center justify-between mb-6">
<h1 className="font-serif text-2xl text-gold">Moja história</h1>
<button onClick={() => navigate('/')} className="text-sm text-green-dim hover:text-gold">
Späť
</button>
</div>
{history.length === 0 && (
<p className="text-center text-green-dim py-6">Zatiaľ žiadne odohrané hry.</p>
)}
<div className="flex flex-col gap-3">
{history.map((g) => (
<div
key={g.gid}
className="flex items-stretch bg-header border border-[#142018] rounded-xl overflow-hidden"
>
<button
onClick={() => emit.getGameDetail(g.gid)}
className="flex-1 min-w-0 text-left px-4 py-3 hover:bg-white/[.02] transition-colors"
>
<p className="font-serif text-green-score truncate">{g.name || 'Hra'}</p>
<p className="text-xs text-green-dim mt-1 truncate">{g.players.join(', ')}</p>
<p className="text-xs text-[#7a7058] mt-0.5">
{fmtDate(g.created_at)} · {g.completed ? 'dohraná' : 'predčasne ukončená'}
</p>
</button>
<div className="flex flex-col items-end justify-center gap-2 py-3 pl-2 pr-3">
<span className="text-base font-serif text-gold whitespace-nowrap leading-none">
{g.my_points}
<span className="text-[11px] text-green-dim ml-0.5">b.</span>
</span>
{!g.completed && (
<button
onClick={() => emit.restoreGame(g.gid)}
className="px-4 py-1.5 rounded-lg text-sm font-serif font-semibold bg-gold text-table hover:bg-gold-bright transition-colors whitespace-nowrap"
>
Obnoviť
</button>
)}
</div>
</div>
))}
</div>
</div>
);
}
const SEP = '1px solid rgba(201,168,76,.16)'; // vertical divider between players
const DESKTOP_COLS = '28px repeat(8,1fr)'; // index + 4 players × 2 series columns
const MOBILE_COLS = '28px repeat(4,1fr)'; // index + 4 players (one series stacked)
/** Scoreboard-style detail. Desktop: 4 player columns in the header (total
* beside the name), and under each a pair of series side by side — series 1 &
* 2 on top, 3 & 4 below, separated by a blank row. Mobile: the same 4 player
* columns, but the series stack one below another. Per round a cell shows the
* points (hit bid) or the struck-through bid (missed → 0); each series ends
* with a Σ total. */
function GameDetailView({ detail, onBack }: { detail: GameDetail; onBack: () => void }) {
const desktop = useIsDesktop();
const seats = detail.players; // seat order 0..3
const seatIds = seats.map((p) => p.player_id);
const totals = seatIds.map((pid) =>
detail.rounds.reduce((a, r) => (r.player_id === pid ? a + r.points : a), 0),
);
// series -> round -> playerId -> round entry
const bySeries = useMemo(() => {
const m = new Map<number, Map<number, Map<number, GameDetailRound>>>();
for (const r of detail.rounds) {
let rounds = m.get(r.series_number);
if (!rounds) m.set(r.series_number, (rounds = new Map()));
let byPlayer = rounds.get(r.round_number);
if (!byPlayer) rounds.set(r.round_number, (byPlayer = new Map()));
byPlayer.set(r.player_id, r);
}
return m;
}, [detail.rounds]);
const seriesNums = [...bySeries.keys()].sort((a, b) => a - b);
const roundsOf = (s: number | undefined) =>
s === undefined ? [] : [...(bySeries.get(s)?.keys() ?? [])];
const cellNode = (s: number | undefined, rn: number, seat: number) => {
const r = s === undefined ? undefined : bySeries.get(s)?.get(rn)?.get(seatIds[seat]);
if (!r) return null;
return r.won ? (
<span className="font-serif" style={{ fontSize: 14, color: '#c8bb95' }}>{r.points}</span>
) : (
<span className="font-serif line-through" style={{ fontSize: 14, color: '#7a6e4a' }}>{r.guess}</span>
);
};
const seriesTotal = (s: number | undefined, seat: number) =>
s === undefined
? ''
: [...(bySeries.get(s)?.values() ?? [])].reduce(
(a, byP) => a + (byP.get(seatIds[seat])?.points ?? 0),
0,
);
// Header: player names with their grand total beside the name (both layouts).
const header = (
<div
className="border-b border-gold/[.18]"
style={{ display: 'grid', gridTemplateColumns: MOBILE_COLS, padding: '9px 8px' }}
>
<div />
{seats.map((p, c) => (
<div
key={c}
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'center',
gap: 6,
borderLeft: c > 0 ? SEP : undefined,
}}
>
<span className="uppercase text-gold" style={{ letterSpacing: '.06em', fontSize: 11 }}>
{p.username}
</span>
<span className="font-serif text-gold-dim" style={{ fontWeight: 700, fontSize: 16 }}>
{totals[c]}
</span>
</div>
))}
</div>
);
// A round row: index column + one cell per (player × series) in `cols`.
const roundRow = (rn: number, cols: (number | undefined)[]) => (
<div
key={rn}
className="border-b border-gold/[.04]"
style={{
display: 'grid',
gridTemplateColumns: desktop ? DESKTOP_COLS : MOBILE_COLS,
alignItems: 'center',
padding: '3px 8px',
}}
>
<div style={{ textAlign: 'center', fontSize: 10, color: '#7a7252' }}>{rn + 1}</div>
{seats.map((_, c) =>
cols.map((s, si) => (
<div key={`${c}-${si}`} style={{ textAlign: 'center', borderLeft: c > 0 && si === 0 ? SEP : undefined }}>
{cellNode(s, rn, c)}
</div>
)),
)}
</div>
);
// Σ row: per-series totals for each player.
const sigmaRow = (cols: (number | undefined)[]) => (
<div
className="bg-gold/[.08] border-b border-gold/[.14]"
style={{
display: 'grid',
gridTemplateColumns: desktop ? DESKTOP_COLS : MOBILE_COLS,
alignItems: 'center',
padding: '5px 8px',
}}
>
<div className="text-green-dim" style={{ textAlign: 'center', fontSize: 11 }}>Σ</div>
{seats.map((_, c) =>
cols.map((s, si) => (
<div
key={`${c}-${si}`}
className="font-serif text-green-score"
style={{ textAlign: 'center', fontWeight: 600, fontSize: 14, borderLeft: c > 0 && si === 0 ? SEP : undefined }}
>
{seriesTotal(s, c)}
</div>
)),
)}
</div>
);
// Tiny row telling which series each sub-column is (desktop only — always a
// fixed pair, sB may be absent for a trailing odd series).
const seriesTags = (sA: number, sB: number | undefined) => (
<div
className="border-b border-gold/10"
style={{ display: 'grid', gridTemplateColumns: DESKTOP_COLS, padding: '5px 8px 3px' }}
>
<div />
{seats.map((_, c) => (
<Fragment key={c}>
<div style={{ textAlign: 'center', fontSize: 10, color: '#8a8064', borderLeft: c > 0 ? SEP : undefined }}>
{sA + 1}
</div>
<div style={{ textAlign: 'center', fontSize: 10, color: '#8a8064' }}>
{sB !== undefined ? sB + 1 : ''}
</div>
</Fragment>
))}
</div>
);
// Desktop: pair series side by side (S1|S2, then S3|S4) with a blank row between.
const desktopBody = (() => {
const blocks: number[][] = [];
for (let i = 0; i < seriesNums.length; i += 2) blocks.push(seriesNums.slice(i, i + 2));
return blocks.map((block, bi) => {
const [sA, sB] = [block[0], block[1]];
const roundNums = [...new Set([...roundsOf(sA), ...roundsOf(sB)])].sort((a, b) => a - b);
return (
<Fragment key={bi}>
{seriesTags(sA, sB)}
{roundNums.map((rn) => roundRow(rn, [sA, sB]))}
{sigmaRow([sA, sB])}
{bi < blocks.length - 1 && <div style={{ height: 12 }} />}
</Fragment>
);
});
})();
// Mobile: one series per block, stacked vertically, 4 player columns each.
const mobileBody = seriesNums.map((s, si) => {
const roundNums = roundsOf(s).sort((a, b) => a - b);
return (
<Fragment key={s}>
<div style={{ padding: '7px 8px 3px', fontSize: 10, textTransform: 'uppercase', letterSpacing: '.09em', color: '#8a8064' }}>
Séria {s + 1}
</div>
{roundNums.map((rn) => roundRow(rn, [s]))}
{sigmaRow([s])}
{si < seriesNums.length - 1 && <div style={{ height: 12 }} />}
</Fragment>
);
});
return (
<div className="max-w-lg mx-auto p-4 pt-8 min-h-screen">
<button onClick={onBack} className="text-sm text-green-dim hover:text-gold mb-4">
Späť na zoznam
</button>
<h1 className="font-serif text-2xl text-gold mb-1 truncate">{detail.name || 'Detail hry'}</h1>
<p className="text-xs text-green-dim mb-4">{fmtDate(detail.created_at)}</p>
{/* Desktop packs 8 columns → allow horizontal scroll on narrow widths;
mobile uses only 4 columns and fits the phone, so no scroll. */}
<div className={`bg-header border border-[#142018] rounded-xl ${desktop ? 'overflow-x-auto' : ''}`}>
<div style={desktop ? { minWidth: 440 } : undefined}>
{header}
{desktop ? desktopBody : mobileBody}
</div>
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useNavigate, useParams } from 'react-router-dom';
import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket';
export default function Lobby() {
const { gid } = useParams<{ gid: string }>();
const navigate = useNavigate();
const myPlayer = useGameStore((s) => s.myPlayer);
const games = useGameStore((s) => s.games);
const game = games.find((g) => g.gid === gid);
const players = game?.players ?? [];
const isHost = myPlayer?.order === 0;
const canStart = players.length === 4 && isHost;
const handleLeave = () => {
emit.leaveGame();
useGameStore.getState().reset();
localStorage.removeItem('bridzik_player');
navigate('/', { replace: true });
};
const handleCopyCode = () => {
if (gid) navigator.clipboard.writeText(gid);
};
return (
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
<div className="flex items-center justify-between mb-6">
<h1 className="font-serif text-2xl text-gold">{game?.name ?? 'Hra'}</h1>
<button onClick={handleLeave} className="text-sm text-green-dim hover:text-gold">
Odísť
</button>
</div>
<div className="bg-header border border-[#142018] rounded-xl p-4 mb-4 flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[.1em] text-green-dim mb-1">Kód hry</p>
<p className="font-mono text-sm text-green-score break-all">{gid}</p>
</div>
<button
onClick={handleCopyCode}
className="ml-3 px-3 py-1 rounded-lg text-sm border border-gold/30 text-gold hover:bg-gold hover:text-table transition-colors"
>
Kopírovať
</button>
</div>
<div className="bg-header border border-[#142018] rounded-xl p-4 mb-6 flex flex-col gap-3">
{[0, 1, 2, 3].map((order) => {
const p = players.find((pl) => pl.order === order);
return (
<div key={order} className="flex items-center gap-3">
<span className={`text-lg ${p ? 'text-gold' : 'text-[#7a7058]'}`}>
{p ? '✦' : '○'}
</span>
<span className={p ? 'font-serif text-green-score' : 'text-green-dim italic'}>
{p ? `${p.name}${myPlayer?.order === p.order ? ' (ty)' : ''}` : 'Čaká sa…'}
</span>
</div>
);
})}
</div>
<button
disabled={!canStart}
onClick={() => gid && emit.startGame(gid)}
className="w-full py-3 rounded-xl bg-gold text-table font-serif font-semibold text-lg disabled:opacity-40 disabled:cursor-default hover:bg-gold-bright transition-colors"
>
{isHost ? (canStart ? 'Začať hru' : `Čaká sa na hráčov (${players.length}/4)`) : 'Čaká sa na hostiteľa…'}
</button>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { create } from 'zustand';
import type {
Account,
GameDetail,
GameInfo,
GameStatusPayload,
Hand,
HistoryGame,
MyPlayer,
Registration,
} from '../types';
interface GameStore {
games: GameInfo[];
account: Account | null;
registration: Registration | null;
history: HistoryGame[];
gameDetail: GameDetail | null;
myPlayer: MyPlayer | null;
gameStatus: GameStatusPayload | null;
hand: Hand;
error: string | null;
setGames: (games: GameInfo[]) => void;
setAccount: (account: Account | null) => void;
setRegistration: (registration: Registration | null) => void;
setHistory: (history: HistoryGame[]) => void;
setGameDetail: (detail: GameDetail | null) => void;
setMyPlayer: (player: MyPlayer | null) => void;
setGameStatus: (status: GameStatusPayload) => void;
setHand: (hand: Hand) => void;
setError: (error: string | null) => void;
clearError: () => void;
updatePlayerConnection: (order: number, connected: boolean) => void;
reset: () => void;
logout: () => void;
}
export const useGameStore = create<GameStore>((set) => ({
games: [],
account: null,
registration: null,
history: [],
gameDetail: null,
myPlayer: null,
gameStatus: null,
hand: {},
error: null,
setGames: (games) => set({ games }),
setAccount: (account) => set({ account }),
setRegistration: (registration) => set({ registration }),
setHistory: (history) => set({ history }),
setGameDetail: (gameDetail) => set({ gameDetail }),
setMyPlayer: (myPlayer) => set({ myPlayer }),
setGameStatus: (gameStatus) => set({ gameStatus }),
setHand: (hand) => set({ hand }),
setError: (error) => set({ error }),
clearError: () => set({ error: null }),
updatePlayerConnection: (order, connected) =>
set((state) => ({
gameStatus: state.gameStatus
? {
...state.gameStatus,
players: state.gameStatus.players.map((p) =>
p.order === order ? { ...p, connected } : p
),
}
: 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,
}),
}));
+102
View File
@@ -0,0 +1,102 @@
export type CardColor = 'HEARTS' | 'LEAVES' | 'ACORNS' | 'BELLS';
export type CardValue = 'C7' | 'C8' | 'C9' | 'C10' | 'LOWER' | 'UPPER' | 'KING' | 'ACE';
export interface Card {
color: CardColor;
value: CardValue;
}
export interface PlayerInfo {
order: number;
name: string;
connected: boolean;
player_id?: number;
}
export interface MyPlayer {
order: number;
name: string;
token: string;
gid: string;
}
export interface StashData {
first_player: number;
cards: Record<string, Card>;
}
export interface GameStatusDetail {
active_player?: number;
active_round_guesses?: Record<string, number>;
active_round_stashes?: number[];
active_stash?: StashData;
previous_stash?: StashData;
standings: number[][][];
/** Tips per series/round/seat, same shape as standings. Used to show the
* struck-through tip in place of 0 when a tip failed. */
standings_guesses?: number[][][];
}
export interface GameStatusPayload {
gid: string;
completed: boolean;
players: PlayerInfo[];
series_number: number;
round_number: number;
cards_in_round: number;
status: GameStatusDetail;
}
export interface GameInfo {
gid: string;
name: string;
started: boolean;
players: PlayerInfo[];
}
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;
name: string;
created_at: string | null;
ended_at: string | null;
players: string[];
my_points: number;
/** True = dohraná naplno; false = predčasne ukončená (dá sa obnoviť do lobby). */
completed: boolean;
}
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;
name: string;
created_at: string | null;
ended_at: string | null;
players: { player_id: number; username: string }[];
rounds: GameDetailRound[];
}
+63
View File
@@ -0,0 +1,63 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// Velvet table palette (design handoff: "01 — Sametový stôl")
table: '#090e0b',
header: '#070c09',
circle: '#0c1a0f',
'circle-active': '#0e2015',
gold: '#c9a84c',
'gold-bright': '#f0d060',
'gold-dim': '#e8c14a',
// Secondary text is warm cream (not green) for legibility on the dark
// table — the green is reserved for structure (felt, opponent cards).
'green-dim': '#9c906c',
'green-score': '#d8cba6',
'green-circle': '#c2b58c',
},
fontFamily: {
serif: ['"Playfair Display"', 'Georgia', 'serif'],
sans: ['"DM Sans"', 'system-ui', 'sans-serif'],
},
keyframes: {
// turn-pulse — blinking dot / placeholder slot
tp: { '0%,100%': { opacity: '1' }, '50%': { opacity: '.5' } },
// card-in — card lands on the table
ci: {
from: { opacity: '0', transform: 'translateY(-6px) scale(.9)' },
to: { opacity: '1', transform: 'none' },
},
// active-ring — glowing gold ring around the active player circle
ar: {
'0%,100%': {
boxShadow:
'0 0 0 3px rgba(201,168,76,.18),0 0 18px rgba(201,168,76,.5),0 0 42px rgba(201,168,76,.2)',
},
'50%': {
boxShadow:
'0 0 0 5px rgba(201,168,76,.34),0 0 32px rgba(201,168,76,.85),0 0 56px rgba(201,168,76,.3)',
},
},
// glow-1 — gold glow border on a playable card
g1: {
'0%,100%': {
boxShadow: '0 0 18px rgba(201,168,76,.55),0 6px 18px rgba(0,0,0,.55)',
},
'50%': {
boxShadow: '0 0 34px rgba(201,168,76,.85),0 6px 18px rgba(0,0,0,.55)',
},
},
},
animation: {
tp: 'tp 1.8s ease-in-out infinite',
ci: 'ci .3s ease both',
ar: 'ar 2.2s ease-in-out infinite',
g1: 'g1 2.2s ease-in-out infinite',
},
},
},
plugins: [],
};
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+45
View File
@@ -0,0 +1,45 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Bridzik',
short_name: 'Bridzik',
theme_color: '#1e3a5f',
background_color: '#0f172a',
display: 'standalone',
icons: [
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
},
}),
],
server: {
host: true,
// Docker bind mounts on Windows/macOS don't forward native FS events into the
// container, so Vite's watcher never fires and HMR appears "stuck". Polling the
// mounted files makes hot reload work without restarting the container.
watch: {
usePolling: true,
interval: 200,
},
proxy: {
'/socket.io': {
// Local dev defaults to localhost; docker-compose sets VITE_BACKEND_URL
// to the backend service (http://backend:5000).
target: process.env.VITE_BACKEND_URL ?? 'http://localhost:5000',
ws: true,
changeOrigin: true,
},
},
},
});
+14 -18
View File
@@ -1,18 +1,14 @@
astroid==2.3.3 # Realtime Socket.IO server running on ASGI (uvicorn).
click==7.1.1 python-socketio>=5.11
colorama==0.4.3 python-engineio>=4.9
Flask==1.1.1 uvicorn>=0.30
Flask-WTF==0.14.3
isort==4.3.21 # Persistence layer (history + TOTP auth).
itsdangerous==1.1.0 SQLAlchemy[asyncio]>=2.0
Jinja2==2.11.1 aiosqlite>=0.20 # dev / default DATABASE_URL
lazy-object-proxy==1.4.3 asyncpg>=0.29 # production (PostgreSQL)
MarkupSafe==1.1.1 pyotp>=2.9 # TOTP login
mccabe==0.6.1
pylint==2.4.4 # NOTE: the legacy Flask/Jinja HTTP UI (api/routes.py, api/forms.py,
python-dotenv==0.12.0 # api/templates/) is dormant and its dependencies (Flask, Flask-WTF, etc.)
six==1.14.0 # were removed during the ASGI migration. Re-add them only if that UI is revived.
typed-ast==1.4.1
Werkzeug==1.0.0
wrapt==1.11.2
WTForms==2.2.1
-1
View File
@@ -1 +0,0 @@
from api import app
+234
View File
@@ -0,0 +1,234 @@
import unittest
import api
class SocketLayerCase(unittest.IsolatedAsyncioTestCase):
"""Drives the socket handlers directly with a fake emit, asserting the
identity-binding, privacy, lifecycle and error-handling guarantees."""
def setUp(self):
api.games.clear()
api.sessions.clear()
self.emits = []
async def fake_emit(event, data=None, room=None, to=None, **kw):
self.emits.append({"event": event, "data": data, "room": room, "to": to})
async def noop(*a, **kw):
pass
# Patch the network-facing primitives; logic under test is untouched.
api.sio.emit = fake_emit
api.sio.enter_room = noop
api.sio.leave_room = noop
# --- helpers ----------------------------------------------------------
def last(self, event):
return next(e for e in reversed(self.emits) if e["event"] == event)
def events(self, event):
return [e for e in self.emits if e["event"] == event]
async def make_game(self):
await api.create_game("host", "Game")
return list(api.games.keys())[0]
async def make_started_game(self):
gid = await self.make_game()
for i in range(4):
await api.register_player(f"s{i}", gid, f"P{i}")
await api.start_game("s0", gid)
return gid
# --- creation & lobby -------------------------------------------------
async def test_create_game_mints_uuid(self):
gid = await self.make_game()
self.assertNotEqual(gid, "a")
self.assertEqual(len(gid), 36)
self.assertEqual(self.last("create_game")["data"]["gid"], gid)
async def test_lobby_payload_hides_sids_and_tokens(self):
gid = await self.make_game()
await api.register_player("s0", gid, "P0")
lobby = self.last("get_games")["data"]["games"]
blob = repr(lobby)
self.assertNotIn("sid", blob)
self.assertNotIn("token", blob)
self.assertEqual(lobby[0]["players"][0], {"order": 0, "name": "P0", "connected": True})
async def test_register_returns_private_token_to_caller_only(self):
gid = await self.make_game()
await api.register_player("s0", gid, "P0")
reg = self.last("register_player")
self.assertEqual(reg["to"], "s0")
self.assertIn("token", reg["data"])
self.assertEqual(reg["data"]["player"], {"order": 0, "name": "P0"})
async def test_register_rejects_unknown_game_fifth_and_started(self):
await api.register_player("x", "nope", "P") # unknown gid
self.assertEqual(self.last("error")["data"]["error"], "Hra neexistuje.")
gid = await self.make_game()
for i in range(4):
await api.register_player(f"s{i}", gid, f"P{i}")
await api.register_player("s4", gid, "P4") # fifth
self.assertEqual(self.last("error")["data"]["error"], "Prekroceny pocet hracov.")
await api.start_game("s0", gid)
await api.register_player("s5", gid, "late") # already started
self.assertEqual(self.last("error")["data"]["error"], "Hra uz zacala.")
# --- start lifecycle --------------------------------------------------
async def test_start_deals_and_emits_private_hands(self):
gid = await self.make_started_game()
self.assertTrue(api.games[gid].started)
self.assertEqual(self.last("game_status")["room"], gid)
hands = self.events("player_cards")
self.assertEqual(sorted(h["to"] for h in hands), ["s0", "s1", "s2", "s3"])
async def test_start_game_twice_is_rejected_and_does_not_reset(self):
gid = await self.make_started_game()
core_before = api.games[gid].bridzik_core
await api.start_game("s0", gid)
self.assertEqual(self.last("error")["data"]["error"], "Hra uz zacala.")
self.assertIs(api.games[gid].bridzik_core, core_before)
# --- identity binding (the anti-cheat guarantee) ----------------------
async def test_player_cards_serves_only_your_own_seat(self):
gid = await self.make_started_game()
# The handler takes no player argument: a connection can only ever
# request the hand bound to its own session.
self.emits.clear()
await api.player_cards("s2")
sent = self.last("player_cards")
self.assertEqual(sent["to"], "s2")
expected = api.games[gid].bridzik_core.get_player_cards(2)
self.assertEqual(len(sent["data"]["cards"]), len(expected))
async def test_action_without_session_is_rejected(self):
await self.make_started_game()
await api.add_guess("stranger", 1)
self.assertEqual(self.last("error")["data"]["error"], "Nie ste v rozohratej hre.")
# --- full play flow ---------------------------------------------------
async def test_guess_and_play_flow(self):
gid = await self.make_started_game()
core = api.games[gid].bridzik_core
# Round 0 leads with seat 0; guess 0 all round (sum 0 != 8 tricks).
for i in range(4):
await api.add_guess(f"s{i}", 0)
rnd = core.series[-1].get_last_round()
self.assertTrue(rnd.is_guessing_completed())
leader = rnd.get_active_player()
before = len(core.get_player_cards(leader))
self.emits.clear()
await api.play_card(f"s{leader}", 0)
after = len(core.get_player_cards(leader))
self.assertEqual(after, before - 1)
status = self.last("game_status")["data"]
self.assertIn("completed", status)
self.assertIn("active_stash", status["status"])
async def test_bad_input_does_not_crash(self):
await self.make_started_game()
await api.add_guess("s0", "not-a-number")
self.assertEqual(self.last("error")["data"]["error"], "Neplatny tip.")
await api.play_card("s0", "xx")
self.assertEqual(self.last("error")["data"]["error"], "Neplatna karta.")
async def test_engine_error_message_is_forwarded(self):
gid = await self.make_started_game()
# Seat 0 leads guessing; seat 1 acting out of turn -> engine rejects,
# and the real (Slovak) reason is forwarded, not a generic message.
await api.add_guess("s1", 1)
self.assertEqual(self.last("error")["data"]["error"], "Hrac nie je na tahu")
# --- reconnect --------------------------------------------------------
async def test_secure_reconnect_with_token_rebinds_seat(self):
gid = await self.make_game()
await api.register_player("s0", gid, "P0")
token = self.last("register_player")["data"]["token"]
for i in range(1, 4):
await api.register_player(f"s{i}", gid, f"P{i}")
await api.start_game("s0", gid)
await api.reconnect_to_game("s0-new", gid, token)
self.assertEqual(api.games[gid].player_by_token(token).sid, "s0-new")
self.assertEqual(api.sessions["s0-new"], {"gid": gid, "order": 0})
self.emits.clear()
await api.reconnect_to_game("zzz", gid, "wrong-token")
self.assertEqual(self.last("error")["data"]["error"], "Neplatny token pre pripojenie.")
# --- self-contained game_status --------------------------------------
async def test_game_status_carries_roster_and_round_meta(self):
gid = await self.make_started_game()
self.emits.clear()
await api.game_status("s0")
data = self.last("game_status")["data"]
self.assertEqual(data["round_number"], 0)
self.assertEqual(data["cards_in_round"], 8)
self.assertEqual(data["series_number"], 0)
roster = data["players"]
self.assertEqual([p["order"] for p in roster], [0, 1, 2, 3])
self.assertEqual([p["name"] for p in roster], ["P0", "P1", "P2", "P3"])
self.assertTrue(all(p["connected"] for p in roster))
# --- leave_game -------------------------------------------------------
async def test_leave_before_start_frees_the_seat(self):
gid = await self.make_game()
await api.register_player("s0", gid, "P0")
await api.register_player("s1", gid, "P1")
await api.leave_game("s0") # order 0 leaves
self.assertNotIn("s0", api.sessions)
self.assertEqual([p.order for p in api.games[gid].players], [1])
# the freed seat 0 is reused by the next joiner
await api.register_player("s2", gid, "P2")
self.assertEqual(api.sessions["s2"]["order"], 0)
async def test_leave_last_lobby_player_drops_game(self):
gid = await self.make_game()
await api.register_player("s0", gid, "P0")
await api.leave_game("s0")
self.assertNotIn(gid, api.games)
async def test_leave_started_game_keeps_seat_marks_offline(self):
gid = await self.make_started_game()
await api.leave_game("s1")
self.assertNotIn("s1", api.sessions)
player = api.games[gid].player_by_order(1)
self.assertIsNotNone(player) # seat kept for reconnect
self.assertFalse(player.connected)
self.assertIn(gid, api.games) # others still in
async def test_leave_without_session_is_a_noop(self):
await api.leave_game("ghost")
# no exception, and no error emitted to a non-participant
self.assertEqual(self.events("error"), [])
# --- disconnect & cleanup --------------------------------------------
async def test_disconnect_marks_player_then_drops_empty_game(self):
gid = await self.make_started_game()
await api.disconnect("s0")
self.assertFalse(api.games[gid].player_by_sid("s0").connected)
self.assertNotIn("s0", api.sessions)
self.assertIn(gid, api.games) # others still connected
for i in range(1, 4):
await api.disconnect(f"s{i}")
self.assertNotIn(gid, api.games) # everyone gone -> game removed
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -185,9 +185,9 @@ class RoundCase(unittest.TestCase):
self.assertEqual(r.get_active_player(), 2) self.assertEqual(r.get_active_player(), 2)
r.add_player_guess(2, 0) r.add_player_guess(2, 0)
self.assertEqual(r.get_active_player(), 3) self.assertEqual(r.get_active_player(), 3)
r.add_player_guess(3, 2) r.add_player_guess(3, 1)
self.assertEqual(r.get_active_player(), 0) self.assertEqual(r.get_active_player(), 0)
r.add_player_guess(0, 4) r.add_player_guess(0, 2)
self.assertEqual(r.get_active_player(), 0) self.assertEqual(r.get_active_player(), 0)
r.play_card(0, r.player_cards[0][0]) r.play_card(0, r.player_cards[0][0])
@@ -256,7 +256,7 @@ class RoundCase(unittest.TestCase):
r.add_player_guess(1, 1) r.add_player_guess(1, 1)
r.add_player_guess(2, 0) r.add_player_guess(2, 0)
r.add_player_guess(3, 2) r.add_player_guess(3, 2)
r.add_player_guess(0, 4) r.add_player_guess(0, 1)
self.assertTrue(r.is_guessing_completed()) self.assertTrue(r.is_guessing_completed())
def test_get_last_stash(self): def test_get_last_stash(self):
+206
View File
@@ -0,0 +1,206 @@
"""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_reopen_prematurely_ended_game(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)))
run(history.mark_game_ended(gid))
# V historii sa ukazuje ako predcasne ukoncena (nie naplno dohrana).
rows = run(history.get_player_history(ids[0]))
mine = next(g for g in rows if g["gid"] == gid)
self.assertFalse(mine["completed"])
# Cudzi hrac ju obnovit nemoze.
outsider = self._make_players(1)[0]
self.assertIsNone(run(history.reopen_game(gid, outsider)))
# Clen ju obnovi -> ended_at sa zmaze a hra je zas medzi nedohratymi.
info = run(history.reopen_game(gid, ids[0]))
self.assertIsNotNone(info)
self.assertEqual(len(info["seats"]), 4)
self.assertTrue(any(g["gid"] == gid for g in run(history.get_unfinished_games())))
# A teda uz nie je v historii (zobrazuju sa iba ukoncene hry).
self.assertFalse(any(g["gid"] == gid for g in run(history.get_player_history(ids[0]))))
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, guesses = run(history.get_standings(gid))
# 1 seria, 1 kolo. Body podla sedadiel zo stubu [12, 0, 10, 11],
# tipy zo stubu {0: 2, 1: 1, 2: 0, 3: 1}.
self.assertEqual(standings, [[[12, 0, 10, 11]]])
self.assertEqual(guesses, [[[2, 1, 0, 1]]])
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()