Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04185a0d97 | ||
|
|
97c71cf6e1 | ||
|
|
1636757217 | ||
|
|
8e18325660 | ||
|
|
973c279cbd | ||
|
|
b1010ae008 | ||
|
|
23de3ae3ac | ||
|
|
9a750756c5 | ||
|
|
8f2449a408 | ||
|
|
e1733f4943 | ||
|
|
3710a68e37 | ||
|
|
1b14ace2cd | ||
|
|
f17f85ebd9 | ||
|
|
558c8ec2d9 | ||
|
|
b1ca4bc78d | ||
|
|
6c95c64398 | ||
|
|
ca0cf413ee | ||
|
|
fb90737944 | ||
|
|
1fbba5a7e1 | ||
|
|
80929e7fed | ||
|
|
9ade92d7fe | ||
|
|
5b9d6342ad | ||
|
|
53274607b1 | ||
|
|
aed0cd5d7f | ||
|
|
42f8dfdcf0 | ||
|
|
652a5aa66e | ||
|
|
7be89e70c8 | ||
|
|
28bb045274 | ||
|
|
7886b3a6b8 | ||
|
|
c4800fca0e | ||
|
|
49ac0d3e78 | ||
|
|
3615fa2bb1 | ||
|
|
7a5202195b | ||
|
|
4f0b706c28 | ||
|
|
fbe0c3aa18 | ||
|
|
0845562a21 | ||
|
|
c59dca754f | ||
|
|
2c2f07c2ec | ||
|
|
30c32b7714 | ||
|
|
beaf142ee4 | ||
|
|
b8e2d15e27 | ||
|
|
821c7e81ce | ||
|
|
d47eb03bce | ||
|
|
aa1b037c1a | ||
|
|
00937aa89f | ||
|
|
cd3d84319e | ||
|
|
3053040da9 |
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
.idea
|
||||
.devcontainer
|
||||
frontend
|
||||
*.db
|
||||
.env
|
||||
.env.*
|
||||
docker-compose*.yaml
|
||||
# trenovacie artefakty do image nepatria (produkcia cita len rl/weights/)
|
||||
rl/checkpoints
|
||||
rl/runs
|
||||
@@ -0,0 +1,30 @@
|
||||
# Copy to .env and fill in real values before `docker compose -f docker-compose.prod.yaml up`.
|
||||
# .env is gitignored -- never commit real secrets.
|
||||
|
||||
POSTGRES_USER=bridzik
|
||||
POSTGRES_PASSWORD=bridzik
|
||||
POSTGRES_DB=bridzik
|
||||
|
||||
# Comma-separated list of origins allowed to open a Socket.IO connection.
|
||||
# Must be the public URL(s) the frontend is served from -- never "*" in prod.
|
||||
CORS_ALLOWED_ORIGINS=https://bridzik.liptim.eu
|
||||
|
||||
# Shared secret for the self-hosted /api/admin/* stats endpoints.
|
||||
ADMIN_TOKEN=tajneheslo
|
||||
|
||||
# pgAdmin login (UI bound to 127.0.0.1:8081 on the host -- access via SSH tunnel,
|
||||
# never expose publicly since it has full access to the Postgres data).
|
||||
PGADMIN_DEFAULT_EMAIL=admin@example.com
|
||||
PGADMIN_DEFAULT_PASSWORD=zmen_ma
|
||||
|
||||
# Fernet key encrypting Player.totp_secret at rest. Generate with:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
# Keep it stable across restarts/redeploys -- losing it locks every account
|
||||
# out (their TOTP secret can no longer be decrypted).
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# Optional: IP -> country for /api/track pageviews (see GEOIP_DB_PATH in
|
||||
# docker-compose.prod.yaml). Place a .mmdb file (GeoLite2-City/Country from a
|
||||
# MaxMind account, or a DB-IP/IP2Location Lite equivalent) at
|
||||
# ./geoip/GeoLite2-City.mmdb next to docker-compose.prod.yaml. Left missing ->
|
||||
# country is just recorded as "".
|
||||
@@ -1,6 +1,14 @@
|
||||
__pycache__/
|
||||
.flaskenv
|
||||
/.vscode
|
||||
/logs
|
||||
/env-bridzik-dev
|
||||
*.png
|
||||
*.pyc
|
||||
.venv/
|
||||
*.db
|
||||
.idea/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
geoip/*.mmdb
|
||||
rl/runs/
|
||||
rl/checkpoints/
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# 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.test_engine -v # pure-engine unittest (no deps)
|
||||
python -m unittest tests.test_history -v # persistence + auth (needs sqlalchemy/aiosqlite/pyotp)
|
||||
python -m unittest tests.test_engine.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/test_engine.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/crypto.py`** — encryption/hashing for sensitive `Player` columns. `encrypt`/`decrypt` (Fernet, key from env **`ENCRYPTION_KEY`** — must stay stable, losing it locks out every account) for `totp_secret`; `hash_token` (SHA-256, one-way) for `auth_token`.
|
||||
- **`db/models.py`** — 3 tables:
|
||||
- **`Player`** — account + auth: `username` (unique login), `totp_secret` (Fernet-encrypted at rest via `db/crypto.py`), `totp_last_step` (TOTP replay guard), `auth_token` (session token for reconnect, stored as a SHA-256 hash, not plaintext).
|
||||
- **`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/test_engine.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/test_history.py`, not `tests/test_engine.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.
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM python:3.14-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Backend code only -- the frontend has its own image (frontend/Dockerfile).
|
||||
COPY bridzik.py app.py ./
|
||||
COPY api ./api
|
||||
COPY db ./db
|
||||
COPY rl ./rl
|
||||
COPY tests ./tests
|
||||
|
||||
RUN useradd --create-home --uid 1000 appuser \
|
||||
&& chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
# Serve the ASGI Socket.IO app with uvicorn.
|
||||
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "5000"]
|
||||
@@ -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` až `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.
|
||||
@@ -0,0 +1,20 @@
|
||||
# TASKS.md
|
||||
|
||||
Nápady a úlohy pre bridge hru. Nový nápad pridaj ako riadok do "Nedokončené" (cez mobil/Dispatch alebo priamo).
|
||||
Hodinová routine v Claude Code číta túto sekciu, implementuje a presúva hotové úlohy dole.
|
||||
|
||||
## Nedokončené
|
||||
|
||||
## Rozpracované
|
||||
|
||||
<!-- Sem si routine presunie úlohu, na ktorej práve pracuje, aby sa pri ďalšom behu neduplikovala. -->
|
||||
|
||||
## Hotovo
|
||||
|
||||
- [x] Skús upraviť dizajn scrollbaru v zozname bodov v hre tak, aby zodpovedal celej hre. — 2026-07-07: pridaný `.velvet-scroll` v `index.css` (tenký priehľadný track + zlatý polopriehľadný thumb, webkit aj Firefox `scrollbar-color`) a aplikovaný na scrollovateľný zoznam kôl v `Standings.tsx` (herný sidebar aj mobilný panel). Commit 6c95c64.
|
||||
- [x] V hre keď sa hádžu karty, pri hodení poslednej karty všetky karty na chvíľu zmiznú a potom sa znovu objavia cez animáciu. Uprav to tak, aby sa posledná karta pridala k predchádzajúcim bez zmiznutia kôpky, a až následne kôpka zmizla smerom k hráčovi, ktorý ju zobral. — 2026-07-07: `GameTable.tsx` číta `previous_stash` synchrónne (namiesto `lingeredStash` nastavovaného v `useEffect`), takže kôpka pri 4. karte nezmizne — posledná karta len pribudne k trom. Po krátkej pauze (`SETTLE_MS`) sa celá kôpka animáciou (`collect-*` keyframes v `index.css`) odsunie k sedadlu víťaza; víťaz sa počíta cez nový `stashWinner` v `gameRules.ts` podľa pravidiel enginu. Commit fb90737.
|
||||
- [x] Urob samostatné scrollovanie v zozname bodov v hlavnej hre, lebo teraz tam nevidno všetky hry. Zároveň dorob, aby sa po jednotlivých sériách dali body collapsnúť/expandnúť a ostali by len celkové body za sériu. — 2026-07-07: `Standings.tsx` rozdelený na fixnú hlavičku, scrollovateľný zoznam kôl (`overflow-y-auto`, na mobile `max-h-[45vh]`) a fixné súčty; dokončená séria (Σ riadok) je teraz klikateľná a zbaľuje/rozbaľuje svoje kolá. Commit 80929e7.
|
||||
- [x] V hre aj v histórii zruš preciarkavanie pri nesprávnych tipoch. — 2026-07-07: odstránené `line-through` z `Standings.tsx` (herný pohľad) aj `History.tsx` (detail hry) pri neúspešnom tipe. Commit 80929e7.
|
||||
- [x] V lobby hre sa dá skopírovať ID hry. Zmeň to tak, aby sa namiesto ID kopírovala celá URL linka na hru. — 2026-07-03: tlačidlo "Kopírovať" v Lobby.tsx teraz kopíruje `${window.location.origin}/lobby/${gid}` namiesto holého gid. Commit 49ac0d3.
|
||||
|
||||
<!-- Sem routine presunie dokončené úlohy s dátumom a krátkym popisom + commit hashom. -->
|
||||
@@ -1,23 +1,903 @@
|
||||
from flask import Flask
|
||||
import asyncio
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from config import Config
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from json import JSONEncoder
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
import socketio
|
||||
|
||||
if not app.debug:
|
||||
if not os.path.exists('logs'):
|
||||
os.mkdir('logs')
|
||||
file_handler = RotatingFileHandler('logs/bridzik_api.log', maxBytes=10240, backupCount=10)
|
||||
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)
|
||||
from bridzik import Bridzik, BridzikException, Card
|
||||
from db.db import init_db
|
||||
from api import auth as auth_module, history
|
||||
from api import bots as bots_module
|
||||
from api import stats as stats_module
|
||||
from api.auth import AuthError, RegistrationIncomplete
|
||||
from rl.encoding import index_card
|
||||
|
||||
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 _read_body(receive) -> bytes:
|
||||
body = b""
|
||||
while True:
|
||||
message = await receive()
|
||||
body += message.get("body", b"")
|
||||
if not message.get("more_body"):
|
||||
break
|
||||
return body
|
||||
|
||||
|
||||
async def _send_text(send, status: int, body: bytes, content_type: bytes = b"text/plain"):
|
||||
await send({"type": "http.response.start", "status": status,
|
||||
"headers": [(b"content-type", content_type)]})
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
|
||||
|
||||
def _admin_authorized(scope) -> bool:
|
||||
token = os.environ.get("ADMIN_TOKEN", "")
|
||||
if not token:
|
||||
return False
|
||||
headers = dict(scope.get("headers") or [])
|
||||
auth_header = headers.get(b"authorization", b"").decode("utf-8", "ignore")
|
||||
return hmac.compare_digest(auth_header, f"Bearer {token}")
|
||||
|
||||
|
||||
def _client_ip(scope) -> str:
|
||||
# Behind nginx (docker-compose.prod.yaml) this is the real client IP --
|
||||
# the backend port is never published, so X-Forwarded-For can't be spoofed
|
||||
# by an external caller going around the proxy.
|
||||
headers = dict(scope.get("headers") or [])
|
||||
xff = headers.get(b"x-forwarded-for", b"").decode("utf-8", "ignore")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
client = scope.get("client")
|
||||
return client[0] if client else "unknown"
|
||||
|
||||
|
||||
# Lockout na neuspesne pokusy o /api/admin/stats, per client IP -- rovnaky
|
||||
# in-memory pattern ako login lockout v api/auth.py (jeden proces, ziadny Redis).
|
||||
_ADMIN_ATTEMPT_LIMIT = 5
|
||||
_ADMIN_ATTEMPT_WINDOW = 300 # sekund
|
||||
_admin_failed_attempts: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
|
||||
def _admin_locked_out(ip: str) -> bool:
|
||||
cutoff = time.monotonic() - _ADMIN_ATTEMPT_WINDOW
|
||||
attempts = [t for t in _admin_failed_attempts.get(ip, []) if t > cutoff]
|
||||
if attempts:
|
||||
_admin_failed_attempts[ip] = attempts
|
||||
else:
|
||||
_admin_failed_attempts.pop(ip, None)
|
||||
return len(attempts) >= _ADMIN_ATTEMPT_LIMIT
|
||||
|
||||
|
||||
def _register_admin_failure(ip: str) -> None:
|
||||
_admin_failed_attempts[ip].append(time.monotonic())
|
||||
|
||||
|
||||
async def _handle_track(scope, receive, send):
|
||||
try:
|
||||
data = json.loads((await _read_body(receive)) or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
headers = dict(scope.get("headers") or [])
|
||||
user_agent = headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300]
|
||||
# player_id sa sem nikdy nedava z klienta (nedovereny/spoofovatelny vstup na
|
||||
# neautentifikovanom endpointe) -- pripaja sa len server-side na evente
|
||||
# "login" (pozri handler @sio.on("login")).
|
||||
await stats_module.record_pageview(
|
||||
path=str(data.get("path", ""))[:200],
|
||||
referrer=str(data.get("referrer", ""))[:300],
|
||||
user_agent=user_agent,
|
||||
ip=_client_ip(scope),
|
||||
)
|
||||
await send({"type": "http.response.start", "status": 204, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
|
||||
|
||||
async def _handle_admin_stats(scope, send):
|
||||
ip = _client_ip(scope)
|
||||
if _admin_locked_out(ip):
|
||||
return await _send_text(send, 429, b"too many attempts")
|
||||
if not _admin_authorized(scope):
|
||||
_register_admin_failure(ip)
|
||||
return await _send_text(send, 403, b"forbidden")
|
||||
query = parse_qs((scope.get("query_string") or b"").decode("utf-8", "ignore"))
|
||||
logged_in_only = query.get("logged_in", ["0"])[0] == "1"
|
||||
data = await stats_module.get_daily_stats(logged_in_only=logged_in_only)
|
||||
await _send_text(send, 200, json.dumps(data).encode(), b"application/json")
|
||||
|
||||
|
||||
async def _health_app(scope, receive, send):
|
||||
"""Minimal ASGI handler for non-socket.io HTTP routes (liveness checks,
|
||||
pageview tracking beacon, admin stats)."""
|
||||
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":
|
||||
path = scope.get("path", "")
|
||||
method = scope.get("method", "GET")
|
||||
if path in ("/health", "/healthz"):
|
||||
return await _send_text(send, 200, b"ok")
|
||||
if path == "/api/track" and method == "POST":
|
||||
return await _handle_track(scope, receive, send)
|
||||
if path == "/api/admin/stats" and method == "GET":
|
||||
return await _handle_admin_stats(scope, send)
|
||||
await _send_text(send, 404, b"not found")
|
||||
|
||||
|
||||
# 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
|
||||
# Serializuje tahovu slucku botov -- dva sucasne _run_bot_turns tasky
|
||||
# by inak mohli tahat za to iste sedadlo.
|
||||
self.bot_lock = asyncio.Lock()
|
||||
|
||||
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
|
||||
# Bot = sedadlo bez socketu; `brain` je rozhodovaci objekt s rozhranim
|
||||
# guess(rnd, seat) / play(rnd, seat) z rl/players.py.
|
||||
self.is_bot = False
|
||||
self.brain = None
|
||||
|
||||
|
||||
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.
|
||||
|
||||
A game that finished naturally (all 4 series played out) stays in the
|
||||
`games` dict for reconnect purposes (e.g. a reload while still on the
|
||||
GameOver screen), but it has nothing left to offer the lobby — drop it
|
||||
here rather than have it linger forever as a "started"/resumable entry.
|
||||
"""
|
||||
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,
|
||||
"is_bot": p.is_bot,
|
||||
}
|
||||
for p in g.players
|
||||
],
|
||||
}
|
||||
for g in games.values()
|
||||
if g.bridzik_core is None or not g.bridzik_core.is_completed()
|
||||
]
|
||||
|
||||
|
||||
# --- 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,
|
||||
"is_bot": p.is_bot}
|
||||
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)
|
||||
|
||||
|
||||
# Ako dlho prezije nezacata hra, ked su vsetci hraci naraz offline. Na mobile
|
||||
# sa socket bezne strati uz pri zamknuti obrazovky, takze okamzite zmazanie
|
||||
# hry rusilo lobby, v ktorom hraci len cakali so zhasnutym telefonom.
|
||||
LOBBY_ABANDON_GRACE_SECONDS = 10 * 60
|
||||
|
||||
|
||||
async def _cleanup_abandoned_lobby(gid: str):
|
||||
"""Po grace periode zmaz nezacatu hru, ak sa medzitym nikto nevratil.
|
||||
Podmienka sa overuje az po uplynuti casu, takze pri navrate hraca je
|
||||
task neskodny no-op (netreba nic rusit)."""
|
||||
await asyncio.sleep(LOBBY_ABANDON_GRACE_SECONDS)
|
||||
game = games.get(gid)
|
||||
if game is not None and not game.started and not _any_human_connected(game):
|
||||
del games[gid]
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
def _any_human_connected(game: "Game") -> bool:
|
||||
"""Boti su 'pripojeni' stale, pre opustenost lobby sa pocitaju len ludia."""
|
||||
return any(p.connected for p in game.players if not p.is_bot)
|
||||
|
||||
|
||||
async def _mark_player_offline(game: "Game", player: "Player"):
|
||||
"""Mark player disconnected. An unstarted game with nobody left gets a
|
||||
delayed cleanup (mobile sockets drop on screen lock, so an immediate
|
||||
delete would kill lobbies where everyone is just waiting); 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_human_connected(game) and not game.started:
|
||||
asyncio.create_task(_cleanup_abandoned_lobby(game.gid))
|
||||
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
|
||||
# Boti sa rozpoznaju konvenciou mena a dostanu novy mozog -- ozivi ich
|
||||
# prvy _kick_bots (napr. ked sa clovek vrati cez rejoin_game).
|
||||
if bots_module.is_bot_username(uname):
|
||||
player.is_bot = True
|
||||
player.brain = bots_module.make_brain(uname)
|
||||
player.connected = True
|
||||
game.players.append(player)
|
||||
games[info["gid"]] = game
|
||||
return game
|
||||
|
||||
|
||||
# --- bot turns --------------------------------------------------------------
|
||||
|
||||
# Pauza medzi tahmi bota, nech ludia stihaju sledovat hru (0 = okamzite).
|
||||
BOT_MOVE_DELAY_SECONDS = float(os.environ.get("BOT_MOVE_DELAY_SECONDS", "0.8"))
|
||||
# Kopka na stole sa po dohrati este chvilu zmieta smerom k vitazovi (SETTLE_MS +
|
||||
# COLLECT_MS vo frontend/src/pages/GameTable.tsx, spolu 1650ms) -- kym tato
|
||||
# animacia nedobehne vsetkym hracom, prvy bot na tahu nesmie zahodit kartu do
|
||||
# novej kopky, inak by mu karta "vyletela" uprostred zmetania predoslej.
|
||||
TRICK_SWEEP_SECONDS = 1.7
|
||||
|
||||
|
||||
def _kick_bots(gid: str) -> None:
|
||||
"""Ak je v hre bot, spusti (na pozadi) dohratie vsetkych botich tahov.
|
||||
Vola sa po kazdej akcii, ktora mohla posunut tah na botie sedadlo."""
|
||||
game = games.get(gid)
|
||||
if game is not None and game.started and any(p.is_bot for p in game.players):
|
||||
asyncio.create_task(_run_bot_turns(gid))
|
||||
|
||||
|
||||
async def _run_bot_turns(gid: str):
|
||||
"""Kym je na tahu botie sedadlo, vykonavaj jeho tahy tym istym internym
|
||||
postupom ako handlery add_guess/play_card (engine validuje, historia sa
|
||||
zapisuje, room dostava game_status). MC vypocet bezi v executori, aby
|
||||
nedrzal event loop ostatnych hier."""
|
||||
game = games.get(gid)
|
||||
if game is None or not game.started or game.bridzik_core is None:
|
||||
return
|
||||
async with game.bot_lock:
|
||||
core = game.bridzik_core
|
||||
loop = asyncio.get_running_loop()
|
||||
while not core.is_completed():
|
||||
rnd = core.series[-1].get_last_round()
|
||||
seat = rnd.get_active_player()
|
||||
bot = game.player_by_order(seat)
|
||||
if bot is None or not bot.is_bot:
|
||||
return # na tahu je clovek
|
||||
delay = BOT_MOVE_DELAY_SECONDS
|
||||
if rnd.is_guessing_completed() and not rnd.get_last_stash().get_cards() \
|
||||
and core.get_previous_stash() is not None:
|
||||
# Bot vedie novu kopku a este bezi zmetanie tej predoslej.
|
||||
delay = max(delay, TRICK_SWEEP_SECONDS)
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
if games.get(gid) is not game:
|
||||
return # hru medzitym niekto ukoncil (end_game)
|
||||
played_card = False
|
||||
try:
|
||||
if not rnd.is_guessing_completed():
|
||||
guess = await loop.run_in_executor(None, bot.brain.guess, rnd, seat)
|
||||
core.add_player_guess(seat, guess)
|
||||
else:
|
||||
action = await loop.run_in_executor(None, bot.brain.play, rnd, seat)
|
||||
core.play_card(seat, index_card(action))
|
||||
await history.record_completed_rounds(gid, core)
|
||||
played_card = True
|
||||
except BridzikException as exc:
|
||||
# Nemalo by nastat (bot hra len legalne tahy) -- nezacykli sa,
|
||||
# slucku znovu spusti dalsia akcia cloveka.
|
||||
await send_error_room(gid, str(exc))
|
||||
return
|
||||
# game_status musi ist PRED player_cards -- klient podla neho (novy
|
||||
# round_number + previous_stash) pozna, ze prave zacalo nove kolo, a
|
||||
# dovtedy si drzi starych karty na obrazovke (pozri "displayedHand" vo
|
||||
# frontend/src/pages/GameTable.tsx), kym nedobehne animacia zmetenia
|
||||
# poslednej kopky. Opacne poradie by novu ruku odhalilo predcasne.
|
||||
await send_game_status(gid)
|
||||
if played_card:
|
||||
for player in game.players:
|
||||
if player.sid:
|
||||
await send_player_cards(gid, player.order, player.sid)
|
||||
|
||||
|
||||
async def send_error_room(gid: str, message: str):
|
||||
await sio.emit("error", {"error": message}, room=gid)
|
||||
|
||||
|
||||
# --- 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)
|
||||
|
||||
|
||||
async def _record_login_event(sid: str, player_id: int) -> None:
|
||||
scope = (sio.get_environ(sid) or {}).get("asgi.scope", {})
|
||||
headers = dict(scope.get("headers") or [])
|
||||
await stats_module.record_pageview(
|
||||
path="login",
|
||||
referrer="",
|
||||
user_agent=headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300],
|
||||
ip=_client_ip(scope),
|
||||
player_id=player_id,
|
||||
)
|
||||
|
||||
|
||||
@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 _record_login_event(sid, identity["player_id"])
|
||||
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 RegistrationIncomplete as exc:
|
||||
# Nedokoncena registracia -> vydame novy QR kod, klient sa prepne
|
||||
# na registracny tab a pouzivatel ju moze dokoncit.
|
||||
try:
|
||||
data = await auth_module.register_account(username)
|
||||
except AuthError as exc2:
|
||||
return await send_error(sid, str(exc2))
|
||||
await sio.emit("register_account", data, to=sid)
|
||||
return await send_error(sid, str(exc))
|
||||
except AuthError as exc:
|
||||
return await send_error(sid, str(exc))
|
||||
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
|
||||
await _record_login_event(sid, identity["player_id"])
|
||||
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("add_bot")
|
||||
async def add_bot(sid, gid, kind=None):
|
||||
"""Hostitel prida bota na najnizsie volne sedadlo nezacatej hry."""
|
||||
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 pridavat botov.")
|
||||
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 kind == "neural" and not bots_module.neural_available():
|
||||
return await send_error(sid, "AI bot nie je na tomto serveri dostupny.")
|
||||
if kind not in bots_module.BOT_KINDS:
|
||||
kind = bots_module.DEFAULT_KIND
|
||||
account = await bots_module.ensure_bot_account(
|
||||
kind, {p.player_id for p in game.players}
|
||||
)
|
||||
used = {p.order for p in game.players}
|
||||
order = next(o for o in range(4) if o not in used)
|
||||
player = Player(None, account["username"], order, account["player_id"])
|
||||
player.is_bot = True
|
||||
player.brain = bots_module.make_brain(account["username"])
|
||||
game.players.append(player)
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
@sio.on("remove_bot")
|
||||
async def remove_bot(sid, gid, order):
|
||||
"""Hostitel odoberie bota z nezacatej hry (sedadlo sa uvolni)."""
|
||||
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 odoberat botov.")
|
||||
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.")
|
||||
try:
|
||||
seat = int(order)
|
||||
except (TypeError, ValueError):
|
||||
return await send_error(sid, "Neplatne sedadlo.")
|
||||
player = game.player_by_order(seat)
|
||||
if player is None or not player.is_bot:
|
||||
return await send_error(sid, "Na tomto sedadle nie je bot.")
|
||||
game.players.remove(player)
|
||||
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:
|
||||
# sid None = bot alebo offline sedadlo -- emit s to=None by karty
|
||||
# broadcastol VSETKYM klientom, preto sa preskakuje.
|
||||
if player.sid:
|
||||
await send_player_cards(gid, player.order, player.sid)
|
||||
_kick_bots(gid)
|
||||
|
||||
|
||||
@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()
|
||||
_kick_bots(gid)
|
||||
|
||||
|
||||
@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()
|
||||
_kick_bots(gid)
|
||||
|
||||
|
||||
@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)
|
||||
_kick_bots(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:
|
||||
if player.sid: # None (bot/offline) by broadcastoval karty vsetkym
|
||||
await send_player_cards(game.gid, player.order, player.sid)
|
||||
# A naturally-finished game (all 4 series played out) has nothing left to
|
||||
# offer the lobby -- refresh the list so it drops out immediately instead
|
||||
# of lingering as "started"/resumable until someone happens to leave it.
|
||||
if core.is_completed():
|
||||
await broadcast_lobby()
|
||||
_kick_bots(game.gid)
|
||||
|
||||
|
||||
# --- 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)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""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
|
||||
from collections import defaultdict
|
||||
|
||||
import pyotp
|
||||
from sqlalchemy import select
|
||||
|
||||
from db import crypto
|
||||
from db.db import async_session
|
||||
from db.models import Player
|
||||
|
||||
ISSUER = "Bridžik"
|
||||
TOTP_PERIOD = 30 # sekund -- default pyotp
|
||||
|
||||
# Lockout na neuspesne prihlasovacie pokusy, per username, len v pamati
|
||||
# procesu (rovnaky pattern ako games/sessions/accounts v api/__init__.py --
|
||||
# proces je jediny, ziadny Redis).
|
||||
_LOGIN_ATTEMPT_LIMIT = 5
|
||||
_LOGIN_ATTEMPT_WINDOW = 300 # sekund
|
||||
_failed_attempts: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
"""Chyba prihlasenia/registracie (slovenska sprava pre klienta)."""
|
||||
|
||||
|
||||
class RegistrationIncomplete(AuthError):
|
||||
"""Ucet existuje, ale registracia nebola nikdy potvrdena kodom.
|
||||
|
||||
Handler v api/__init__.py na nu reaguje novym QR kodom namiesto chyby.
|
||||
"""
|
||||
|
||||
|
||||
def _is_unconfirmed(player: Player) -> bool:
|
||||
"""Ucet, ktory nikdy neoveril TOTP kod (confirm_account nastavuje oboje)."""
|
||||
return player.auth_token is None and player.totp_last_step == 0
|
||||
|
||||
|
||||
def _new_token() -> str:
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
def _check_lockout(username: str) -> None:
|
||||
cutoff = time.monotonic() - _LOGIN_ATTEMPT_WINDOW
|
||||
attempts = [t for t in _failed_attempts.get(username, []) if t > cutoff]
|
||||
if attempts:
|
||||
_failed_attempts[username] = attempts
|
||||
else:
|
||||
_failed_attempts.pop(username, None)
|
||||
if len(attempts) >= _LOGIN_ATTEMPT_LIMIT:
|
||||
raise AuthError("Príliš veľa neúspešných pokusov. Skúste to znova o pár minút.")
|
||||
|
||||
|
||||
def _register_failure(username: str) -> None:
|
||||
_failed_attempts[username].append(time.monotonic())
|
||||
|
||||
|
||||
def _clear_failures(username: str) -> None:
|
||||
_failed_attempts.pop(username, None)
|
||||
|
||||
|
||||
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(crypto.decrypt(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.
|
||||
|
||||
Nedokoncenu registraciu (meno existuje, ale kod nebol nikdy potvrdeny)
|
||||
prepise novym secretom -- povodny QR kod tym prestane platit.
|
||||
"""
|
||||
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 and not _is_unconfirmed(existing):
|
||||
raise AuthError("Toto meno je už obsadené.")
|
||||
if existing is not None:
|
||||
existing.totp_secret = crypto.encrypt(secret)
|
||||
else:
|
||||
session.add(Player(username=username, totp_secret=crypto.encrypt(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.
|
||||
|
||||
Pre nedokoncenu registraciu vyhodi RegistrationIncomplete namiesto
|
||||
overovania kodu -- pouzivatel bez naskenovaneho QR ziadny kod nema.
|
||||
"""
|
||||
return await _verify_and_issue_token(username, code, unconfirmed_ok=False)
|
||||
|
||||
|
||||
async def _verify_and_issue_token(
|
||||
username: str, code: str, *, unconfirmed_ok: bool = True
|
||||
) -> dict:
|
||||
username = (username or "").strip()
|
||||
_check_lockout(username)
|
||||
async with async_session() as session:
|
||||
player = await session.scalar(select(Player).where(Player.username == username))
|
||||
if player is None:
|
||||
_register_failure(username)
|
||||
raise AuthError("Účet neexistuje.")
|
||||
if not unconfirmed_ok and _is_unconfirmed(player):
|
||||
raise RegistrationIncomplete(
|
||||
"Registrácia nie je dokončená — naskenuj QR kód a potvrď prvým kódom."
|
||||
)
|
||||
try:
|
||||
_verify_code(player, (code or "").strip())
|
||||
except AuthError:
|
||||
_register_failure(username)
|
||||
raise
|
||||
_clear_failures(username)
|
||||
token = _new_token()
|
||||
player.auth_token = crypto.hash_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 == crypto.hash_token(token))
|
||||
)
|
||||
if player is None:
|
||||
return None
|
||||
return {"player_id": player.id, "username": player.username}
|
||||
@@ -0,0 +1,120 @@
|
||||
"""In-process boti: DB ucty botov a ich rozhodovacie "mozgy" z rl/players.py.
|
||||
|
||||
Bot je normalny hrac na sedadle -- ma riadok v tabulke `players` (aby
|
||||
historia, standings a restore fungovali bez zmeny), ale ziadny socket.
|
||||
Tahovu slucku botov ma api/__init__.py (_run_bot_turns); tu je len to,
|
||||
co potrebuje DB a rl vrstvu.
|
||||
|
||||
Bezpecnost botich uctov: totp_secret je nahodny a nikde sa neuklada v
|
||||
citatelnej podobe, totp_last_step sa nastavi na aktualny krok -- ucet tym
|
||||
padom NIE JE "nedokoncena registracia" (viz auth._is_unconfirmed), takze
|
||||
register_account ho odmietne prepisat a login bez secretu neprejde.
|
||||
"""
|
||||
|
||||
import os
|
||||
from random import Random
|
||||
|
||||
import pyotp
|
||||
from sqlalchemy import select
|
||||
|
||||
from api import auth
|
||||
from db import crypto
|
||||
from db.db import async_session
|
||||
from db.models import Player
|
||||
from rl.players import HeuristicPlayer, RandomPlayer
|
||||
from rl.pure_net import DEFAULT_WEIGHTS_PATH, PureNet, PureNeuralPlayer
|
||||
|
||||
# Prefix je konvencia na rozpoznanie bota (aj po restarte servera, kedy sa
|
||||
# sedadla obnovuju z DB len ako (player_id, username)).
|
||||
BOT_PREFIX = "bot:"
|
||||
DEFAULT_KIND = "heuristic"
|
||||
|
||||
# Natrenovana siet -- vahy (rl/weights/, export z rl/export.py) sa nacitaju
|
||||
# raz a zdielaju medzi botmi (PureNet je bezstavovy, len cita).
|
||||
_pure_net: PureNet | None = None
|
||||
|
||||
|
||||
def _neural_brain() -> PureNeuralPlayer:
|
||||
global _pure_net
|
||||
if _pure_net is None:
|
||||
_pure_net = PureNet.load()
|
||||
return PureNeuralPlayer(_pure_net)
|
||||
|
||||
|
||||
def neural_available() -> bool:
|
||||
return os.path.exists(DEFAULT_WEIGHTS_PATH)
|
||||
|
||||
|
||||
_BRAINS = {
|
||||
"heuristic": lambda: HeuristicPlayer(Random()),
|
||||
"random": lambda: RandomPlayer(Random()),
|
||||
"neural": _neural_brain,
|
||||
}
|
||||
BOT_KINDS = tuple(_BRAINS)
|
||||
|
||||
|
||||
def available_kinds() -> tuple:
|
||||
"""Druhy botov ponuknutelne na tomto serveri (neural len s vahami)."""
|
||||
return tuple(k for k in _BRAINS if k != "neural" or neural_available())
|
||||
|
||||
|
||||
def is_bot_username(username: str) -> bool:
|
||||
return bool(username) and username.startswith(BOT_PREFIX)
|
||||
|
||||
|
||||
def kind_of(username: str) -> str:
|
||||
"""'bot:heuristic-2' -> 'heuristic'; neznamy druh padne na DEFAULT_KIND."""
|
||||
body = username[len(BOT_PREFIX):]
|
||||
kind = body.rsplit("-", 1)[0]
|
||||
return kind if kind in _BRAINS else DEFAULT_KIND
|
||||
|
||||
|
||||
def make_brain(username: str):
|
||||
"""Rozhodovaci objekt (guess/play rozhranie z rl/players.py) pre bota.
|
||||
|
||||
Neural bez suboru vah (napr. restore hry na serveri bez exportu) padne
|
||||
na heuristiku -- sedadlo hra dalej, len inym mozgom.
|
||||
"""
|
||||
kind = kind_of(username)
|
||||
if kind == "neural" and not neural_available():
|
||||
kind = DEFAULT_KIND
|
||||
return _BRAINS[kind]()
|
||||
|
||||
|
||||
def _suffix_number(username: str) -> int:
|
||||
try:
|
||||
return int(username.rsplit("-", 1)[1])
|
||||
except (IndexError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
async def ensure_bot_account(kind: str, exclude_ids: set) -> dict:
|
||||
"""Najde alebo zalozi boti ucet daneho druhu; vrati {player_id, username}.
|
||||
|
||||
`exclude_ids` su ucty uz obsadene v danej hre -- kazde sedadlo potrebuje
|
||||
INY ucet (Game.playerN_id aj unikat v Guess predpokladaju 4 rozne ID).
|
||||
Ucty sa cisluju bot:<kind>-1, -2, ... a recykluju sa medzi hrami.
|
||||
"""
|
||||
prefix = f"{BOT_PREFIX}{kind}-"
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.scalars(
|
||||
select(Player).where(Player.username.like(prefix + "%"))
|
||||
)
|
||||
).all()
|
||||
for player in sorted(rows, key=lambda p: _suffix_number(p.username)):
|
||||
if player.id not in exclude_ids:
|
||||
return {"player_id": player.id, "username": player.username}
|
||||
|
||||
number = 1 + max((_suffix_number(p.username) for p in rows), default=0)
|
||||
username = f"{prefix}{number}"
|
||||
player = Player(
|
||||
username=username,
|
||||
# nahodny secret, ktory sa zahodi -- nikto sa zan neprihlasi
|
||||
totp_secret=crypto.encrypt(pyotp.random_base32()),
|
||||
# nenulovy last_step = ucet sa netvari ako nedokoncena registracia
|
||||
totp_last_step=auth._current_step(),
|
||||
)
|
||||
session.add(player)
|
||||
await session.commit()
|
||||
return {"player_id": player.id, "username": player.username}
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Self-hosted usage analytics: zapis pageview beacon + citanie agregatov pre /admin/stats.
|
||||
|
||||
Oddelene od api/history.py (ktory drzi zivu hru + restore-on-startup logiku),
|
||||
rovnako ako je api/auth.py samostatny modul.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import geoip2.database
|
||||
import geoip2.errors
|
||||
from sqlalchemy import extract, func, or_, select
|
||||
from user_agents import parse as parse_ua
|
||||
|
||||
from db.db import async_session
|
||||
from db.models import Game, Guess, PageView, Player
|
||||
|
||||
# Konvencia na rozpoznanie botieho uctu (viz api.bots.BOT_PREFIX) -- drzana tu
|
||||
# lokalne, aby sa do statistickej cesty netahala rl vrstva (siet/numpy).
|
||||
_BOT_PREFIX = "bot:"
|
||||
|
||||
_geoip_reader: "geoip2.database.Reader | None" = None
|
||||
_geoip_load_attempted = False
|
||||
|
||||
|
||||
def _country_for_ip(ip: str) -> str:
|
||||
"""Cely anglicky nazov krajiny z lokalneho .mmdb (GEOIP_DB_PATH), alebo ""
|
||||
ak nie je dostupny subor alebo sa IP neda rozlusit (privatna/lokalna
|
||||
adresa a pod.)."""
|
||||
global _geoip_reader, _geoip_load_attempted
|
||||
if not ip:
|
||||
return ""
|
||||
if _geoip_reader is None:
|
||||
if _geoip_load_attempted:
|
||||
return ""
|
||||
_geoip_load_attempted = True
|
||||
path = os.environ.get("GEOIP_DB_PATH", "")
|
||||
if not path or not os.path.exists(path):
|
||||
return ""
|
||||
_geoip_reader = geoip2.database.Reader(path)
|
||||
try:
|
||||
return _geoip_reader.country(ip).country.name or ""
|
||||
except (geoip2.errors.AddressNotFoundError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
# Cesty s dynamickym ID segmentom -- do statistik sa uklada len prefix, aby sa
|
||||
# navstevy neroztriestili na /lobby/<gid>, /game/<gid>... (kazda hra inak max 4x).
|
||||
_DYNAMIC_PATH_PREFIXES = ("/lobby", "/game")
|
||||
|
||||
# Tieto (po normalizacii) sa vobec nezaznamenavaju -- vysoka frekvencia (kazda
|
||||
# akcia v hre) bez analytickej hodnoty. "/auth" je zas 1:1 duplicita eventu
|
||||
# "landing": dostat sa nan da len plnym loadom stranky (interne redirecty nan
|
||||
# su REPLACE a beacon ich skipuje), a kazdy plny load uz posiela "landing"
|
||||
# (main.tsx). Ostava len /history a pomenovane eventy (napr. "landing",
|
||||
# "rules_view", "login"), ktore sem nespadaju.
|
||||
_SKIPPED_PATHS = frozenset({"/", "/auth", "/lobby", "/game"})
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
for prefix in _DYNAMIC_PATH_PREFIXES:
|
||||
if path == prefix or path.startswith(prefix + "/"):
|
||||
return prefix
|
||||
return path
|
||||
|
||||
|
||||
async def record_pageview(
|
||||
path: str, referrer: str, user_agent: str, ip: str = "", player_id: int | None = None
|
||||
) -> None:
|
||||
"""Zapise navstevu URL cesty ALEBO pomenovany event -- rovnaky stlpec `path`
|
||||
rozlisuje oboje podla toho, ci zacina "/" (pozri PageView.path)."""
|
||||
normalized = _normalize_path(path)
|
||||
if normalized in _SKIPPED_PATHS:
|
||||
return
|
||||
ua = parse_ua(user_agent)
|
||||
device_type = (
|
||||
"bot" if ua.is_bot else "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "pc"
|
||||
)
|
||||
async with async_session() as session:
|
||||
session.add(
|
||||
PageView(
|
||||
path=normalized,
|
||||
player_id=player_id,
|
||||
referrer=referrer,
|
||||
user_agent=user_agent,
|
||||
browser=ua.browser.family[:40],
|
||||
os=ua.os.family[:40],
|
||||
device_type=device_type,
|
||||
ip=ip[:45],
|
||||
country=_country_for_ip(ip),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
_DAYS_WINDOW = 30 # kazdy "za den" graf/rozklad zobrazuje rovnake okno
|
||||
|
||||
# Identita navstevnika = IP + User-Agent (rovnaky pristup ako Plausible/
|
||||
# GoatCounter): odlisi dvoch ludi za jednym NAT-om s roznym prehliadacom/
|
||||
# zariadenim. Dvoch s uplne identickym UA neodlisi nic bez cookies.
|
||||
_visitor_id = PageView.ip + "|" + PageView.user_agent
|
||||
|
||||
|
||||
async def _pageviews_by_day_and(session, column, logged_in_only=False) -> dict[str, dict[str, int]]:
|
||||
"""Denne navstevy rozdelene podla danej dimenzie (device_type/browser/os),
|
||||
napr. {"2026-07-01": {"pc": 3, "mobile": 1}, ...} -- pre prepinatelny graf.
|
||||
Orezane na _DAYS_WINDOW dni, rovnako ako pageviews_per_day (a ostatne denne
|
||||
grafy), aby prepnutie medzi dimenziami neroztiahlo graf na celu historiu.
|
||||
|
||||
Scope "logged_in": kazdy login sa pocita samostatne (rovnako ako
|
||||
_login_event_counts), aby graf sedel s cislami v BreakdownTable nizsie.
|
||||
Scope "all": pocita sa "navstevnicky den" (rovnako ako _daily_unique_by),
|
||||
z toho isteho dovodu -- inak by graf (klikova statistika) nesedel s
|
||||
cislami dole (navstevnicka statistika)."""
|
||||
vday = func.date(PageView.created_at)
|
||||
if logged_in_only:
|
||||
recent_days_q = select(vday.label("day")).distinct().where(PageView.path == "login")
|
||||
rows_q = (
|
||||
select(vday.label("day"), column.label("cat"), func.count().label("n"))
|
||||
.where(PageView.path == "login")
|
||||
)
|
||||
recent_days = recent_days_q.order_by(vday.desc()).limit(_DAYS_WINDOW).subquery()
|
||||
rows = (
|
||||
await session.execute(
|
||||
rows_q.where(vday.in_(select(recent_days.c.day)))
|
||||
.group_by(vday, column)
|
||||
.order_by(vday.desc())
|
||||
)
|
||||
).all()
|
||||
else:
|
||||
inner = select(
|
||||
vday.label("day"), column.label("cat"), _visitor_id.label("visitor")
|
||||
).distinct().subquery()
|
||||
recent_days = (
|
||||
select(inner.c.day).distinct().order_by(inner.c.day.desc()).limit(_DAYS_WINDOW)
|
||||
).subquery()
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(inner.c.day, inner.c.cat, func.count().label("n"))
|
||||
.where(inner.c.day.in_(select(recent_days.c.day)))
|
||||
.group_by(inner.c.day, inner.c.cat)
|
||||
.order_by(inner.c.day.desc())
|
||||
)
|
||||
).all()
|
||||
nested: dict[str, dict[str, int]] = {}
|
||||
for r in rows:
|
||||
nested.setdefault(str(r.day), {})[r.cat] = r.n
|
||||
return nested
|
||||
|
||||
|
||||
async def _daily_unique_by(session, column, exclude_empty=False) -> list:
|
||||
"""Rozklad podla dimenzie (browser/os/...) pre anonymnu navstevnost, kde
|
||||
jednotka nie je klik ale "navstevnicky den": ten isty navstevnik (IP+UA)
|
||||
sa v ramci jedneho dna pocita raz, na dalsi den znova. Sedi tak so suctom
|
||||
grafu visitors_per_day. Pre scope "logged_in" sa nepouziva -- tam ma kazde
|
||||
prihlasenie vahu 1x (viz _login_event_counts), aby to sedelo s
|
||||
pageviews_per_day ("Ked sa 2x prihlasi ten isty user, chcem to mat ako 2x").
|
||||
|
||||
Portable cez SQLite aj Postgres: najprv DISTINCT (den, kategoria, navstevnik)
|
||||
v subquery, potom GROUP BY kategoria."""
|
||||
vday = func.date(PageView.created_at)
|
||||
inner = select(
|
||||
vday.label("day"), column.label("cat"), _visitor_id.label("visitor")
|
||||
).distinct()
|
||||
if exclude_empty:
|
||||
inner = inner.where(column != "")
|
||||
sub = inner.subquery()
|
||||
return (
|
||||
await session.execute(
|
||||
select(sub.c.cat, func.count().label("n"))
|
||||
.group_by(sub.c.cat)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
).all()
|
||||
|
||||
|
||||
async def _login_event_counts(session, column, exclude_empty=False) -> list:
|
||||
"""Rozklad podla dimenzie pocitany priamo z poctu login-eventov (kazdy
|
||||
riadok PageView s path == "login" sa pocita samostatne) -- na rozdiel od
|
||||
_daily_unique_by nededuplikuje podla navstevnika/dna, takze opakovane
|
||||
prihlasenie toho isteho hraca v ten isty den sa prejavi ako 2, presne
|
||||
ako v pageviews_per_day."""
|
||||
q = select(column.label("cat"), func.count().label("n")).where(PageView.path == "login")
|
||||
if exclude_empty:
|
||||
q = q.where(column != "")
|
||||
return (
|
||||
await session.execute(q.group_by(column).order_by(func.count().desc()))
|
||||
).all()
|
||||
|
||||
|
||||
async def get_daily_stats(logged_in_only: bool = False) -> dict:
|
||||
"""logged_in_only obmedzuje traffic-analyticke widgety (PageView) na
|
||||
zaznamy z eventu "login" (jediny event, ktory nesie player_id -- bezne
|
||||
beacony ho neposielaju vobec). Herne metriky (games/players/rounds) su
|
||||
uz zo svojej podstaty vzdy o prihlasenych uctoch, prepinac sa ich netyka.
|
||||
top_paths ostava vzdy pocitane zo vsetkych navstev bez ohladu na scope --
|
||||
najnavstevovanejsie stranky maju zmysel len ako celok."""
|
||||
async with async_session() as session:
|
||||
# func.date() (not cast(..., Date)) -- the `date()` SQL function is portable
|
||||
# across SQLite and Postgres and returns a plain string/date value without
|
||||
# the double-conversion issue cast(..., Date) triggers on SQLite (aiosqlite
|
||||
# already coerces TIMESTAMP columns to datetime before the Date result
|
||||
# processor tries to re-parse them as an ISO string).
|
||||
day = func.date(Game.created_at)
|
||||
game_rows = (
|
||||
await session.execute(
|
||||
select(day.label("day"), func.count().label("n"))
|
||||
.group_by(day)
|
||||
.order_by(day.desc())
|
||||
.limit(_DAYS_WINDOW)
|
||||
)
|
||||
).all()
|
||||
|
||||
pday = func.date(Player.created_at)
|
||||
player_rows = (
|
||||
await session.execute(
|
||||
select(pday.label("day"), func.count().label("n"))
|
||||
.group_by(pday)
|
||||
.order_by(pday.desc())
|
||||
.limit(_DAYS_WINDOW)
|
||||
)
|
||||
).all()
|
||||
|
||||
# Dokoncene hry rozdelene podla toho, ci na niektorom zo 4 sedadiel sedel
|
||||
# bot (ucet s username "bot:..."). has_bot je pravdive, ak aspon jedno
|
||||
# sedadlo patri botiemu uctu.
|
||||
bot_ids = select(Player.id).where(Player.username.like(f"{_BOT_PREFIX}%"))
|
||||
has_bot = or_(
|
||||
Game.player0_id.in_(bot_ids),
|
||||
Game.player1_id.in_(bot_ids),
|
||||
Game.player2_id.in_(bot_ids),
|
||||
Game.player3_id.in_(bot_ids),
|
||||
)
|
||||
finished = Game.ended_at.is_not(None)
|
||||
finished_bot_games, finished_human_games = (
|
||||
await session.execute(
|
||||
select(
|
||||
func.count().filter(finished & has_bot),
|
||||
func.count().filter(finished & ~has_bot),
|
||||
)
|
||||
)
|
||||
).one()
|
||||
|
||||
avg_duration = (
|
||||
await session.execute(
|
||||
select(func.avg(func.extract("epoch", Game.ended_at - Game.created_at))).where(
|
||||
Game.ended_at.is_not(None)
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
|
||||
# Nedokoncena registracia = kod nebol nikdy potvrdeny (SQL obdoba
|
||||
# api/auth._is_unconfirmed). "Hraci celkom" pocita len potvrdene ucty,
|
||||
# nedokoncene sa vykazuju osobitne.
|
||||
unconfirmed = Player.auth_token.is_(None) & (Player.totp_last_step == 0)
|
||||
total_players, unconfirmed_players = (
|
||||
await session.execute(
|
||||
select(
|
||||
func.count(Player.id).filter(~unconfirmed),
|
||||
func.count(Player.id).filter(unconfirmed),
|
||||
)
|
||||
)
|
||||
).one()
|
||||
|
||||
peak_hours = (
|
||||
await session.execute(
|
||||
select(extract("hour", Game.created_at).label("h"), func.count().label("n"))
|
||||
.group_by("h")
|
||||
.order_by("h")
|
||||
)
|
||||
).all()
|
||||
|
||||
rday = func.date(Game.created_at)
|
||||
rounds_rows = (
|
||||
await session.execute(
|
||||
select(rday.label("day"), func.count().label("n"))
|
||||
.select_from(Guess)
|
||||
.join(Game, Guess.game_id == Game.id)
|
||||
.group_by(rday)
|
||||
.order_by(rday.desc())
|
||||
.limit(_DAYS_WINDOW)
|
||||
)
|
||||
).all()
|
||||
|
||||
vday = func.date(PageView.created_at)
|
||||
pageview_q = select(vday.label("day"), func.count().label("n"))
|
||||
if logged_in_only:
|
||||
# "Navstevnici" v scope Prihlaseni = unikatni HRACI (player_id) za
|
||||
# den, nie unikatne IP+UA -- to je presny pocet skutocnych uctov.
|
||||
pageview_q = pageview_q.where(PageView.path == "login")
|
||||
visitor_q = (
|
||||
select(vday.label("day"), func.count(func.distinct(PageView.player_id)).label("n"))
|
||||
.where(PageView.path == "login")
|
||||
)
|
||||
else:
|
||||
visitor_q = select(vday.label("day"), func.count(func.distinct(_visitor_id)).label("n"))
|
||||
|
||||
pageview_rows = (
|
||||
await session.execute(
|
||||
pageview_q.group_by(vday).order_by(vday.desc()).limit(_DAYS_WINDOW)
|
||||
)
|
||||
).all()
|
||||
# Unikatni navstevnici za den (distinct IP+UA v ramci dna; ten isty
|
||||
# navstevnik sa na dalsi den pocita znova).
|
||||
visitor_rows = (
|
||||
await session.execute(
|
||||
visitor_q.group_by(vday).order_by(vday.desc()).limit(_DAYS_WINDOW)
|
||||
)
|
||||
).all()
|
||||
# Top stranky vzdy zo vsetkych navstev -- scope prepinac sa ich netyka.
|
||||
top_paths = (
|
||||
await session.execute(
|
||||
select(PageView.path, func.count().label("n"))
|
||||
.group_by(PageView.path)
|
||||
.order_by(func.count().desc())
|
||||
.limit(20)
|
||||
)
|
||||
).all()
|
||||
|
||||
if logged_in_only:
|
||||
# Kazdy login sa pocita samostatne (nededuplikovane) -- sedi to s
|
||||
# pageviews_per_day, kde opakovane prihlasenie toho isteho hraca
|
||||
# v ten isty den ma tiez pridat 2, nie 1.
|
||||
top_referrers = (await _login_event_counts(session, PageView.referrer, exclude_empty=True))[:20]
|
||||
browsers = await _login_event_counts(session, PageView.browser)
|
||||
os_rows = await _login_event_counts(session, PageView.os)
|
||||
device_rows = await _login_event_counts(session, PageView.device_type)
|
||||
country_rows = await _login_event_counts(session, PageView.country, exclude_empty=True)
|
||||
else:
|
||||
# Zlozenie anonymnej navstevnosti sa pocita v "navstevnickych
|
||||
# dnoch" (den+IP raz), nie v klikoch -- jeden aktivny hrac tak
|
||||
# neprevazi tabulky.
|
||||
top_referrers = (await _daily_unique_by(session, PageView.referrer, exclude_empty=True))[:20]
|
||||
browsers = await _daily_unique_by(session, PageView.browser)
|
||||
os_rows = await _daily_unique_by(session, PageView.os)
|
||||
device_rows = await _daily_unique_by(session, PageView.device_type)
|
||||
country_rows = await _daily_unique_by(session, PageView.country, exclude_empty=True)
|
||||
|
||||
pageviews_per_day_by_device = await _pageviews_by_day_and(
|
||||
session, PageView.device_type, logged_in_only=logged_in_only
|
||||
)
|
||||
pageviews_per_day_by_browser = await _pageviews_by_day_and(
|
||||
session, PageView.browser, logged_in_only=logged_in_only
|
||||
)
|
||||
pageviews_per_day_by_os = await _pageviews_by_day_and(
|
||||
session, PageView.os, logged_in_only=logged_in_only
|
||||
)
|
||||
|
||||
return {
|
||||
"games_per_day": {str(r.day): r.n for r in game_rows},
|
||||
"players_per_day": {str(r.day): r.n for r in player_rows},
|
||||
"finished_bot_games": finished_bot_games,
|
||||
"finished_human_games": finished_human_games,
|
||||
"avg_game_duration_minutes": (float(avg_duration) / 60) if avg_duration else None,
|
||||
"total_players": total_players,
|
||||
"unconfirmed_players": unconfirmed_players,
|
||||
"peak_hours": {int(r.h): r.n for r in peak_hours},
|
||||
"rounds_per_day": {str(r.day): r.n for r in rounds_rows},
|
||||
"pageviews_per_day": {str(r.day): r.n for r in pageview_rows},
|
||||
"visitors_per_day": {str(r.day): r.n for r in visitor_rows},
|
||||
"pageviews_per_day_by_device": pageviews_per_day_by_device,
|
||||
"pageviews_per_day_by_browser": pageviews_per_day_by_browser,
|
||||
"pageviews_per_day_by_os": pageviews_per_day_by_os,
|
||||
"top_referrers": {r.cat: r.n for r in top_referrers},
|
||||
"top_paths": {r.path: r.n for r in top_paths},
|
||||
"browsers": {r.cat: r.n for r in browsers},
|
||||
"operating_systems": {r.cat: r.n for r in os_rows},
|
||||
"device_types": {r.cat: r.n for r in device_rows},
|
||||
"countries": {r.cat: r.n for r in country_rows},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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,6 +19,10 @@ class Card_colors(Enum):
|
||||
return self.name == other.name
|
||||
return NotImplemented
|
||||
|
||||
# vlastne __eq__ rusi zdedeny __hash__ -- obnovit konzistentne s __eq__
|
||||
def __hash__(self):
|
||||
return hash(self.name)
|
||||
|
||||
|
||||
class Card_values(Enum):
|
||||
C7 = 1
|
||||
@@ -51,6 +55,10 @@ class Card_values(Enum):
|
||||
return self.name == other.name
|
||||
return NotImplemented
|
||||
|
||||
# vlastne __eq__ rusi zdedeny __hash__ -- obnovit konzistentne s __eq__
|
||||
def __hash__(self):
|
||||
return hash(self.name)
|
||||
|
||||
|
||||
class Card():
|
||||
def __init__(self, color: Card_colors, value: Card_values):
|
||||
@@ -63,6 +71,10 @@ class Card():
|
||||
and self.value == other.value
|
||||
return NotImplemented
|
||||
|
||||
# vlastne __eq__ rusi zdedeny __hash__ -- obnovit konzistentne s __eq__
|
||||
def __hash__(self):
|
||||
return hash((self.color, self.value))
|
||||
|
||||
def __str__(self):
|
||||
return '{}_{}'.format(self.color.name, self.value.name)
|
||||
|
||||
@@ -82,8 +94,15 @@ class Card():
|
||||
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():
|
||||
def __init__(self, shuffler = shuffle):
|
||||
def __init__(self, shuffler=shuffle):
|
||||
self.shuffler = shuffler
|
||||
self.series = [Series(0, 0, shuffler=self.shuffler)]
|
||||
|
||||
@@ -99,13 +118,16 @@ class Bridzik():
|
||||
raise BridzikException('Hra je ukoncena.')
|
||||
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 = {}
|
||||
last_series = self.series[-1]
|
||||
if not self.is_completed():
|
||||
status['active_player'] = last_series.get_last_round().get_active_player()
|
||||
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():
|
||||
status['active_round_stashes'] = last_series.get_last_round().get_stashes_winner_summary()
|
||||
status['active_stash'] = {
|
||||
@@ -121,7 +143,7 @@ class Bridzik():
|
||||
return status
|
||||
|
||||
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):
|
||||
if len(self.series[-1].get_last_round().stashes) > 1:
|
||||
@@ -166,7 +188,7 @@ class Series():
|
||||
self.start_new_round()
|
||||
|
||||
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):
|
||||
return [r.get_points_summary() for r in self.rounds if r.is_completed()]
|
||||
@@ -186,9 +208,9 @@ class Series():
|
||||
|
||||
|
||||
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
|
||||
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.')
|
||||
if first_player not in [0, 1, 2, 3]:
|
||||
raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.')
|
||||
@@ -285,7 +307,7 @@ class Round():
|
||||
def get_last_stash(self):
|
||||
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()
|
||||
shuffler(self.round_cards)
|
||||
self.round_cards = self.round_cards[(4*self.round_number):]
|
||||
@@ -308,7 +330,7 @@ class Round():
|
||||
self.stashes.append(Stash(self.get_last_stash().get_winner()))
|
||||
|
||||
|
||||
class Stash():
|
||||
class Stash:
|
||||
def __init__(self, first_player: int):
|
||||
if first_player not in [0, 1, 2, 3]:
|
||||
raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.')
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Datova vrstva (persistencia). Nezavisla od Socket.IO/Flask, ako engine.
|
||||
|
||||
Exportuje pripojenie a ORM modely; aplikacna logika (api/) ich pouziva.
|
||||
"""
|
||||
|
||||
from db.db import Base, engine, async_session, init_db
|
||||
from db import models
|
||||
|
||||
__all__ = ["Base", "engine", "async_session", "init_db", "models"]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Sifrovanie/hashovanie citlivych stlpcov (Player.totp_secret, Player.auth_token).
|
||||
|
||||
totp_secret sa musi dat spatne desifrovat (treba ho na vygenerovanie/overenie
|
||||
TOTP kodu), preto Fernet -- symetricke sifrovanie s klucom z env ENCRYPTION_KEY.
|
||||
auth_token sa iba porovnava, nikdy nepotrebujeme povodnu hodnotu spat, preto
|
||||
staci jednosmerny SHA-256 hash (token ma 384 bitov entropie z
|
||||
secrets.token_urlsafe(48) v api/auth.py, takze netreba salt/pepper).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
_ENV_VAR = "ENCRYPTION_KEY"
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
key = os.environ.get(_ENV_VAR)
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
f"{_ENV_VAR} nie je nastaveny. Vygeneruj ho pomocou:\n"
|
||||
' python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"\n'
|
||||
"a nastav ako env premennu (drz ho stabilny -- zmena znamena, "
|
||||
"ze existujuce totp_secret sa uz nedaju desifrovat)."
|
||||
)
|
||||
return Fernet(key.encode())
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
return _fernet().encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt(ciphertext: str) -> str:
|
||||
try:
|
||||
return _fernet().decrypt(ciphertext.encode()).decode()
|
||||
except InvalidToken as exc:
|
||||
raise ValueError("Neplatny alebo poskodeny sifrovany udaj.") from exc
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Async SQLAlchemy pripojenie a inicializacia schemy.
|
||||
|
||||
Connection string z env DATABASE_URL. Default je lokalny SQLite subor (dev);
|
||||
v produkcii staci nastavit DATABASE_URL na PostgreSQL (asyncpg), kod sa nemeni.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///bridzik.db")
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
async_session: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
engine, expire_on_commit=False
|
||||
)
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Vytvori tabulky, ak este neexistuju. Vola sa pri starte servera."""
|
||||
# Import modelov registruje tabulky na Base.metadata.
|
||||
from db import models # noqa: F401
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""ORM modely: Player, Game, Guess, PageView.
|
||||
|
||||
`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)
|
||||
# Sifrovany cez db/crypto.py (Fernet) -- nikdy neuklada plaintext secret.
|
||||
totp_secret: Mapped[str] = mapped_column(String(255))
|
||||
# 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`).
|
||||
# Uklada sa SHA-256 hash (db/crypto.hash_token), nie surovy token.
|
||||
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)
|
||||
|
||||
|
||||
class PageView(Base):
|
||||
"""Jedna navsteva stranky (pre /track). Bez schema migracii -- create_all only."""
|
||||
|
||||
__tablename__ = "page_views"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
# Konvencia: zacina "/" -> skutocna URL cesta; inak nazov eventu (napr.
|
||||
# "rules_view", "login") -- ziadny osobitny stlpec, rovnaky stlpec pre oboje.
|
||||
path: Mapped[str] = mapped_column(String(200))
|
||||
# Vyplnene len ak je znamy prihlaseny hrac v momente zapisu (napr. event "login").
|
||||
player_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("players.id"), nullable=True, index=True
|
||||
)
|
||||
referrer: Mapped[str] = mapped_column(String(300), default="")
|
||||
user_agent: Mapped[str] = mapped_column(String(300), default="")
|
||||
browser: Mapped[str] = mapped_column(String(40), default="")
|
||||
os: Mapped[str] = mapped_column(String(40), default="")
|
||||
device_type: Mapped[str] = mapped_column(String(20), default="") # mobile/tablet/pc/bot
|
||||
ip: Mapped[str] = mapped_column(String(45), default="") # surova IP (IPv4/IPv6), "" ak nezname
|
||||
country: Mapped[str] = mapped_column(String(60), default="") # anglicky nazov z GeoIP, "" ak nerozlusene
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
# Production stack: built images (no bind mounts), nginx serves the built
|
||||
# frontend + reverse-proxies Socket.IO, Postgres is internal-only.
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env # fill in real secrets
|
||||
# docker compose -f docker-compose.prod.yaml up -d --build
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:18-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
TZ: Europe/Bratislava
|
||||
PGTZ: Europe/Bratislava
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql
|
||||
restart: always
|
||||
networks:
|
||||
caddy_network:
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL}
|
||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD}
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin
|
||||
restart: always
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
caddy_network:
|
||||
|
||||
backend:
|
||||
build: .
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||
ADMIN_TOKEN: ${ADMIN_TOKEN}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
GEOIP_DB_PATH: /app/geoip/GeoLite2-Country.mmdb
|
||||
volumes:
|
||||
- /home/tim/docker_volumes/bridzik/:/app/geoip:ro
|
||||
restart: always
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
caddy_network:
|
||||
|
||||
frontend:
|
||||
container_name: bridzik
|
||||
build: ./frontend
|
||||
environment:
|
||||
BACKEND_UPSTREAM: backend:5000
|
||||
restart: always
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
caddy_network:
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
pgadmin_data:
|
||||
|
||||
networks:
|
||||
caddy_network:
|
||||
external: true
|
||||
@@ -0,0 +1,56 @@
|
||||
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
|
||||
# Shared secret for the self-hosted /api/admin/* stats endpoints.
|
||||
ADMIN_TOKEN: tajneheslo
|
||||
# Dev-only Fernet key encrypting Player.totp_secret -- fine to hardcode
|
||||
# here since the dev DB is disposable (docker-compose down -v).
|
||||
ENCRYPTION_KEY: FAMD5i_Pc-Ursu_Bi49ZYMN2ehhfBkjjehxTOFvNrBU=
|
||||
# Optional: IP -> country for /api/track. Drop a .mmdb file (GeoLite2 or
|
||||
# a DB-IP/IP2Location Lite equivalent) at ./geoip/ -- it's already inside
|
||||
# the ./:/app bind mount below, no extra volume entry needed. Missing
|
||||
# file -> country is just recorded as "" (see api/stats.py:_country_for_ip).
|
||||
GEOIP_DB_PATH: /app/geoip/GeoLite2-Country.mmdb
|
||||
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:
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
@@ -0,0 +1,20 @@
|
||||
# ---- builder: npm ci + vite build -----------------------------------------
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---- runtime: static files served by nginx ---------------------------------
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
# Rendered to /etc/nginx/conf.d/default.conf at container start by the base
|
||||
# image's docker-entrypoint.sh, substituting ${BACKEND_UPSTREAM} (set via
|
||||
# compose env, e.g. "backend:5000").
|
||||
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||
|
||||
EXPOSE 80
|
||||
@@ -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>
|
||||
@@ -0,0 +1,38 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA: let the router handle unknown paths.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Socket.IO (websocket + polling) -> backend.
|
||||
location /socket.io/ {
|
||||
proxy_pass http://${BACKEND_UPSTREAM}/socket.io/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://${BACKEND_UPSTREAM}/health;
|
||||
}
|
||||
|
||||
# Pageview beacon + admin stats -> backend.
|
||||
location /api/ {
|
||||
proxy_pass http://${BACKEND_UPSTREAM}/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "bridzik-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"bysquare": "^4.0.0",
|
||||
"qrcode.react": "^4.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"recharts": "^2.12.7",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 852 B |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,131 @@
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useNavigationType } from 'react-router-dom';
|
||||
import { useGameStore } from './store/gameStore';
|
||||
import { socket, emit } from './lib/socket';
|
||||
import { trackEvent } from './lib/track';
|
||||
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';
|
||||
import Donate from './pages/Donate';
|
||||
|
||||
// Admin pulls in recharts — lazy-load it so players never download that chunk.
|
||||
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout'));
|
||||
const AdminStats = lazy(() => import('./pages/admin/AdminStats'));
|
||||
|
||||
function AppInner() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
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');
|
||||
// /admin is a separate concern gated by its own token, not the player login.
|
||||
const onAdminRoute = location.pathname.startsWith('/admin');
|
||||
const targetRoute = onAdminRoute
|
||||
? null
|
||||
: !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]);
|
||||
|
||||
// Fire-and-forget pageview beacon for self-hosted analytics; must never
|
||||
// affect the app (network errors are swallowed). Skip REPLACE navigations --
|
||||
// those are app-internal gate/index redirects (login gate, /admin index ->
|
||||
// stats), not a page the user actually navigated to, so they'd otherwise
|
||||
// inflate the count with one extra row per redirect hop. Skip /admin itself
|
||||
// too -- that's the dashboard viewing its own traffic, not player usage.
|
||||
// Skip /, /lobby, /game -- high-frequency in-game navigation with no
|
||||
// analytical value; the backend drops these anyway (api/stats.py _SKIPPED_PATHS).
|
||||
// The visitor's first touch (incl. landings on "/", which both skips would
|
||||
// otherwise swallow) is captured by trackLanding() in main.tsx instead.
|
||||
// Skip /auth too: it's only ever reached by a full page load (every in-app
|
||||
// redirect to it is REPLACE), so each /auth row would be a 1:1 duplicate of
|
||||
// the landing event fired by that same load.
|
||||
useEffect(() => {
|
||||
if (navigationType === 'REPLACE') return;
|
||||
if (location.pathname.startsWith('/admin')) return;
|
||||
if (
|
||||
location.pathname === '/' ||
|
||||
location.pathname === '/auth' ||
|
||||
location.pathname.startsWith('/lobby') ||
|
||||
location.pathname.startsWith('/game')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
trackEvent(location.pathname, document.referrer);
|
||||
}, [location.pathname, navigationType]);
|
||||
|
||||
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>
|
||||
)}
|
||||
<Suspense fallback={<p className="text-center text-green-dim pt-20 font-serif italic">Načítava sa…</p>}>
|
||||
<Routes>
|
||||
<Route path="/auth" element={<Auth />} />
|
||||
<Route path="/" element={<GameList />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/donate" element={<Donate />} />
|
||||
<Route path="/lobby/:gid" element={<Lobby />} />
|
||||
<Route path="/game/:gid" element={<GameTable />} />
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="stats" replace />} />
|
||||
<Route path="stats" element={<AdminStats />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppInner />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useCardSkin } from '../lib/cardSkin';
|
||||
|
||||
/** Small header button that flips the deck between the classic vector cards and
|
||||
* the illustrated "sedmové" art. Persists via the cardSkin store. */
|
||||
export default function CardSkinToggle({ className = '' }: { className?: string }) {
|
||||
const skin = useCardSkin((s) => s.skin);
|
||||
const toggle = useCardSkin((s) => s.toggle);
|
||||
const illustrated = skin === 'illustrated';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
title="Prepnúť vzhľad kariet"
|
||||
aria-label="Prepnúť vzhľad kariet"
|
||||
aria-pressed={illustrated}
|
||||
className={`text-[13px] whitespace-nowrap transition-colors ${
|
||||
illustrated ? 'text-gold hover:text-gold-bright' : 'text-[#7a7058] hover:text-gold'
|
||||
} ${className}`}
|
||||
>
|
||||
{illustrated ? '🂠 Sedmové' : '🂠 Klasické'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Card } from '../types';
|
||||
import { useCardSkin, suitIcon } from '../lib/cardSkin';
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
// The bell, leaf and heart icons are a touch taller than the acorn, so in the
|
||||
// centre they sometimes bleed into the corner marks — nudge those three down.
|
||||
const CENTER_ICON_SCALE: Record<string, number> = {
|
||||
HEARTS: 0.85,
|
||||
LEAVES: 0.85,
|
||||
BELLS: 0.85,
|
||||
ACORNS: 1,
|
||||
};
|
||||
|
||||
// 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 skin = useCardSkin((s) => s.skin);
|
||||
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;
|
||||
// 'illustrated' skin keeps the classic card but swaps the ♥♠♣♦ glyphs for the
|
||||
// suit-icon artwork; 'classic' uses the plain text glyphs.
|
||||
const useIcons = skin === 'illustrated';
|
||||
// With the icon artwork the bell suit reads brown, so tint its value label to
|
||||
// match instead of the classic blue.
|
||||
const labelColor = useIcons && card.color === 'BELLS' ? '#8a5a1c' : color;
|
||||
|
||||
const corner = (rotated: boolean) => (
|
||||
<span
|
||||
// Icons align to the number's left edge so a wide label (VIII) doesn't shove
|
||||
// the centred icon toward the middle; classic text stays centred as before.
|
||||
className={`absolute flex flex-col leading-none ${useIcons ? 'items-start' : 'items-center'}`}
|
||||
style={
|
||||
rotated
|
||||
? { bottom: d.inset, right: d.inset, transform: 'rotate(180deg)' }
|
||||
: { top: d.inset, left: d.inset }
|
||||
}
|
||||
>
|
||||
<span style={{ color: labelColor, fontSize: d.label, fontFamily: 'Georgia,serif', fontWeight: 700, lineHeight: 1 }}>
|
||||
{label}
|
||||
</span>
|
||||
{useIcons ? (
|
||||
<img
|
||||
src={suitIcon(card.color, 'small')}
|
||||
alt={symbol}
|
||||
draggable={false}
|
||||
style={{ height: d.suitSm * 1.25, width: 'auto', marginTop: 1 }}
|
||||
className="select-none"
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
{useIcons ? (
|
||||
<img
|
||||
src={suitIcon(card.color, 'large')}
|
||||
alt={symbol}
|
||||
draggable={false}
|
||||
style={{ height: d.suitLg * CENTER_ICON_SCALE[card.color], width: 'auto' }}
|
||||
className="select-none"
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color, fontSize: d.suitLg, lineHeight: 1 }}>{symbol}</span>
|
||||
)}
|
||||
</span>
|
||||
{corner(true)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { emit } from '../lib/socket';
|
||||
import { forbiddenGuess } from '../lib/gameRules';
|
||||
import { GUESS_BUTTON } from '../lib/boardTheme';
|
||||
|
||||
interface Props {
|
||||
cardsInRound: number;
|
||||
guesses: Record<string, number>;
|
||||
myOrder: number;
|
||||
activePlayer: number;
|
||||
activePlayerName: string;
|
||||
desktop?: boolean;
|
||||
}
|
||||
|
||||
export default function GuessControls({ cardsInRound, guesses, myOrder, activePlayer, activePlayerName, desktop = true }: Props) {
|
||||
const isMyTurn = activePlayer === myOrder;
|
||||
const forbidden = forbiddenGuess(cardsInRound, guesses);
|
||||
|
||||
if (!isMyTurn) {
|
||||
return (
|
||||
<p className="text-center text-[14px] 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);
|
||||
const btn = desktop ? GUESS_BUTTON.desktop : GUESS_BUTTON.mobile;
|
||||
|
||||
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}
|
||||
style={{ width: btn.size, height: btn.size, fontSize: btn.font }}
|
||||
className={[
|
||||
'rounded-full font-serif 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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);
|
||||
|
||||
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>
|
||||
|
||||
{/* Fixed height reserves the card row even when the hand is briefly
|
||||
empty (last card of a round just played, next deal not in yet) --
|
||||
otherwise this whole area collapses and the layout jumps. */}
|
||||
<div className="flex items-end justify-center" style={{ minHeight: desktop ? 100 : 84 }}>
|
||||
{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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
username?: string;
|
||||
onHistory: () => void;
|
||||
onDonate: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
/** Header navigation: a row of links from `sm:` up, a hamburger dropdown below it. */
|
||||
export default function HeaderMenu({ username, onHistory, onDonate, onLogout }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const pick = (action: () => void) => () => {
|
||||
setOpen(false);
|
||||
action();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden sm:flex items-center gap-3 text-sm">
|
||||
<span className="text-green-dim">{username}</span>
|
||||
<button onClick={onHistory} className="text-gold hover:text-gold-bright">
|
||||
História
|
||||
</button>
|
||||
<button onClick={onDonate} className="text-gold hover:text-gold-bright">
|
||||
Na kávu
|
||||
</button>
|
||||
<button onClick={onLogout} className="text-green-dim hover:text-gold">
|
||||
Odhlásiť
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={ref} className="relative sm:hidden">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label="Menu"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className="flex flex-col justify-center gap-[5px] w-9 h-9 items-center rounded-lg border border-gold/20 text-gold hover:border-gold/50 transition-colors"
|
||||
>
|
||||
<span className="block w-4 h-px bg-current" />
|
||||
<span className="block w-4 h-px bg-current" />
|
||||
<span className="block w-4 h-px bg-current" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-11 z-40 w-44 bg-header border border-[#142018] rounded-xl py-1 shadow-[0_20px_60px_rgba(0,0,0,.6)]"
|
||||
>
|
||||
{username && (
|
||||
<p className="px-4 py-2 text-xs text-green-dim border-b border-[#142018] truncate">
|
||||
{username}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={pick(onHistory)}
|
||||
className="block w-full text-left px-4 py-2.5 text-sm text-gold hover:bg-gold/10"
|
||||
>
|
||||
História
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={pick(onDonate)}
|
||||
className="block w-full text-left px-4 py-2.5 text-sm text-gold hover:bg-gold/10"
|
||||
>
|
||||
Na kávu
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={pick(onLogout)}
|
||||
className="block w-full text-left px-4 py-2.5 text-sm text-green-dim hover:bg-gold/10 hover:text-gold"
|
||||
>
|
||||
Odhlásiť
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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);
|
||||
// Bid status at a glance: terracotta once the player has taken more tricks
|
||||
// than bid (the bid is already lost), gold while exactly on target, cream
|
||||
// while still chasing. Scoring: exact match = 10 + bid, anything else = 0.
|
||||
const guessColor =
|
||||
guess === null ? '#d8cba6' : won > guess ? '#e08066' : won === guess ? '#e8c14a' : '#d8cba6';
|
||||
// Oval: height a touch shorter than size so it reads as an ellipse. Width
|
||||
// starts at `size` (a circle for short names) but grows with the name via
|
||||
// fit-content + padding, up to a cap beyond which the name is ellipsised
|
||||
// rather than wrapping (wrapping would break the fixed height/oval shape).
|
||||
const height = Math.round(size * 0.78);
|
||||
const hPad = Math.round(size * 0.16);
|
||||
const maxWidth = Math.round(size * 2);
|
||||
|
||||
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-full ${
|
||||
active ? 'bg-circle-active border-2 border-gold' : 'bg-circle border-[1.5px] border-gold/20'
|
||||
}`}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
minWidth: size,
|
||||
maxWidth,
|
||||
height,
|
||||
paddingLeft: hPad,
|
||||
paddingRight: hPad,
|
||||
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 overflow-hidden text-ellipsis whitespace-nowrap max-w-full ${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}
|
||||
{/* The bid matters as much as the tricks won — keep it nearly as big
|
||||
and in readable cream; only the slash separator stays muted. */}
|
||||
<span style={{ fontSize: valueFont * 0.7, color: '#b0a585' }}>/</span>
|
||||
<span style={{ fontSize: valueFont , color: guessColor }}>{guess ?? '?'}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 až 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 až 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState } from 'react';
|
||||
import type { PlayerInfo } from '../types';
|
||||
import { computeTotal } from '../lib/standings';
|
||||
import { displayName } from '../lib/names';
|
||||
import { STANDINGS_FONT } from '../lib/boardTheme';
|
||||
|
||||
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);
|
||||
// Finished series (8/8 rounds) collapsed to just their Σ row; toggled per series.
|
||||
const [collapsedSeries, setCollapsedSeries] = useState<Set<number>>(new Set());
|
||||
const toggleSeries = (si: number) =>
|
||||
setCollapsedSeries((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(si)) next.delete(si);
|
||||
else next.add(si);
|
||||
return next;
|
||||
});
|
||||
|
||||
// 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.
|
||||
// Sizes live in one place (boardTheme.ts) alongside the rest of the board.
|
||||
const fz = desktop ? STANDINGS_FONT.desktop : STANDINGS_FONT.mobile;
|
||||
|
||||
const gridCols = { gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` };
|
||||
|
||||
const columnHeader = (
|
||||
<div className="px-3 pt-3 flex-shrink-0">
|
||||
<div className="grid items-end mb-1" style={gridCols}>
|
||||
<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 }}
|
||||
>
|
||||
{displayName(p.name)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-px bg-gold/10" />
|
||||
</div>
|
||||
);
|
||||
|
||||
// Completed rounds, grouped by series with a per-series summary row. Finished
|
||||
// series can be collapsed to just their Σ row so long games stay scannable.
|
||||
const rows = standings.flatMap((seriesRounds, si) => {
|
||||
const priorRounds = seriesRoundOffsets[si];
|
||||
const isFinished = seriesRounds.length === ROUNDS_PER_SERIES;
|
||||
const isCollapsed = isFinished && collapsedSeries.has(si);
|
||||
const elems = isCollapsed
|
||||
? []
|
||||
: seriesRounds.map((scores, lri) => (
|
||||
<div key={`r-${si}-${lri}`} className="grid items-center py-1 border-b border-gold/[.05]" style={gridCols}>
|
||||
<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 → show the tip in the dimmer "0 points" color, no strikethrough.
|
||||
if (points === 0) {
|
||||
return (
|
||||
<div
|
||||
key={p.order}
|
||||
className="text-center font-serif leading-none"
|
||||
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. Clicking toggles
|
||||
// whether that series' individual rounds are shown.
|
||||
if (isFinished) {
|
||||
elems.push(
|
||||
<div
|
||||
key={`s-${si}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => toggleSeries(si)}
|
||||
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleSeries(si)}
|
||||
className="grid items-center py-1 my-0.5 rounded bg-gold/[.07] cursor-pointer select-none"
|
||||
style={gridCols}
|
||||
>
|
||||
<div className="text-center font-serif text-gold flex items-center justify-center gap-[2px]" style={{ fontSize: fz.sigma }}>
|
||||
<span className="text-[8px] text-green-dim">{isCollapsed ? '▸' : '▾'}</span>
|
||||
Σ{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;
|
||||
});
|
||||
|
||||
const scrollableRounds = (
|
||||
<div className={`velvet-scroll flex-1 min-h-0 overflow-y-auto px-3 ${desktop ? '' : 'max-h-[45vh]'}`}>
|
||||
{rows}
|
||||
|
||||
{/* Active round placeholder */}
|
||||
<div className="grid items-center py-1 rounded mt-0.5 bg-gold/[.04]" style={gridCols}>
|
||||
<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>
|
||||
);
|
||||
|
||||
const totalsBlock = (
|
||||
<div className="px-3 pb-4 pt-2 flex-shrink-0">
|
||||
<div className="h-px bg-gold/20 mb-2" />
|
||||
<div className="grid items-center py-0.5" style={gridCols}>
|
||||
<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>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{columnHeader}
|
||||
{scrollableRounds}
|
||||
{totalsBlock}
|
||||
</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] flex-shrink-0">
|
||||
<span className="font-serif uppercase tracking-[.12em] text-[13px] text-gold">Skóre</span>
|
||||
</div>
|
||||
{content}
|
||||
</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 && content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { PlayerInfo, StashData } from '../types';
|
||||
import CardView from './CardView';
|
||||
import { displayName } from '../lib/names';
|
||||
|
||||
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) =>
|
||||
displayName(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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Slim, on-theme scrollbar (velvet felt + gold accent) for opt-in scroll areas
|
||||
such as the in-game score list, instead of the chunky OS default. */
|
||||
.velvet-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(201, 168, 76, 0.35) transparent;
|
||||
}
|
||||
.velvet-scroll::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
.velvet-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.velvet-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(201, 168, 76, 0.28);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.velvet-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(201, 168, 76, 0.5);
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
/* A completed trick is swept off the table towards the seat that won it. */
|
||||
@keyframes collect-bottom {
|
||||
from { opacity: 1; transform: none; }
|
||||
to { opacity: 0; transform: translateY(150px) scale(0.66); }
|
||||
}
|
||||
@keyframes collect-top {
|
||||
from { opacity: 1; transform: none; }
|
||||
to { opacity: 0; transform: translateY(-150px) scale(0.66); }
|
||||
}
|
||||
@keyframes collect-left {
|
||||
from { opacity: 1; transform: none; }
|
||||
to { opacity: 0; transform: translateX(-190px) scale(0.66); }
|
||||
}
|
||||
@keyframes collect-right {
|
||||
from { opacity: 1; transform: none; }
|
||||
to { opacity: 0; transform: translateX(190px) scale(0.66); }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Single source of truth for the game board's fixed pixel sizes.
|
||||
//
|
||||
// The board deliberately uses fixed px (not rem) so it stays pixel-precise and
|
||||
// scales as one unit via `useFitScale` (desktop) — see index.css for why the
|
||||
// board opts out of the global rem type lever. These constants keep those px
|
||||
// values in one place instead of scattered as inline literals across the board
|
||||
// components (Standings / GameTable / …).
|
||||
//
|
||||
// PlayerCircle derives its own font/padding as ratios of the `size` it gets, so
|
||||
// its internal ratios stay local to that component; only the per-seat `size`
|
||||
// inputs live here.
|
||||
|
||||
/** Player-circle diameter per seat, by viewport. */
|
||||
export const SEAT_SIZE = {
|
||||
desktop: { top: 64, side: 64, me: 70 },
|
||||
mobile: { top: 52, side: 48, me: 60 },
|
||||
} as const;
|
||||
|
||||
/** Score list (Standings) type sizes. */
|
||||
export const STANDINGS_FONT = {
|
||||
desktop: { head: 12, idx: 13, cell: 18, dot: 18, sigma: 18, total: 20 },
|
||||
mobile: { head: 11, idx: 9, cell: 16, dot: 13, sigma: 15, total: 18 },
|
||||
} as const;
|
||||
|
||||
/** Header / score totals row (GameTable). The value size differs between the
|
||||
* compact desktop header and the mobile header. */
|
||||
export const TOTALS_FONT = {
|
||||
label: 12,
|
||||
valueDesktop: 18,
|
||||
valueMobile: 17,
|
||||
} as const;
|
||||
|
||||
/** Guess (tip) picker buttons (GuessControls): round button diameter + label
|
||||
* size, in px — kept fixed like the rest of the board (not rem Tailwind classes)
|
||||
* so they don't drift with the global font-size lever. */
|
||||
export const GUESS_BUTTON = {
|
||||
desktop: { size: 48, font: 22 },
|
||||
mobile: { size: 40, font: 18 },
|
||||
} as const;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { create } from 'zustand';
|
||||
import type { CardColor } from '../types';
|
||||
|
||||
// Two ways to draw the suit marks on the (otherwise identical) cards: plain
|
||||
// ♥♠♣♦ text glyphs ('classic') or the "sedmové" suit-icon artwork ('illustrated').
|
||||
export type CardSkin = 'classic' | 'illustrated';
|
||||
|
||||
const STORAGE_KEY = 'bridzik.cardSkin';
|
||||
|
||||
function loadSkin(): CardSkin {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'illustrated' ? 'illustrated' : 'classic';
|
||||
} catch {
|
||||
return 'classic';
|
||||
}
|
||||
}
|
||||
|
||||
interface CardSkinStore {
|
||||
skin: CardSkin;
|
||||
setSkin: (skin: CardSkin) => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export const useCardSkin = create<CardSkinStore>((set) => ({
|
||||
skin: loadSkin(),
|
||||
setSkin: (skin) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, skin);
|
||||
} catch {
|
||||
/* ignore — a private-mode failure just means the choice isn't persisted */
|
||||
}
|
||||
set({ skin });
|
||||
},
|
||||
toggle: () => set((s) => {
|
||||
const skin: CardSkin = s.skin === 'illustrated' ? 'classic' : 'illustrated';
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, skin);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { skin };
|
||||
}),
|
||||
}));
|
||||
|
||||
// Filenames in /public/cards use their own suit/value words, not the engine's
|
||||
// enum names — map between the two here.
|
||||
const SUIT_FILE: Record<CardColor, string> = {
|
||||
HEARTS: 'heart',
|
||||
LEAVES: 'leaf',
|
||||
ACORNS: 'acorn',
|
||||
BELLS: 'bell',
|
||||
};
|
||||
|
||||
/** Path to a suit icon at a given resolution, e.g. `/cards/suit-icons/heart-icon@small.png`.
|
||||
* Used to render the suit marks on the classic vector cards. */
|
||||
export function suitIcon(color: CardColor, size: 'small' | 'medium' | 'large'): string {
|
||||
return `/cards/suit-icons/${SUIT_FILE[color]}-icon@${size}.png`;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { CardColor, CardValue, Hand, StashData } 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);
|
||||
}
|
||||
|
||||
const VALUE_ORDER: CardValue[] = ['C7', 'C8', 'C9', 'C10', 'LOWER', 'UPPER', 'KING', 'ACE'];
|
||||
|
||||
/** Seat that wins a completed 4-card trick — mirrors Stash.get_winner in the
|
||||
* engine: HEARTS (červeň) is the permanent trump, otherwise the highest card
|
||||
* of the led colour wins. Safe to call on a partial stash (returns the current
|
||||
* leader among the cards played so far). */
|
||||
export function stashWinner(stash: StashData): number {
|
||||
const led = stash.cards[String(stash.first_player)];
|
||||
if (!led) return stash.first_player;
|
||||
let winner = stash.first_player;
|
||||
let best = led;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const c = stash.cards[String(i)];
|
||||
if (!c) continue;
|
||||
if (c.color === led.color || c.color === 'HEARTS') {
|
||||
if (c.color === best.color) {
|
||||
if (VALUE_ORDER.indexOf(c.value) >= VALUE_ORDER.indexOf(best.value)) {
|
||||
best = c;
|
||||
winner = i;
|
||||
}
|
||||
} else if (c.color === 'HEARTS') {
|
||||
best = c;
|
||||
winner = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return winner;
|
||||
}
|
||||
|
||||
/** The bid the last guesser may not make: the four bids must not sum to the
|
||||
* number of tricks in the round (mirrors Round.add_player_guess in the engine).
|
||||
* Returns null while earlier players are still guessing. */
|
||||
export function forbiddenGuess(cardsInRound: number, guesses: Record<string, number>): number | null {
|
||||
if (Object.keys(guesses).length !== 3) return null;
|
||||
const alreadySum = Object.values(guesses).reduce((a, b) => a + b, 0);
|
||||
return cardsInRound - alreadySum;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Display helpers for player names. Bot accounts follow the server-side
|
||||
* convention "bot:<kind>-<n>" (see api/bots.py) — render them as a short
|
||||
* friendly label instead of the raw username. */
|
||||
|
||||
const BOT_PREFIX = 'bot:';
|
||||
|
||||
export function isBotName(name?: string | null): boolean {
|
||||
return !!name && name.startsWith(BOT_PREFIX);
|
||||
}
|
||||
|
||||
/** "bot:heuristic-2" -> "Bot 2", "bot:neural-1" -> "AI bot 1"; other kinds
|
||||
* keep a suffix ("Bot 1 (random)"); non-bot names pass through unchanged. */
|
||||
export function displayName(name?: string | null): string {
|
||||
if (!name || !isBotName(name)) return name ?? '';
|
||||
const body = name.slice(BOT_PREFIX.length);
|
||||
const dash = body.lastIndexOf('-');
|
||||
const kind = dash > 0 ? body.slice(0, dash) : body;
|
||||
const num = dash > 0 ? body.slice(dash + 1) : '';
|
||||
if (kind === 'neural') return num ? `AI bot ${num}` : 'AI bot';
|
||||
const label = num ? `Bot ${num}` : 'Bot';
|
||||
return kind === 'heuristic' ? label : `${label} (${kind})`;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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'),
|
||||
// Bots: host-only, before the game starts (seat picked by the server).
|
||||
// kind: 'heuristic' (default) | 'neural' (trained net) | 'random'
|
||||
addBot: (gid: string, kind: string = 'heuristic') => socket.emit('add_bot', gid, kind),
|
||||
removeBot: (gid: string, order: number) => socket.emit('remove_bot', gid, order),
|
||||
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) => {
|
||||
// `active` = klient sa automaticky pokusi znova (typicky prebudeny mobil,
|
||||
// kym sa siet zobudi) -- prechodne, netreba strasit toastom.
|
||||
if (socket.active) return;
|
||||
useGameStore.getState().setError(`Spojenie so serverom zlyhalo: ${err.message}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function computeTotal(standings: number[][][], playerOrder: number): number {
|
||||
return standings.flat().reduce((sum, round) => sum + (round[playerOrder] ?? 0), 0);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Fire-and-forget event/pageview beacon for self-hosted analytics; must
|
||||
* never affect the app (network errors are swallowed). player_id is only
|
||||
* ever attached server-side to the "login" event -- ordinary beacons don't
|
||||
* carry it. */
|
||||
export function trackEvent(path: string, referrer = '') {
|
||||
fetch('/api/track', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, referrer }),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/** One "landing" event per full page load — the visitor's first touch,
|
||||
* carrying document.referrer (e.g. facebook.com). Without it a visit that
|
||||
* starts on "/" leaves no trace at all: "/" is in the beacon's skip list and
|
||||
* the auth-gate redirect to /auth is a REPLACE navigation (see App.tsx), so
|
||||
* both hops are skipped. Not sent on /admin — the dashboard doesn't track
|
||||
* itself. */
|
||||
export function trackLanding() {
|
||||
if (window.location.pathname.startsWith('/admin')) return;
|
||||
trackEvent('landing', document.referrer);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const QUERY = '(min-width: 1024px)';
|
||||
|
||||
/** True on viewports >= 1024px — drives the desktop GameTable layout
|
||||
* (score sidebar, larger oval, bigger cards). */
|
||||
export function useIsDesktop(): boolean {
|
||||
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);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
return isDesktop;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import { setupSocketListeners, socket, setAuthToken } from './lib/socket';
|
||||
import { trackLanding } from './lib/track';
|
||||
|
||||
setupSocketListeners();
|
||||
// Carry the stored session token into the handshake so the server auto-logs us in.
|
||||
setAuthToken(localStorage.getItem('bridzik_token'));
|
||||
socket.connect();
|
||||
// Here (not in a component effect): runs exactly once per full page load,
|
||||
// unaffected by StrictMode double-mounting.
|
||||
trackLanding();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { emit } from '../lib/socket';
|
||||
import { trackEvent } from '../lib/track';
|
||||
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());
|
||||
|
||||
// Server can push a registration payload even from the login tab (unfinished
|
||||
// registration → re-issued QR) — always land on the tab that shows it.
|
||||
useEffect(() => {
|
||||
if (registration) {
|
||||
setMode('register');
|
||||
setCode('');
|
||||
}
|
||||
}, [registration]);
|
||||
|
||||
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);
|
||||
trackEvent('register');
|
||||
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);
|
||||
};
|
||||
|
||||
const handleShowRules = () => {
|
||||
setShowRules(true);
|
||||
trackEvent('rules_view');
|
||||
};
|
||||
|
||||
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={handleShowRules}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { encode, PaymentOptions, CurrencyCode } from 'bysquare/pay';
|
||||
|
||||
const IBAN = 'SK2583300000002803542822';
|
||||
const AMOUNTS = [1, 2, 5];
|
||||
const PAYMENT_NOTE = 'Bridzik';
|
||||
const BENEFICIARY = 'Liptak Timotej';
|
||||
|
||||
export default function Donate() {
|
||||
const navigate = useNavigate();
|
||||
const [amount, setAmount] = useState(2);
|
||||
const [custom, setCustom] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Vlastna suma ma prednost pred presetom; akceptuje ciarku aj bodku.
|
||||
const customParsed = parseFloat(custom.replace(',', '.'));
|
||||
const customValid = custom !== '' && !isNaN(customParsed) && customParsed > 0;
|
||||
const effectiveAmount = customValid ? Math.round(customParsed * 100) / 100 : amount;
|
||||
|
||||
const qrValue = useMemo(
|
||||
() =>
|
||||
encode({
|
||||
payments: [
|
||||
{
|
||||
type: PaymentOptions.PaymentOrder,
|
||||
amount: effectiveAmount,
|
||||
currencyCode: CurrencyCode.EUR,
|
||||
paymentNote: PAYMENT_NOTE,
|
||||
beneficiary: { name: BENEFICIARY },
|
||||
bankAccounts: [{ iban: IBAN }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[effectiveAmount],
|
||||
);
|
||||
|
||||
const copyIban = async () => {
|
||||
await navigator.clipboard.writeText(IBAN);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
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">Podpor Bridžik ☕</h1>
|
||||
<button onClick={() => navigate('/')} className="text-sm text-green-dim hover:text-gold">
|
||||
Späť
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-header border border-[#142018] rounded-2xl p-6 text-sm text-green-score">
|
||||
<p className="text-green-dim mb-4">
|
||||
Bridžik je zadarmo. Ak sa ti hra páči, dobrovoľný príspevok pomôže s prevádzkou servera a rozvojom nových funkcionalít.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 mb-2">
|
||||
{AMOUNTS.map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => {
|
||||
setAmount(a);
|
||||
setCustom('');
|
||||
}}
|
||||
className={`flex-1 py-1.5 rounded-lg font-serif font-semibold transition-colors ${
|
||||
!customValid && amount === a
|
||||
? 'bg-gold text-table'
|
||||
: 'border border-gold/40 text-gold hover:bg-gold/10'
|
||||
}`}
|
||||
>
|
||||
{a} €
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-1.5 mb-4 transition-colors ${
|
||||
customValid ? 'border-gold bg-gold/10' : 'border-gold/40'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="Vlastná suma"
|
||||
value={custom}
|
||||
onChange={(e) => setCustom(e.target.value)}
|
||||
className="flex-1 bg-transparent outline-none text-gold placeholder-green-dim font-serif font-semibold"
|
||||
/>
|
||||
<span className="text-gold font-serif font-semibold">€</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 flex justify-center mb-2">
|
||||
<QRCodeSVG value={qrValue} size={176} />
|
||||
</div>
|
||||
<p className="text-center text-xs text-green-dim mb-4">
|
||||
Naskenuj QR kód bankovou aplikáciou (PAY by square)
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border border-gold/20 rounded-lg px-3 py-2">
|
||||
<span className="font-mono text-xs break-all">{IBAN}</span>
|
||||
<button
|
||||
onClick={copyIban}
|
||||
className="shrink-0 text-gold hover:text-gold-bright text-xs font-semibold"
|
||||
>
|
||||
{copied ? 'Skopírované ✓' : 'Kopírovať'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { emit, socket, setAuthToken } from '../lib/socket';
|
||||
import { trackEvent } from '../lib/track';
|
||||
import HeaderMenu from '../components/HeaderMenu';
|
||||
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 handleShowRules = () => {
|
||||
setShowRules(true);
|
||||
trackEvent('rules_view');
|
||||
};
|
||||
|
||||
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>
|
||||
<HeaderMenu
|
||||
username={account?.username}
|
||||
onHistory={() => navigate('/history')}
|
||||
onDonate={() => navigate('/donate')}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</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={handleShowRules}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { PlayerInfo } from '../types';
|
||||
import { computeTotal } from '../lib/standings';
|
||||
import { leaveGame } from '../lib/leaveGame';
|
||||
import { displayName } from '../lib/names';
|
||||
|
||||
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">{displayName(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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
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, stashWinner } from '../lib/gameRules';
|
||||
import { computeTotal } from '../lib/standings';
|
||||
import { displayName } from '../lib/names';
|
||||
import { useIsDesktop } from '../lib/useIsDesktop';
|
||||
import { useFitScale } from '../lib/useFitScale';
|
||||
import { SEAT_SIZE, TOTALS_FONT } from '../lib/boardTheme';
|
||||
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 CardSkinToggle from '../components/CardSkinToggle';
|
||||
import GameOver from './GameOver';
|
||||
import type { Hand as HandCards, PlayerInfo, StashData } from '../types';
|
||||
|
||||
// A completed trick stays face-up for SETTLE_MS (so the last card visibly joins
|
||||
// the pile), then is swept towards the winner over COLLECT_MS.
|
||||
const SETTLE_MS = 1100;
|
||||
const COLLECT_MS = 550;
|
||||
// Sweep direction by the winner's seat offset from me: 0=me(bottom) 1=left 2=top 3=right.
|
||||
const COLLECT_BY_OFFSET = ['collect-bottom', 'collect-left', 'collect-top', 'collect-right'];
|
||||
|
||||
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);
|
||||
|
||||
// Once a completed trick has been swept away, its key is remembered here so it
|
||||
// is not shown again while we wait for the winner to lead the next trick.
|
||||
const [dismissedKey, setDismissedKey] = useState<string | null>(null);
|
||||
// Turns on for the collect (fly-to-winner) phase, after the settle pause.
|
||||
const [collecting, setCollecting] = useState(false);
|
||||
|
||||
const previousStash = gameStatus?.status.previous_stash ?? null;
|
||||
// Every game_status payload recreates the stash object, so identify the trick
|
||||
// by content — the timers must restart only when a *different* trick completes.
|
||||
const previousStashKey = previousStash
|
||||
? `${previousStash.first_player}:${JSON.stringify(previousStash.cards)}`
|
||||
: null;
|
||||
|
||||
// On first load of an already-running game (reconnect / restore-on-startup) a
|
||||
// completed trick is already present; adopt it as "already swept" so we don't
|
||||
// replay a stale sweep over the live board. A freshly started game has no
|
||||
// completed trick at this point, so its very first trick still animates.
|
||||
const booted = useRef(false);
|
||||
useEffect(() => {
|
||||
if (booted.current || !gameStatus) return;
|
||||
booted.current = true;
|
||||
if (previousStashKey) setDismissedKey(previousStashKey);
|
||||
}, [gameStatus, previousStashKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previousStashKey) return;
|
||||
setCollecting(false);
|
||||
const settle = setTimeout(() => setCollecting(true), SETTLE_MS);
|
||||
const done = setTimeout(() => {
|
||||
setCollecting(false);
|
||||
setDismissedKey(previousStashKey);
|
||||
}, SETTLE_MS + COLLECT_MS);
|
||||
return () => {
|
||||
clearTimeout(settle);
|
||||
clearTimeout(done);
|
||||
};
|
||||
}, [previousStashKey]);
|
||||
|
||||
// A just-completed trick that hasn't been swept away yet always wins the centre
|
||||
// — even once the winner has already led the next trick. The engine reveals that
|
||||
// lead card (and, at a round boundary, the next bidding phase) the instant the
|
||||
// 4th card lands, so without holding the pile here the sweep would be cut off
|
||||
// after every trick. Reading `previousStash` synchronously (rather than a state
|
||||
// set in an effect) also means the pile never blinks to empty on the frame the
|
||||
// 4th card lands — the last card simply joins the three already there, then the
|
||||
// whole pile is collected before the next trick takes over.
|
||||
const finishing = previousStashKey !== null && previousStashKey !== dismissedKey;
|
||||
|
||||
// The new round's dealt hand arrives (via `player_cards`) the instant the last
|
||||
// trick of the previous round is played, but the centre oval is still sweeping
|
||||
// that trick away — hold an empty hand on screen (the previous round's last card
|
||||
// really was just played, the engine just never broadcasts that "0 cards" beat on
|
||||
// its own since it deals the new round in the same step) until the sweep finishes,
|
||||
// so the new cards don't appear before the previous round has visibly wrapped up.
|
||||
// Mid-round plays (round number unchanged) still update instantly, since that's
|
||||
// just the player's own card leaving their hand, not a fresh deal.
|
||||
const [displayedHand, setDisplayedHand] = useState<HandCards>(hand);
|
||||
const lastAppliedRoundRef = useRef<number | null>(gameStatus?.round_number ?? null);
|
||||
useEffect(() => {
|
||||
if (!gameStatus) return;
|
||||
const isNewRound = gameStatus.round_number !== lastAppliedRoundRef.current;
|
||||
if (isNewRound && finishing) {
|
||||
setDisplayedHand({}); // last card of the previous round is gone; new deal waits
|
||||
return;
|
||||
}
|
||||
lastAppliedRoundRef.current = gameStatus.round_number;
|
||||
setDisplayedHand(hand);
|
||||
}, [hand, gameStatus, finishing]);
|
||||
|
||||
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 = finishing
|
||||
? previousStash
|
||||
: activeCards > 0 && active_stash
|
||||
? active_stash
|
||||
: null;
|
||||
|
||||
// During the collect phase, sweep the pile towards whoever won it.
|
||||
const collectAnim =
|
||||
collecting && finishing && previousStash
|
||||
? COLLECT_BY_OFFSET[(stashWinner(previousStash) - myOrder + 4) % 4]
|
||||
: null;
|
||||
|
||||
// Block play until the previous trick's sweep animation has finished — otherwise
|
||||
// the winner could lead the next card while the pile is still visibly clearing.
|
||||
const canPlayNow = myTurnToPlay && !finishing;
|
||||
|
||||
const playableKeys = canPlayNow && active_stash
|
||||
? computePlayable(displayedHand, active_stash.cards[String(active_stash.first_player)]?.color ?? null)
|
||||
: undefined;
|
||||
|
||||
const activePlayerName = displayName(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);
|
||||
|
||||
// Live round state of one seat, shaped as PlayerCircle props.
|
||||
const seatProps = (o?: number) => ({
|
||||
won: o === undefined ? 0 : active_round_stashes?.[o] ?? 0,
|
||||
guess: (o === undefined ? null : active_round_guesses?.[String(o)] ?? null) as number | null,
|
||||
active: 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 = active_player === 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: TOTALS_FONT.label }}>
|
||||
{displayName(p.name)}
|
||||
</div>
|
||||
<div className="font-serif text-green-score leading-none" style={{ fontSize: compact ? TOTALS_FONT.valueDesktop : TOTALS_FONT.valueMobile }}>
|
||||
{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: TOTALS_FONT.label }}>
|
||||
{myPlayer.name}
|
||||
</div>
|
||||
<div className="font-serif font-semibold text-gold-dim leading-none" style={{ fontSize: compact ? TOTALS_FONT.valueDesktop : TOTALS_FONT.valueMobile }}>
|
||||
{computeTotal(standings, myOrder)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Center of the oval: trick during play, guess controls during bidding.
|
||||
// `finishing` also keeps the trick on screen while the round's *last* stash is
|
||||
// swept away: the engine advances to the next round's bidding the instant the
|
||||
// 4th card lands, so `isPlayPhase` flips to false immediately — without this,
|
||||
// that final trick would vanish straight into the guess controls with no sweep.
|
||||
const ovalContent = isPlayPhase || finishing ? (
|
||||
<div style={collectAnim ? { animation: `${collectAnim} ${COLLECT_MS}ms ease-in both` } : undefined}>
|
||||
<Trick stash={displayedStash} players={players} myOrder={myOrder} />
|
||||
</div>
|
||||
) : (
|
||||
active_round_guesses !== undefined && active_player !== undefined ? (
|
||||
<GuessControls
|
||||
cardsInRound={cards_in_round}
|
||||
guesses={active_round_guesses}
|
||||
myOrder={myOrder}
|
||||
activePlayer={active_player}
|
||||
activePlayerName={activePlayerName}
|
||||
desktop={desktop}
|
||||
/>
|
||||
) : null
|
||||
);
|
||||
|
||||
const seatSize = desktop ? SEAT_SIZE.desktop : SEAT_SIZE.mobile;
|
||||
|
||||
const topSeat = (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<PlayerCircle name={displayName(topP?.name) || '—'} {...seatProps(topP?.order)} size={seatSize.top} />
|
||||
<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={displayName(p?.name) || '—'} {...seatProps(p?.order)} size={seatSize.side} />
|
||||
<FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const meSeat = (
|
||||
<div className="flex justify-center">
|
||||
<PlayerCircle name={myPlayer.name} {...seatProps(myOrder)} size={seatSize.me} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const handArea = (
|
||||
<Hand hand={displayedHand} myTurn={canPlayNow} 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-[18px] text-gold whitespace-nowrap">
|
||||
Bridžik
|
||||
</span>
|
||||
<div className="w-px h-[22px] bg-[#1a3a22]" />
|
||||
<span className="font-serif text-[14px] 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]" />
|
||||
<CardSkinToggle />
|
||||
{canEnd && (
|
||||
<button onClick={handleEnd} className="text-[13px] text-[#8a8064] hover:text-gold whitespace-nowrap">
|
||||
Ukončiť
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleLeave} className="text-[13px] 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-[12px] text-gold">
|
||||
Séria {series_number + 1} · Kolo {round_number + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<CardSkinToggle />
|
||||
{canEnd && (
|
||||
<button onClick={handleEnd} className="text-[13px] text-[#6a3030] hover:text-red-400">
|
||||
Ukončiť
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleLeave} className="text-[13px] 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
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 { displayName } from '../lib/names';
|
||||
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.map((n) => displayName(n)).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" 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 }}>
|
||||
{displayName(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;
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { emit } from '../lib/socket';
|
||||
import { leaveGame } from '../lib/leaveGame';
|
||||
import { displayName } from '../lib/names';
|
||||
|
||||
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 [showCopied, setShowCopied] = useState(false);
|
||||
const copiedTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
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 = () => leaveGame(navigate);
|
||||
|
||||
const handleCopyCode = () => {
|
||||
if (!gid) return;
|
||||
navigator.clipboard.writeText(`${window.location.origin}/lobby/${gid}`);
|
||||
setShowCopied(true);
|
||||
clearTimeout(copiedTimeout.current);
|
||||
copiedTimeout.current = setTimeout(() => setShowCopied(false), 1800);
|
||||
};
|
||||
|
||||
useEffect(() => () => clearTimeout(copiedTimeout.current), []);
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
|
||||
<div
|
||||
className={`fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg bg-header border border-gold/30 text-gold text-sm shadow-lg transition-opacity duration-300 z-50 ${
|
||||
showCopied ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
Odkaz na hru skopírovaný
|
||||
</div>
|
||||
|
||||
<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ť URL
|
||||
</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 ? (p.is_bot ? '⚙' : '✦') : '○'}
|
||||
</span>
|
||||
<span className={p ? 'font-serif text-green-score' : 'text-green-dim italic'}>
|
||||
{p ? `${displayName(p.name)}${myPlayer?.order === p.order ? ' (ty)' : ''}` : 'Čaká sa…'}
|
||||
</span>
|
||||
{isHost && p?.is_bot && (
|
||||
<button
|
||||
onClick={() => gid && emit.removeBot(gid, order)}
|
||||
className="ml-auto text-xs text-green-dim hover:text-gold"
|
||||
>
|
||||
Odobrať
|
||||
</button>
|
||||
)}
|
||||
{isHost && !p && (
|
||||
<span className="ml-auto flex gap-2">
|
||||
<button
|
||||
onClick={() => gid && emit.addBot(gid)}
|
||||
className="px-2 py-0.5 rounded-lg text-xs border border-gold/30 text-gold hover:bg-gold hover:text-table transition-colors"
|
||||
>
|
||||
+ Bot
|
||||
</button>
|
||||
<button
|
||||
onClick={() => gid && emit.addBot(gid, 'neural')}
|
||||
className="px-2 py-0.5 rounded-lg text-xs border border-gold/30 text-gold hover:bg-gold hover:text-table transition-colors"
|
||||
>
|
||||
+ AI bot
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
|
||||
const TOKEN_KEY = 'bridzik_admin_token';
|
||||
|
||||
const AdminTokenContext = createContext<string>('');
|
||||
|
||||
/** Admin token, read from the input/sessionStorage owned by AdminLayout.
|
||||
* Kept separate from the player login -- /admin is gated by ADMIN_TOKEN only. */
|
||||
export function useAdminToken(): string {
|
||||
return useContext(AdminTokenContext);
|
||||
}
|
||||
|
||||
export default function AdminLayout() {
|
||||
const [token, setToken] = useState(() => sessionStorage.getItem(TOKEN_KEY) ?? '');
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="max-w-sm mx-auto p-4 pt-24 min-h-screen">
|
||||
<h1 className="font-serif text-2xl text-gold mb-4">Admin</h1>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!draft.trim()) return;
|
||||
sessionStorage.setItem(TOKEN_KEY, draft.trim());
|
||||
setToken(draft.trim());
|
||||
}}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Admin token"
|
||||
className="bg-header border border-[#142018] rounded-lg px-3 py-2 text-green-score placeholder:text-green-dim outline-none focus:border-gold"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 rounded-lg font-serif font-semibold bg-gold text-table hover:bg-gold-bright transition-colors"
|
||||
>
|
||||
Vstup
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminTokenContext.Provider value={token}>
|
||||
<div className="max-w-4xl 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">Admin</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
setToken('');
|
||||
}}
|
||||
className="text-sm text-green-dim hover:text-gold"
|
||||
>
|
||||
Odhlásiť
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex gap-4 mb-6 border-b border-gold/[.14] pb-2">
|
||||
<NavLink
|
||||
to="stats"
|
||||
className={({ isActive }) =>
|
||||
`text-sm ${isActive ? 'text-gold' : 'text-green-dim hover:text-gold'}`
|
||||
}
|
||||
>
|
||||
Štatistiky
|
||||
</NavLink>
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
</AdminTokenContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { useIsDesktop } from '../../lib/useIsDesktop';
|
||||
import { useAdminToken } from './AdminLayout';
|
||||
import PageviewsChart from './PageviewsChart';
|
||||
|
||||
interface DailyStats {
|
||||
games_per_day: Record<string, number>;
|
||||
players_per_day: Record<string, number>;
|
||||
finished_bot_games: number;
|
||||
finished_human_games: number;
|
||||
avg_game_duration_minutes: number | null;
|
||||
total_players: number;
|
||||
unconfirmed_players: number;
|
||||
peak_hours: Record<string, number>;
|
||||
rounds_per_day: Record<string, number>;
|
||||
pageviews_per_day: Record<string, number>;
|
||||
visitors_per_day: Record<string, number>;
|
||||
pageviews_per_day_by_device: Record<string, Record<string, number>>;
|
||||
pageviews_per_day_by_browser: Record<string, Record<string, number>>;
|
||||
pageviews_per_day_by_os: Record<string, Record<string, number>>;
|
||||
top_referrers: Record<string, number>;
|
||||
top_paths: Record<string, number>;
|
||||
browsers: Record<string, number>;
|
||||
operating_systems: Record<string, number>;
|
||||
device_types: Record<string, number>;
|
||||
countries: Record<string, number>;
|
||||
}
|
||||
|
||||
function toSeries(byDay: Record<string, number>) {
|
||||
return Object.entries(byDay)
|
||||
.map(([day, n]) => ({ day, n }))
|
||||
.sort((a, b) => a.day.localeCompare(b.day));
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-header border border-[#142018] rounded-xl px-4 py-3 flex-1 min-w-[140px]">
|
||||
<p className="text-xs text-green-dim uppercase tracking-wide">{label}</p>
|
||||
<p className="font-serif text-xl text-gold mt-1">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeSeriesChart({ title, data, kind = 'line' }: { title: string; data: Record<string, number>; kind?: 'line' | 'bar' }) {
|
||||
const series = toSeries(data);
|
||||
return (
|
||||
<div className="bg-header border border-[#142018] rounded-xl p-4">
|
||||
<p className="text-sm text-gold mb-2">{title}</p>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
{kind === 'line' ? (
|
||||
<LineChart data={series}>
|
||||
<CartesianGrid stroke="#142018" />
|
||||
<XAxis dataKey="day" tick={{ fill: '#9c906c', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ background: '#070c09', border: '1px solid #142018' }} />
|
||||
<Line type="monotone" dataKey="n" stroke="#c9a84c" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={series}>
|
||||
<CartesianGrid stroke="#142018" />
|
||||
<XAxis dataKey="day" tick={{ fill: '#9c906c', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ background: '#070c09', border: '1px solid #142018' }} />
|
||||
<Bar dataKey="n" fill="#c9a84c" />
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakdownTable({ title, data }: { title: string; data: Record<string, number> }) {
|
||||
const rows = Object.entries(data).sort((a, b) => b[1] - a[1]);
|
||||
return (
|
||||
<div className="bg-header border border-[#142018] rounded-xl p-4">
|
||||
<p className="text-sm text-gold mb-2">{title}</p>
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-xs text-green-dim">Žiadne dáta.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{rows.map(([key, n]) => (
|
||||
<tr key={key} className="border-b border-gold/[.06] last:border-0">
|
||||
<td className="py-1 text-green-score truncate">{key || '—'}</td>
|
||||
<td className="py-1 text-right text-gold-dim">{n}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Scope = 'all' | 'logged_in';
|
||||
|
||||
function ScopeToggle({ scope, onChange }: { scope: Scope; onChange: (s: Scope) => void }) {
|
||||
return (
|
||||
<div className="flex rounded-lg overflow-hidden border border-gold/20 self-start">
|
||||
{(['all', 'logged_in'] as Scope[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onChange(s)}
|
||||
className={`px-3 py-1.5 text-xs font-serif tracking-wide transition-colors ${
|
||||
scope === s ? 'bg-gold text-table' : 'bg-header text-green-dim hover:text-gold'
|
||||
}`}
|
||||
>
|
||||
{s === 'all' ? 'Všetci' : 'Prihlásení'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminStats() {
|
||||
const token = useAdminToken();
|
||||
const desktop = useIsDesktop();
|
||||
const [data, setData] = useState<DailyStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [scope, setScope] = useState<Scope>('all');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const qs = scope === 'logged_in' ? '?logged_in=1' : '';
|
||||
fetch(`/api/admin/stats${qs}`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then((res) => {
|
||||
if (res.status === 403) throw new Error('Neplatný token.');
|
||||
if (!res.ok) throw new Error('Chyba pri načítaní štatistík.');
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
if (!cancelled) setData(json);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(e.message);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, scope]);
|
||||
|
||||
if (error) return <p className="text-red-400 text-sm">{error}</p>;
|
||||
if (!data) return <p className="text-green-dim text-sm">Načítavam...</p>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className={`flex gap-3 ${desktop ? '' : 'flex-wrap'}`}>
|
||||
<SummaryCard label="Hráči celkom" value={String(data.total_players)} />
|
||||
<SummaryCard label="Nedokončené registrácie" value={String(data.unconfirmed_players)} />
|
||||
<SummaryCard label="Dokončené hry s ľuďmi" value={String(data.finished_human_games)} />
|
||||
<SummaryCard label="Dokončené hry s botmi" value={String(data.finished_bot_games)} />
|
||||
<SummaryCard
|
||||
label="Priem. dĺžka hry"
|
||||
value={data.avg_game_duration_minutes != null ? `${Math.round(data.avg_game_duration_minutes)} min` : '—'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`grid gap-4 ${desktop ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
<TimeSeriesChart title="Hry za deň" data={data.games_per_day} />
|
||||
<TimeSeriesChart title="Nové registrácie za deň" data={data.players_per_day} />
|
||||
<TimeSeriesChart title="Odohrané kolá za deň" data={data.rounds_per_day} />
|
||||
<TimeSeriesChart title="Hodiny s najvyššou aktivitou" data={data.peak_hours} kind="bar" />
|
||||
</div>
|
||||
|
||||
{/* Prepinac sa tyka len traffic-analytickych widgetov nizsie (PageView) --
|
||||
herne metriky vyssie su uz vzdy o prihlasenych uctoch. */}
|
||||
<ScopeToggle scope={scope} onChange={setScope} />
|
||||
|
||||
<PageviewsChart
|
||||
total={data.pageviews_per_day}
|
||||
totalLabel={scope === 'logged_in' ? 'Prihlásení' : 'Zobrazenia'}
|
||||
visitors={data.visitors_per_day}
|
||||
visitorsLabel={scope === 'logged_in' ? 'Unikátní' : 'Návštevníci'}
|
||||
byDevice={data.pageviews_per_day_by_device}
|
||||
byBrowser={data.pageviews_per_day_by_browser}
|
||||
byOs={data.pageviews_per_day_by_os}
|
||||
/>
|
||||
|
||||
<div className={`grid gap-4 ${desktop ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
<BreakdownTable title="Najnavštevovanejšie stránky" data={data.top_paths} />
|
||||
<BreakdownTable title="Zdroje návštevnosti" data={data.top_referrers} />
|
||||
<BreakdownTable title="Prehliadače" data={data.browsers} />
|
||||
<BreakdownTable title="Operačné systémy" data={data.operating_systems} />
|
||||
<BreakdownTable title="Typ zariadenia" data={data.device_types} />
|
||||
<BreakdownTable title="Krajiny" data={data.countries} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
type Dimension = 'total' | 'device' | 'browser' | 'os';
|
||||
|
||||
const DIMENSION_LABELS: Record<Dimension, string> = {
|
||||
total: 'Spolu',
|
||||
device: 'Zariadenie',
|
||||
browser: 'Prehliadač',
|
||||
os: 'OS',
|
||||
};
|
||||
|
||||
// Cycled per category line -- theme golds/creams, enough spread to stay
|
||||
// distinguishable across the handful of browsers/OSes/device types we expect.
|
||||
const PALETTE = ['#c9a84c', '#d8cba6', '#9c906c', '#f0d060', '#c2b58c', '#8a8064', '#7a6e4a'];
|
||||
|
||||
interface Props {
|
||||
total: Record<string, number>;
|
||||
totalLabel?: string;
|
||||
visitors: Record<string, number>;
|
||||
visitorsLabel?: string;
|
||||
byDevice: Record<string, Record<string, number>>;
|
||||
byBrowser: Record<string, Record<string, number>>;
|
||||
byOs: Record<string, Record<string, number>>;
|
||||
}
|
||||
|
||||
export default function PageviewsChart({
|
||||
total,
|
||||
totalLabel = 'Zobrazenia',
|
||||
visitors,
|
||||
visitorsLabel = 'Návštevníci',
|
||||
byDevice,
|
||||
byBrowser,
|
||||
byOs,
|
||||
}: Props) {
|
||||
const [dimension, setDimension] = useState<Dimension>('total');
|
||||
|
||||
const { rows, categories } = useMemo(() => {
|
||||
if (dimension === 'total') {
|
||||
const days = [...new Set([...Object.keys(total), ...Object.keys(visitors)])].sort();
|
||||
return {
|
||||
rows: days.map((day) => ({ day, n: total[day] ?? 0, visitors: visitors[day] ?? 0 })),
|
||||
categories: ['n', 'visitors'],
|
||||
};
|
||||
}
|
||||
const byDay = dimension === 'device' ? byDevice : dimension === 'browser' ? byBrowser : byOs;
|
||||
const days = Object.keys(byDay).sort();
|
||||
const categories = [...new Set(days.flatMap((d) => Object.keys(byDay[d])))].sort();
|
||||
const rows = days.map((day) => {
|
||||
const row: Record<string, number | string> = { day };
|
||||
for (const c of categories) row[c] = byDay[day][c] ?? 0;
|
||||
return row;
|
||||
});
|
||||
return { rows, categories };
|
||||
}, [dimension, total, visitors, byDevice, byBrowser, byOs]);
|
||||
|
||||
const lineName = (c: string) => (c === 'n' ? totalLabel : c === 'visitors' ? visitorsLabel : c);
|
||||
|
||||
return (
|
||||
<div className="bg-header border border-[#142018] rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
|
||||
<p className="text-sm text-gold">Návštevy za deň</p>
|
||||
<div className="flex gap-1">
|
||||
{(Object.keys(DIMENSION_LABELS) as Dimension[]).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDimension(d)}
|
||||
className={`px-2 py-1 rounded text-xs transition-colors ${
|
||||
dimension === d ? 'bg-gold text-table' : 'text-green-dim hover:text-gold'
|
||||
}`}
|
||||
>
|
||||
{DIMENSION_LABELS[d]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={rows}>
|
||||
<CartesianGrid stroke="#142018" />
|
||||
<XAxis dataKey="day" tick={{ fill: '#9c906c', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ background: '#070c09', border: '1px solid #142018' }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, color: '#9c906c' }} />
|
||||
{categories.map((c, i) => (
|
||||
<Line
|
||||
key={c}
|
||||
type="monotone"
|
||||
dataKey={c}
|
||||
name={lineName(c)}
|
||||
stroke={PALETTE[i % PALETTE.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,103 @@
|
||||
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;
|
||||
is_bot?: boolean;
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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,
|
||||
},
|
||||
'/api': {
|
||||
// Backend HTTP endpoints (/api/track, /api/admin/stats) -- kept under
|
||||
// /api so they never collide with client-side routes like /admin/stats.
|
||||
target: process.env.VITE_BACKEND_URL ?? 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
# RL trening (rl/model.py, rl/selfplay.py, rl/train.py) -- zamerne oddelene
|
||||
# od requirements.txt: server ani Docker image torch nepotrebuju, boti v hre
|
||||
# pouzivaju len cisto-Python rl/players.py (a neskor natrenovane vahy cez
|
||||
# torch az ked sa neuralny bot nasadi).
|
||||
torch>=2.4
|
||||
@@ -1,18 +1,17 @@
|
||||
astroid==2.3.3
|
||||
click==7.1.1
|
||||
colorama==0.4.3
|
||||
Flask==1.1.1
|
||||
Flask-WTF==0.14.3
|
||||
isort==4.3.21
|
||||
itsdangerous==1.1.0
|
||||
Jinja2==2.11.1
|
||||
lazy-object-proxy==1.4.3
|
||||
MarkupSafe==1.1.1
|
||||
mccabe==0.6.1
|
||||
pylint==2.4.4
|
||||
python-dotenv==0.12.0
|
||||
six==1.14.0
|
||||
typed-ast==1.4.1
|
||||
Werkzeug==1.0.0
|
||||
wrapt==1.11.2
|
||||
WTForms==2.2.1
|
||||
# Realtime Socket.IO server running on ASGI (uvicorn).
|
||||
python-socketio>=5.11
|
||||
python-engineio>=4.9
|
||||
uvicorn>=0.30
|
||||
|
||||
# Persistence layer (history + TOTP auth).
|
||||
SQLAlchemy[asyncio]>=2.0
|
||||
aiosqlite>=0.20 # dev / default DATABASE_URL
|
||||
asyncpg>=0.29 # production (PostgreSQL)
|
||||
pyotp>=2.9 # TOTP login
|
||||
cryptography>=42 # Fernet encryption for Player.totp_secret at rest
|
||||
user-agents>=2.2 # parse User-Agent for /track (self-hosted analytics)
|
||||
geoip2>=4.8 # resolve IP -> country from a local .mmdb file (no external calls)
|
||||
|
||||
# NOTE: the legacy Flask/Jinja HTTP UI (api/routes.py, api/forms.py,
|
||||
# api/templates/) is dormant and its dependencies (Flask, Flask-WTF, etc.)
|
||||
# were removed during the ASGI migration. Re-add them only if that UI is revived.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# RL bot pre bridzik — navrh (2026-07-01)
|
||||
|
||||
Ciel: naucit sa principy self-play reinforcement learningu (v duchu AlphaGo Zero)
|
||||
na praktickom priklade — natrenovat sietovy policy pre hranie bridziku. Zamerne
|
||||
zjednodusene oproti AlphaGo Zero: bez MCTS (skryta informacia neumoznuje priamy
|
||||
prehladavaci strom), cisty self-play policy gradient (PPO/REINFORCE) nad `bridzik.py`
|
||||
enginom.
|
||||
|
||||
## Kluc: `Round` je nezavisla epizoda
|
||||
|
||||
Bodovanie (`Round.get_points_summary`) je cisto lokalne pre jedno kolo — nezavisi
|
||||
od predoslych ani nasledujucich kol, len od tipu a poctu kopiek v danom kole.
|
||||
Netreba teda simulovat cely `Bridzik`/`Series` state machine na trening — staci
|
||||
instanciovat `Round(round_number, first_player, shuffler)` priamo, opakovane,
|
||||
s roznymi `round_number` (0-7, teda 8 az 1 karta v ruke). Kazdy `Round` je
|
||||
samostatna self-play epizoda.
|
||||
|
||||
Vyhody:
|
||||
- jednoduchsi self-play loop (ziadne series/game bookkeeping)
|
||||
- vela nezavislych epizod, jednoduchá paralelizacia
|
||||
|
||||
**Curriculum vs. uniformne samplovanie (bod 4).** Povodny napad "zacat na
|
||||
`round_number=7`" je zavadzajuci: pri 1 karte je tip masked na {0,1} a jedina
|
||||
karta je vynutena — nula card-play rozhodnuti, ziadne ucenie, len smoke-test.
|
||||
Realne ucenie je v kolach `round_number` 0-3 (6-8 kariet). Preto:
|
||||
- `round_number` je aj tak v observacii, takze **default = uniformne samplovat
|
||||
`round_number` 0-7** a nechat siet zdielat vahy naprieč velkostami ruk.
|
||||
- Ak curriculum, tak **od tazkych (viac kariet) k lahsim**, nie naopak; alebo
|
||||
aspon uniformne s miernym zvyhodnenim tazsich kol.
|
||||
- `round_number=7` drzat len ako sanity/smoke test pipeline, nie ako trening.
|
||||
|
||||
## 1. State encoding (observation)
|
||||
|
||||
Spolocne pre guess aj play fazu, budovane z `Round` objektu pre daneho hraca:
|
||||
|
||||
- **vlastna ruka** — 32-dim multi-hot (4 farby x 8 hodnot)
|
||||
- **round_number** — one-hot (8) alebo normalizovane cislo (urcuje velkost ruk)
|
||||
- **tipy vsetkych 4 hracov** — 4x (flag "uz tipoval" + normalizovana hodnota)
|
||||
- **vlastny tip** (po tipnuti) — kriticke pre play fazu (viem, ci este potrebujem
|
||||
vyhrat kopku, alebo sa jej mam vyhybat)
|
||||
- **kolko kopiek uz kazdy hrac vyhral v tomto kole** — 4 scalars, odvodene
|
||||
z dokoncenych `Stash` objektov v `self.stashes`
|
||||
- **aktualna kopka v procese** — 4 sloty (karta alebo prazdne) + `first_player`
|
||||
aktualnej kopky
|
||||
- **uz odohrane/videne karty v tomto kole** — 32-dim multi-hot (bod 1). KRITICKE:
|
||||
bez toho observacia NIE JE Markovovska. Pri viac kartach (round_number 0-2)
|
||||
su dve rovnake ruky s rovnakou aktualnou kopkou, ale roznou historiou uz
|
||||
odohranych kariet, rozne stavy s roznym optimalnym tahom (vies, ci este visi
|
||||
eso/cerven). Bez tejto zlozky sa siet nemoze naucit card-counting a strop hry
|
||||
ostane nizky. Kodovat karty odohrane v predoslych dokoncenych `Stash`-och
|
||||
(mimo tvojej ruky a mimo aktualnej rozohranej kopky).
|
||||
- **(volitelne, neskor — bod 8) znama neúčasť supperov vo farbe (voids)** —
|
||||
ked supper neprizna vynasanu farbu, prezradi void → per-hrac x per-farba
|
||||
flag. Silna informacia, ale nechat na neskorsie rozsirenie.
|
||||
|
||||
**Egocentricka rotacia (bod 2) — povinny invariant.** Aby parameter sharing
|
||||
medzi 4 sedadlami fungoval, VSETKY 4-hracske vektory (tipy, pocty vyhranych
|
||||
kopiek, sloty aktualnej kopky, `first_player`) musia byt rotovane tak, ze
|
||||
"ja" = index 0 a ostatni relativne (+1, +2, +3 v smere hry). Toto zapisat do
|
||||
`encoding.py` ako tvrdy invariant + unit test — je to najpravdepodobnejsie
|
||||
miesto tichej chyby, ktora pokazi ucenie.
|
||||
|
||||
Zamerne vynechane: priebezne skore/standings naprieč hrou — kedze odmena je
|
||||
per-round nezavisla, optimalne rozhodnutie v danom kole na standings nezavisi.
|
||||
|
||||
**Velkost observacie (bod 5).** Povodny odhad ~80-100 floatov je podstrelený.
|
||||
Ak sa 4 sloty aktualnej kopky koduju one-hot (4x32=128) + ruka 32 + videne
|
||||
karty 32 + tipy/pocty/round_number/first_player, realny `input_dim` je skor
|
||||
~200. Nie je to problem, len podla toho nastavit vstupnu vrstvu siete.
|
||||
|
||||
## 2. Akcny priestor + maskovanie
|
||||
|
||||
- **Guess**: 9 kategorii (0-8), maskovane na `0..(8-round_number)`; pre 4.
|
||||
(posledneho) tipujuceho naviac zamaskovat hodnotu, ktora by sposobila
|
||||
`BridzikException` (sucet tipov = pocet kopiek) — vypocitatelne vopred
|
||||
z `self.guesses`. Zakazana hodnota = `(8-round_number) - sum(3 tipov)`;
|
||||
ak vyjde mimo `0..(8-round_number)`, je uz aj tak nelegalna a nemaskuje sa
|
||||
nic navyse (osetrit rozsah).
|
||||
- **Play card**: 32 kategorii (rovnaka indexacia farba+hodnota ako hand-encoding),
|
||||
maskovane na karty, ktore hrac realne ma A splnaju follow-suit pravidlo
|
||||
(rovnaka logika ako v `Round.play_card`: farba prvej karty v kopke, inak
|
||||
povinna cervena ak ju hrac ma).
|
||||
|
||||
## 3. Sieť
|
||||
|
||||
Zdielany "trup" (2 hidden layers, ~128-256 neuronov, ReLU) nad observation
|
||||
vektorom (~80-100 floatov), s troma vystupmi:
|
||||
|
||||
- guess head (9 logitov)
|
||||
- play head (32 logitov)
|
||||
- value head (1 scalar) — odhad ocakavanej odmeny do konca kola (baseline
|
||||
pre actor-critic)
|
||||
|
||||
`input_dim` nastavit podla realnej velkosti observacie (~200, viz bod 5
|
||||
v sekcii 1), nie podla povodneho ~80-100.
|
||||
|
||||
Fazovy flag v observacii + maskovanie urcuje, ktora hlava je pouzitelna
|
||||
v danom kroku (guess a play fazy sa nikdy neprelinaju).
|
||||
|
||||
## 4. Odmena a trening
|
||||
|
||||
- Odmena = 0 pocas kola; na konci kola kazdy hrac dostane
|
||||
`points_summary[player]` (0 alebo `10+guess`) ako terminalnu odmenu za
|
||||
VSETKY svoje rozhodnutia v danom kole (guess + vsetky `play_card` tahy).
|
||||
Sparse terminal reward, ziadne discountovanie netreba — `gamma=1` (bod 9),
|
||||
kolo ma max 9 rozhodnuti na hraca: round 0 = 1 tip + 8 kariet.
|
||||
- Algoritmus: self-play PPO (prip. najprv jednoduchsie REINFORCE + baseline),
|
||||
jedna zdielana siet hra vsetkych 4 hracov v kazdom `Round` (parameter
|
||||
sharing, rovnaky princip ako AlphaGo Zero).
|
||||
- **Normalizacia odmeny (bod 7).** `10+guess` je v rozsahu 10-18 a lisi sa
|
||||
per kolo; pri miesanych `round_number` to zvysuje varianciu policy gradientu.
|
||||
Standardizovat advantage per batch (odcitat priemer, delit std) — bezna
|
||||
PPO praktika, tu je nutnejsia kvoli rozne velkym odmenam.
|
||||
- Paralelizacia: `Round` instancie su nezavisle bez shared state, self-play
|
||||
generovanie sa da paralelizovat cez multiprocessing naprieč jadrami CPU
|
||||
(pripadne batchovanim viacerych epizod naraz cez sietovy forward).
|
||||
|
||||
**Caveat: hra nie je zero-sum (bod 3).** Je to 4-hracska general-sum hra —
|
||||
viacero hracov moze naraz trafit tip a vsetci skoruju, zaroven sa o kopky
|
||||
sutazi (`sum(kopky) = pocet kopiek`). Self-play so zdielanymi vahami preto
|
||||
NEMA konvergencne zaruky ako AlphaGo Zero (2-hracska zero-sum); skonverguje
|
||||
k *nejakemu* equilibriu, nie nutne k optimu, a moze oscilovat. Na ucebny
|
||||
ciel to staci, ale: (a) nepredavat si to ako "AlphaZero-grade optimalitu",
|
||||
(b) sledovat progres proti FIXNYM baseline-om (sekcia 5), nie len podla
|
||||
self-play reward, ktory sa hybe s protihracom.
|
||||
|
||||
## 5. Vyhodnotenie
|
||||
|
||||
- priemerne body/kolo oproti baseline (nahodny legalny hrac, Monte Carlo
|
||||
heuristicky tipper — pozri sekciu nizsie)
|
||||
- presnost tipu (% kôl, kde sa tip presne trafil) — interpretovatelnejsia
|
||||
metrika nez surove body
|
||||
|
||||
## Alternativa/doplnok pre tipovaciu fazu: Monte Carlo namiesto siete
|
||||
|
||||
Kedze tipovanie je v podstate odhad pravdepodobnosti pri neznamom rozdeleni
|
||||
zvysnych kariet, da sa riesit aj bez siete:
|
||||
|
||||
1. **Naivna MC simulacia** — vygenerovat vela nahodnych rozdeleni zvysnych
|
||||
kariet medzi ostatnych 3 hracov, odsimulovat kolo s jednoduchou heuristickou
|
||||
hracou strategiou, spocitat rozdelenie poctu vlastnych kopiek. POZOR: kedze
|
||||
bodujeme len presnu zhodu, spravny cieľ je **mod** rozdelenia, nie priemer.
|
||||
POZOR 2 (bod 6 — "discard pile"): `deal_starting_cards` zahodí prvych
|
||||
`4*round_number` kariet (`round_cards[4*round_number:]`), takze v kole NIE
|
||||
su rozdane vsetky karty. MC teda z `32 - vlastna_ruka` kariet rozdá kazdemu
|
||||
z 3 supperov len `(8-round_number)` kariet a **zvysok necha v neznamej kope
|
||||
mimo hru** — nerozdavat vsetko medzi supperov, inak nadhodnotis, kolko
|
||||
vysokych kariet/cervene supperi drzia.
|
||||
2. **Silnejsia verzia** — rovnaky MC rollout, ale simulovat zvysok kola
|
||||
s uz natrenovanou card-play sietou namiesto naivnej heuristiky (analogia
|
||||
MCTS + value network v AlphaZero namiesto ciste nahodnych rolloutov).
|
||||
|
||||
Toto sa da pouzit ako rychly heuristicky baseline bez trenovania siete na
|
||||
tipovanie vobec, alebo ako silnejsi hybrid s uz existujucou play sietou.
|
||||
|
||||
## Poradie implementacie
|
||||
|
||||
1. `rl/encoding.py` — cistě funkcie observation + mask (nad `Round` objektom),
|
||||
testovatelne izolovane. Uz tu zapracovat bod 1 (videne karty) a bod 2
|
||||
(egocentricka rotacia). Unit/property testy: maska NIKDY nepovoli tah, ktory
|
||||
`Round.play_card`/`add_player_guess` odmietne (fuzz-test proti enginu);
|
||||
rotacia je konzistentna pre vsetky 4 sedadla.
|
||||
2. `rl/env.py` — step/reset wrapper okolo jedneho `Round` (nie celeho `Bridzik`)
|
||||
3. **baseline hraci + evaluacny harness UZ TU** (nahodny legalny hrac, MC
|
||||
heuristicky tipper podla sekcie vyssie) — nech je metrika k dispozicii od
|
||||
prvej trenovacej epochy a da sa sledovat progres (bod 3: proti fixnym
|
||||
baseline-om, nie len self-play reward).
|
||||
4. sieť (PyTorch, trup + 3 hlavy) — `input_dim` podla realnej velkosti obs (~200)
|
||||
5. self-play generator (paralelne `Round` epizody)
|
||||
6. PPO update krok (advantage standardizovat per batch — bod 7)
|
||||
7. (neskor, volitelne) rozsirit na cely `Series`/`Bridzik` self-play, ak by
|
||||
sa ukazalo, ze cross-round dynamika (rotacia first_player a pod.) predsa
|
||||
len nieco mení — podla bodu 1 to nie je ocakavane
|
||||
|
||||
## Technologie
|
||||
|
||||
- **PyTorch** — samostatny `requirements-rl.txt`, oddeleny od zakladneho
|
||||
`requirements.txt` projektu
|
||||
- vlastna mensia implementacia PPO (v duchu CleanRL) namiesto Stable-Baselines3/
|
||||
RLlib — nas pripad (multi-agent self-play so zdielanymi vahami, maskovanie
|
||||
akcii) sa bije s ich single-agent Gym abstrakciou viac, nez by pomohla
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Observation a action-mask encoding nad `Round` objektom z `bridzik.py`.
|
||||
|
||||
Ciste funkcie bez externych zavislosti (ziadny numpy/torch) -- vystupy su
|
||||
obycajne zoznamy floatov/boolov, konverzia na tenzory je vecou volajuceho.
|
||||
|
||||
Tvrdy invariant (viz rl/DESIGN.md): VSETKY 4-hracske zlozky observacie su
|
||||
egocentricky rotovane -- "ja" (parameter `player`) je vzdy index 0, ostatni
|
||||
hraci +1/+2/+3 v smere hry. Bez tejto rotacie parameter sharing medzi
|
||||
sedadlami nefunguje.
|
||||
|
||||
Masky zrkadlia pravidla enginu: maska NIKDY nesmie povolit akciu, ktoru by
|
||||
`Round.add_player_guess` / `Round.play_card` odmietli, a naopak. Tuto zhodu
|
||||
vynucuje fuzz-test v tests/test_encoding.py.
|
||||
"""
|
||||
|
||||
from bridzik import Card, Card_colors, Card_values, ROUNDS_PER_SERIES
|
||||
|
||||
# Kanonicke poradie farieb a hodnot = poradie deklaracie v enum-och.
|
||||
COLORS = list(Card_colors) # HEARTS, LEAVES, ACORNS, BELLS
|
||||
VALUES = list(Card_values) # C7, C8, C9, C10, LOWER, UPPER, KING, ACE
|
||||
|
||||
N_CARDS = 32
|
||||
N_GUESS_ACTIONS = 9 # tipy 0..8
|
||||
N_PLAY_ACTIONS = N_CARDS # jedna akcia = jedna karta
|
||||
|
||||
_COLOR_INDEX = {color: i for i, color in enumerate(COLORS)}
|
||||
_VALUE_INDEX = {value: i for i, value in enumerate(VALUES)}
|
||||
|
||||
# Layout observacie -- offsety su sucast verejneho kontraktu (testy aj siet
|
||||
# sa na ne odkazuju menom, nie magickym cislom).
|
||||
OFF_HAND = 0 # 32 multi-hot: vlastna ruka
|
||||
OFF_SEEN = OFF_HAND + N_CARDS # 32 multi-hot: karty z dokoncenych kopiek
|
||||
OFF_ROUND = OFF_SEEN + N_CARDS # 8 one-hot: round_number
|
||||
OFF_PHASE = OFF_ROUND + ROUNDS_PER_SERIES # 1 flag: 1.0 = tipovacia faza
|
||||
OFF_GUESSES = OFF_PHASE + 1 # 4 x (flag "uz tipoval", tip/8), rel. poradie
|
||||
OFF_TRICKS = OFF_GUESSES + 8 # 4 x (vyhrane kopky / 8), rel. poradie
|
||||
OFF_STASH = OFF_TRICKS + 4 # 4 x 32 one-hot: aktualna kopka, rel. poradie
|
||||
OFF_STASH_LEADER = OFF_STASH + 4 * N_CARDS # 4 one-hot: rel. first_player kopky
|
||||
OFF_VOIDS = OFF_STASH_LEADER + 4 # 4 hraci x 4 farby: dedukovane voidy, rel. poradie
|
||||
OBS_DIM = OFF_VOIDS + 16 # = 233
|
||||
|
||||
|
||||
def card_index(card: Card) -> int:
|
||||
"""Index karty 0..31: farba (blok po 8) + hodnota."""
|
||||
return _COLOR_INDEX[card.color] * 8 + _VALUE_INDEX[card.value]
|
||||
|
||||
|
||||
def index_card(index: int) -> Card:
|
||||
"""Inverzia card_index."""
|
||||
return Card(COLORS[index // 8], VALUES[index % 8])
|
||||
|
||||
|
||||
def relative_seat(seat: int, player: int) -> int:
|
||||
"""Egocentricka rotacia: `player` -> 0, dalsi v smere hry -> 1, 2, 3."""
|
||||
return (seat - player) % 4
|
||||
|
||||
|
||||
def deduce_voids(rnd) -> dict:
|
||||
"""Isto-dedukovane chybajuce farby hracov z priebehu kola.
|
||||
|
||||
Follow-suit pravidlo prezradza: kto nepriznal vynasanu farbu, uz ju nema;
|
||||
kto pri tom nezahral ani cerven (tromf), nema ani tu. Vynasajuci hrac
|
||||
neprezradza nic. Vracia dict seat -> set(Card_colors). Kedze karty pocas
|
||||
kola len ubudaju, raz dedukovany void plati do konca kola.
|
||||
"""
|
||||
voids = {seat: set() for seat in range(4)}
|
||||
for stash in rnd.stashes:
|
||||
first = stash.get_first_card()
|
||||
if first is None:
|
||||
continue
|
||||
for seat, card in stash.get_cards().items():
|
||||
if seat == stash.first_player:
|
||||
continue
|
||||
if card.color != first.color:
|
||||
voids[seat].add(first.color)
|
||||
if card.color != Card_colors['HEARTS']:
|
||||
voids[seat].add(Card_colors['HEARTS'])
|
||||
return voids
|
||||
|
||||
|
||||
def encode_observation(rnd, player: int) -> list:
|
||||
"""Observacia kola z pohladu hraca `player` (OBS_DIM floatov v [0, 1]).
|
||||
|
||||
Funguje v oboch fazach (tipovanie aj hra) aj na terminalnom stave;
|
||||
necita get_active_player(), takze sa da zavolat pre lubovolneho hraca
|
||||
kedykolvek.
|
||||
"""
|
||||
obs = [0.0] * OBS_DIM
|
||||
|
||||
for card in rnd.player_cards[player]:
|
||||
obs[OFF_HAND + card_index(card)] = 1.0
|
||||
|
||||
# Dokoncene kopky -> "videne karty"; jedina pripadna nedokoncena kopka
|
||||
# (posledna) je aktualna rozohrana a koduje sa do slotov nizsie.
|
||||
current_stash = None
|
||||
for stash in rnd.stashes:
|
||||
if stash.is_completed():
|
||||
for card in stash.get_cards().values():
|
||||
obs[OFF_SEEN + card_index(card)] = 1.0
|
||||
else:
|
||||
current_stash = stash
|
||||
|
||||
obs[OFF_ROUND + rnd.round_number] = 1.0
|
||||
obs[OFF_PHASE] = 0.0 if rnd.is_guessing_completed() else 1.0
|
||||
|
||||
for seat, guess in rnd.guesses.items():
|
||||
rel = relative_seat(seat, player)
|
||||
obs[OFF_GUESSES + 2 * rel] = 1.0
|
||||
obs[OFF_GUESSES + 2 * rel + 1] = guess / 8
|
||||
|
||||
tricks = rnd.get_stashes_winner_summary()
|
||||
for seat in range(4):
|
||||
obs[OFF_TRICKS + relative_seat(seat, player)] = tricks[seat] / 8
|
||||
|
||||
if current_stash is not None:
|
||||
for seat, card in current_stash.get_cards().items():
|
||||
rel = relative_seat(seat, player)
|
||||
obs[OFF_STASH + rel * N_CARDS + card_index(card)] = 1.0
|
||||
obs[OFF_STASH_LEADER + relative_seat(current_stash.first_player, player)] = 1.0
|
||||
|
||||
for seat, banned in deduce_voids(rnd).items():
|
||||
rel = relative_seat(seat, player)
|
||||
for color in banned:
|
||||
obs[OFF_VOIDS + rel * 4 + _COLOR_INDEX[color]] = 1.0
|
||||
|
||||
return obs
|
||||
|
||||
|
||||
def guess_mask(rnd) -> list:
|
||||
"""Maska legalnych tipov (N_GUESS_ACTIONS boolov) pre aktivneho tipujuceho.
|
||||
|
||||
Zrkadli Round.add_player_guess: tip 0..(8 - round_number); poslednemu
|
||||
(stvrtemu) tipujucemu je navyse zakazana hodnota, pri ktorej by sucet
|
||||
tipov vysiel presne na pocet kopiek v kole.
|
||||
"""
|
||||
max_guess = 8 - rnd.round_number
|
||||
mask = [g <= max_guess for g in range(N_GUESS_ACTIONS)]
|
||||
if len(rnd.guesses) == 3:
|
||||
forbidden = max_guess - sum(rnd.guesses.values())
|
||||
if 0 <= forbidden <= max_guess:
|
||||
mask[forbidden] = False
|
||||
return mask
|
||||
|
||||
|
||||
def legal_cards(hand: list, first_card) -> list:
|
||||
"""Karty z `hand`, ktore smie hrac zahrat do kopky vynasanej `first_card`.
|
||||
|
||||
Zrkadli follow-suit logiku Round.play_card: povinna farba prvej karty
|
||||
kopky; ak ju hrac nema, povinna cervena (tromf); ak nema ani tu,
|
||||
lubovolna karta. Pri vynasani (`first_card is None`) lubovolna karta.
|
||||
"""
|
||||
if first_card is None:
|
||||
return list(hand)
|
||||
same_color = [c for c in hand if c.color == first_card.color]
|
||||
if same_color:
|
||||
return same_color
|
||||
hearts = [c for c in hand if c.color == Card_colors['HEARTS']]
|
||||
return hearts if hearts else list(hand)
|
||||
|
||||
|
||||
def play_mask(rnd, player: int) -> list:
|
||||
"""Maska legalnych kariet (N_PLAY_ACTIONS boolov) pre hraca `player`."""
|
||||
stash = rnd.get_last_stash()
|
||||
first_card = stash.get_first_card() if stash is not None else None
|
||||
mask = [False] * N_PLAY_ACTIONS
|
||||
for card in legal_cards(rnd.player_cards[player], first_card):
|
||||
mask[card_index(card)] = True
|
||||
return mask
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Self-play prostredie nad jednym `Round`-om (viz rl/DESIGN.md).
|
||||
|
||||
Jedno kolo = jedna epizoda. Prostredie je multi-agentne a tahove: v kazdom
|
||||
kroku je na tahu prave jeden hrac (`Decision.player`), akciu zan doda
|
||||
volajuci (zdielana siet, heuristika, ...). Odmena je sparse a terminalna --
|
||||
`Round.get_points_summary()` pre vsetkych 4 hracov naraz na konci kola.
|
||||
|
||||
Akcie: v tipovacej faze index tipu 0..8, v hracej faze index karty 0..31
|
||||
(kanonicke cislovanie z rl/encoding.py). Legalne akcie urcuje
|
||||
`Decision.mask`; nelegalna akcia prebuble ako BridzikException z enginu.
|
||||
"""
|
||||
|
||||
from random import Random
|
||||
from typing import NamedTuple
|
||||
|
||||
from bridzik import Round, ROUNDS_PER_SERIES
|
||||
from rl.encoding import encode_observation, guess_mask, index_card, play_mask
|
||||
|
||||
PHASE_GUESS = 'guess'
|
||||
PHASE_PLAY = 'play'
|
||||
|
||||
|
||||
class Decision(NamedTuple):
|
||||
"""Jeden rozhodovaci bod: kto je na tahu, v akej faze, co vidi a co smie."""
|
||||
player: int
|
||||
phase: str
|
||||
obs: list
|
||||
mask: list
|
||||
|
||||
|
||||
class RoundEnv:
|
||||
def __init__(self, rng: Random = None):
|
||||
self.rng = rng if rng is not None else Random()
|
||||
self.round = None
|
||||
|
||||
def reset(self, round_number: int = None, first_player: int = None) -> Decision:
|
||||
"""Zacne novu epizodu; nezadane parametre sa sampluju uniformne."""
|
||||
if round_number is None:
|
||||
round_number = self.rng.randrange(ROUNDS_PER_SERIES)
|
||||
if first_player is None:
|
||||
first_player = self.rng.randrange(4)
|
||||
self.round = Round(round_number, first_player, shuffler=self.rng.shuffle)
|
||||
return self._decision()
|
||||
|
||||
def step(self, action: int):
|
||||
"""Vykona akciu hraca na tahu.
|
||||
|
||||
Vracia (decision, rewards, done): pocas kola (Decision, None, False),
|
||||
na konci kola (None, [body 4 hracov], True).
|
||||
"""
|
||||
if self.round is None or self.round.is_completed():
|
||||
raise RuntimeError('Epizoda nebezi -- najprv zavolaj reset().')
|
||||
player = self.round.get_active_player()
|
||||
if not self.round.is_guessing_completed():
|
||||
self.round.add_player_guess(player, action)
|
||||
else:
|
||||
self.round.play_card(player, index_card(action))
|
||||
if self.round.is_completed():
|
||||
return None, self.round.get_points_summary(), True
|
||||
return self._decision(), None, False
|
||||
|
||||
def _decision(self) -> Decision:
|
||||
player = self.round.get_active_player()
|
||||
if not self.round.is_guessing_completed():
|
||||
return Decision(player, PHASE_GUESS,
|
||||
encode_observation(self.round, player),
|
||||
guess_mask(self.round))
|
||||
return Decision(player, PHASE_PLAY,
|
||||
encode_observation(self.round, player),
|
||||
play_mask(self.round, player))
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Evaluacny harness: odohra N kol medzi 4 hracmi a spocita metriky.
|
||||
|
||||
Metriky per hrac (viz rl/DESIGN.md, sekcia 5): priemerne body na kolo
|
||||
a presnost tipu (% kol s presne trafenym tipom). Sedadla sa medzi kolami
|
||||
rotuju, aby ziadny hrac nebol systematicky zvyhodneny poradim tipovania.
|
||||
|
||||
Spustenie ako skript porovna baseline botov:
|
||||
py -m rl.evaluate --rounds 500 --seed 7
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from random import Random
|
||||
|
||||
from bridzik import ROUNDS_PER_SERIES
|
||||
from rl.env import PHASE_GUESS, RoundEnv
|
||||
|
||||
|
||||
def play_round(players: list, env: RoundEnv, round_number: int = None,
|
||||
first_player: int = None) -> list:
|
||||
"""Odohra jedno kolo; `players[seat]` rozhoduje za sedadlo `seat`.
|
||||
|
||||
Vrati body 4 sedadiel (`Round.get_points_summary()`).
|
||||
"""
|
||||
decision = env.reset(round_number, first_player)
|
||||
while True:
|
||||
seat = decision.player
|
||||
if decision.phase == PHASE_GUESS:
|
||||
action = players[seat].guess(env.round, seat)
|
||||
else:
|
||||
action = players[seat].play(env.round, seat)
|
||||
decision, rewards, done = env.step(action)
|
||||
if done:
|
||||
return rewards
|
||||
|
||||
|
||||
def evaluate(players: list, n_rounds: int, rng: Random = None,
|
||||
round_numbers: list = None) -> list:
|
||||
"""Odohra `n_rounds` kol s rotaciou sedadiel; vrati stats per hrac.
|
||||
|
||||
Vystup: zoznam dictov v poradi `players` --
|
||||
{'avg_points': float, 'hit_rate': float, 'rounds': int}.
|
||||
"""
|
||||
rng = rng if rng is not None else Random()
|
||||
env = RoundEnv(rng)
|
||||
points = [0] * 4
|
||||
hits = [0] * 4
|
||||
for i in range(n_rounds):
|
||||
round_number = rng.choice(round_numbers) if round_numbers \
|
||||
else rng.randrange(ROUNDS_PER_SERIES)
|
||||
# rotacia: sedadlo s obsadzuje players[(s + i) % 4]
|
||||
seating = [players[(s + i) % 4] for s in range(4)]
|
||||
rewards = play_round(seating, env, round_number)
|
||||
for seat in range(4):
|
||||
player_index = (seat + i) % 4
|
||||
points[player_index] += rewards[seat]
|
||||
hits[player_index] += rewards[seat] > 0
|
||||
return [{'avg_points': points[p] / n_rounds,
|
||||
'hit_rate': hits[p] / n_rounds,
|
||||
'rounds': n_rounds} for p in range(len(players))]
|
||||
|
||||
|
||||
def main():
|
||||
from rl.players import HeuristicPlayer, RandomPlayer
|
||||
|
||||
parser = argparse.ArgumentParser(description='Evaluacia baseline botov')
|
||||
parser.add_argument('--rounds', type=int, default=500)
|
||||
parser.add_argument('--seed', type=int, default=7)
|
||||
parser.add_argument('--mc-samples', type=int, default=100)
|
||||
args = parser.parse_args()
|
||||
|
||||
rng = Random(args.seed)
|
||||
lineups = [
|
||||
('4x random', [RandomPlayer(rng) for _ in range(4)]),
|
||||
('1x heuristika + 3x random',
|
||||
[HeuristicPlayer(rng, n_samples=args.mc_samples)]
|
||||
+ [RandomPlayer(rng) for _ in range(3)]),
|
||||
('4x heuristika',
|
||||
[HeuristicPlayer(rng, n_samples=args.mc_samples) for _ in range(4)]),
|
||||
]
|
||||
for label, players in lineups:
|
||||
stats = evaluate(players, args.rounds, rng)
|
||||
print(f'\n{label} ({args.rounds} kol):')
|
||||
for i, s in enumerate(stats):
|
||||
print(f' hrac {i}: {s["avg_points"]:6.2f} bodov/kolo, '
|
||||
f'tip trafeny {100 * s["hit_rate"]:5.1f} %')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Export vah natrenovanej siete do formatu pre cisto-Python inferenciu.
|
||||
|
||||
Torch je len trenovacia zavislost (host); produkcia hra cez rl/pure_net.py,
|
||||
ktory cita tento subor bez torch/numpy. Vahy sa uladaju ako base64 float32
|
||||
little-endian (bit-exact kopia checkpointu, ziadna strata presnosti).
|
||||
|
||||
Pouzitie:
|
||||
py -m rl.export rl/checkpoints/latest.pt rl/weights/neural-bot.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
from rl.encoding import OBS_DIM
|
||||
from rl.train import load_checkpoint
|
||||
|
||||
DEFAULT_WEIGHTS_PATH = os.path.join('rl', 'weights', 'neural-bot.json')
|
||||
|
||||
|
||||
def _pack(tensor) -> dict:
|
||||
"""Tensor -> {shape, base64 float32 LE}. Cez struct, bez numpy -- tolist()
|
||||
vracia presne hodnoty float32, takze zapis je bit-exact."""
|
||||
import struct
|
||||
data = tensor.detach().to('cpu').contiguous().float()
|
||||
flat = data.reshape(-1).tolist()
|
||||
return {
|
||||
'shape': list(data.shape),
|
||||
'data': base64.b64encode(struct.pack(f'<{len(flat)}f', *flat)).decode('ascii'),
|
||||
}
|
||||
|
||||
|
||||
def export(checkpoint_path: str, out_path: str) -> dict:
|
||||
net = load_checkpoint(checkpoint_path)
|
||||
hidden = net.trunk[0].out_features
|
||||
payload = {
|
||||
'obs_dim': OBS_DIM,
|
||||
'hidden': hidden,
|
||||
'weights': {
|
||||
'trunk0_w': _pack(net.trunk[0].weight),
|
||||
'trunk0_b': _pack(net.trunk[0].bias),
|
||||
'trunk2_w': _pack(net.trunk[2].weight),
|
||||
'trunk2_b': _pack(net.trunk[2].bias),
|
||||
'guess_w': _pack(net.guess_head.weight),
|
||||
'guess_b': _pack(net.guess_head.bias),
|
||||
'play_w': _pack(net.play_head.weight),
|
||||
'play_b': _pack(net.play_head.bias),
|
||||
},
|
||||
}
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(out_path, 'w') as f:
|
||||
json.dump(payload, f)
|
||||
return payload
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Export vah pre pure-Python inferenciu')
|
||||
parser.add_argument('checkpoint', nargs='?', default='rl/checkpoints/latest.pt')
|
||||
parser.add_argument('out', nargs='?', default=DEFAULT_WEIGHTS_PATH)
|
||||
args = parser.parse_args()
|
||||
payload = export(args.checkpoint, args.out)
|
||||
size = os.path.getsize(args.out)
|
||||
print(f'Exportovane: {args.checkpoint} (hidden={payload["hidden"]}) '
|
||||
f'-> {args.out} ({size / 1024:.0f} kB)')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Siet pre self-play PPO (viz rl/DESIGN.md, sekcia 3).
|
||||
|
||||
Zdielany trup nad observaciou z rl/encoding.py a tri hlavy:
|
||||
guess (9 logitov), play (32 logitov), value (1 skalar -- baseline pre
|
||||
actor-critic). Ktora policy hlava plati, urcuje faza rozhodnutia; nelegalne
|
||||
akcie sa odrezavaju maskou (logit -inf), takze distribucia nikdy nenavzorkuje
|
||||
tah, ktory by engine odmietol.
|
||||
"""
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from rl.encoding import N_GUESS_ACTIONS, N_PLAY_ACTIONS, OBS_DIM
|
||||
|
||||
|
||||
class BridzikNet(nn.Module):
|
||||
def __init__(self, hidden: int = 256):
|
||||
super().__init__()
|
||||
self.trunk = nn.Sequential(
|
||||
nn.Linear(OBS_DIM, hidden), nn.ReLU(),
|
||||
nn.Linear(hidden, hidden), nn.ReLU(),
|
||||
)
|
||||
self.guess_head = nn.Linear(hidden, N_GUESS_ACTIONS)
|
||||
self.play_head = nn.Linear(hidden, N_PLAY_ACTIONS)
|
||||
self.value_head = nn.Linear(hidden, 1)
|
||||
|
||||
def forward(self, obs: torch.Tensor):
|
||||
"""obs (B, OBS_DIM) -> (guess_logits (B,9), play_logits (B,32), value (B,))."""
|
||||
h = self.trunk(obs)
|
||||
return self.guess_head(h), self.play_head(h), self.value_head(h).squeeze(-1)
|
||||
|
||||
|
||||
def masked_categorical(logits: torch.Tensor, mask: torch.Tensor) -> torch.distributions.Categorical:
|
||||
"""Kategoricka distribucia s nelegalnymi akciami odrezanymi na -inf.
|
||||
|
||||
`mask` je bool tensor rovnakeho tvaru ako `logits`; kazdy riadok musi mat
|
||||
aspon jednu povolenu akciu (garantuju masky z rl/encoding.py).
|
||||
"""
|
||||
return torch.distributions.Categorical(
|
||||
logits=logits.masked_fill(~mask, float('-inf'))
|
||||
)
|
||||
|
||||
|
||||
def obs_tensor(obs: list) -> torch.Tensor:
|
||||
return torch.tensor(obs, dtype=torch.float32)
|
||||
|
||||
|
||||
def mask_tensor(mask: list) -> torch.Tensor:
|
||||
return torch.tensor(mask, dtype=torch.bool)
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Baseline hraci pre evaluaciu a neskorsi warm-start siete (viz rl/DESIGN.md).
|
||||
|
||||
Spolocne rozhranie: `guess(rnd, seat) -> int` (tip 0..8) a
|
||||
`play(rnd, seat) -> int` (index karty 0..31). Hrac vidi len to, co by videl
|
||||
pri stole -- vlastnu ruku, tipy, dokoncene kopky a rozohranu kopku; do cudzich
|
||||
ruk nesiaha.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
from random import Random
|
||||
|
||||
from bridzik import cards, Card_colors, Stash
|
||||
from rl.encoding import (
|
||||
N_GUESS_ACTIONS, N_PLAY_ACTIONS,
|
||||
card_index, deduce_voids, guess_mask, legal_cards, play_mask,
|
||||
)
|
||||
|
||||
|
||||
class RandomPlayer:
|
||||
"""Uniformne nahodny legalny tah -- najslabsi mozny baseline."""
|
||||
|
||||
def __init__(self, rng: Random = None):
|
||||
self.rng = rng if rng is not None else Random()
|
||||
|
||||
def guess(self, rnd, seat: int) -> int:
|
||||
mask = guess_mask(rnd)
|
||||
return self.rng.choice([g for g in range(N_GUESS_ACTIONS) if mask[g]])
|
||||
|
||||
def play(self, rnd, seat: int) -> int:
|
||||
mask = play_mask(rnd, seat)
|
||||
return self.rng.choice([i for i in range(N_PLAY_ACTIONS) if mask[i]])
|
||||
|
||||
|
||||
def _strength(card) -> tuple:
|
||||
"""Absolutna sila karty: kazda cervena (tromf) bije kazdu necervenu."""
|
||||
return (card.color == Card_colors['HEARTS'], card.value.value)
|
||||
|
||||
|
||||
def _current_best(stash):
|
||||
"""Zatial vitazna karta rozohranej kopky (None ak sa este nevynieslo)."""
|
||||
first = stash.get_first_card() if stash is not None else None
|
||||
if first is None:
|
||||
return None
|
||||
best = first
|
||||
for card in stash.get_cards().values():
|
||||
if _beats(card, best):
|
||||
best = card
|
||||
return best
|
||||
|
||||
|
||||
def _beats(card, best) -> bool:
|
||||
"""Ci `card` prebije `best` (karta drziaca kopku; jej farba je smerodajna)."""
|
||||
if card.color == best.color:
|
||||
return card.value > best.value
|
||||
return card.color == Card_colors['HEARTS']
|
||||
|
||||
|
||||
def simulate_tricks(hands: dict, leader: int, rng: Random) -> list:
|
||||
"""Dohra kopky s nahodnou legalnou strategiou; vrati pocty vyhier hracov.
|
||||
|
||||
`hands` je dict seat -> zoznam kariet (rovnako velke ruky); zoznamy sa
|
||||
spotrebuju. Vitaza kopky urcuje enginovy Stash.get_winner() -- pravidla
|
||||
sa tu neduplikuju.
|
||||
"""
|
||||
tricks = [0] * 4
|
||||
for _ in range(len(hands[leader])):
|
||||
stash = Stash(leader)
|
||||
for i in range(4):
|
||||
seat = (leader + i) % 4
|
||||
card = rng.choice(legal_cards(hands[seat], stash.get_first_card()))
|
||||
hands[seat].remove(card)
|
||||
stash.add_card(seat, card)
|
||||
leader = stash.get_winner()
|
||||
tricks[leader] += 1
|
||||
return tricks
|
||||
|
||||
|
||||
def deal_consistent(unknown: list, hand_sizes: dict, voids: dict,
|
||||
rng: Random, max_tries: int = 20) -> dict:
|
||||
"""Nahodne rozdanie neznamych kariet superom respektujuce voidy.
|
||||
|
||||
Greedy priradenie po zamiesani (najviac obmedzeni hraci prvi); ak sa
|
||||
konzistentne rozdanie nepodari za `max_tries`, padne na rozdanie bez
|
||||
voidov (zriedkave, radsej mierne skreslena vzorka nez ziadna).
|
||||
Zvysok kariet ostava v odlozenej kope mimo hry.
|
||||
"""
|
||||
seats = sorted(hand_sizes, key=lambda s: len(voids.get(s, ())), reverse=True)
|
||||
pool = list(unknown)
|
||||
for _ in range(max_tries):
|
||||
rng.shuffle(pool)
|
||||
remaining = list(pool)
|
||||
hands = {}
|
||||
for seat in seats:
|
||||
hand, rest, banned = [], [], voids.get(seat, set())
|
||||
for card in remaining:
|
||||
if len(hand) < hand_sizes[seat] and card.color not in banned:
|
||||
hand.append(card)
|
||||
else:
|
||||
rest.append(card)
|
||||
if len(hand) < hand_sizes[seat]:
|
||||
break
|
||||
hands[seat] = hand
|
||||
remaining = rest
|
||||
else:
|
||||
return hands
|
||||
rng.shuffle(pool)
|
||||
idx = 0
|
||||
hands = {}
|
||||
for seat in seats:
|
||||
hands[seat] = pool[idx:idx + hand_sizes[seat]]
|
||||
idx += hand_sizes[seat]
|
||||
return hands
|
||||
|
||||
|
||||
def mc_guess_distribution(rnd, seat: int, n_samples: int, rng: Random) -> Counter:
|
||||
"""Monte Carlo odhad rozdelenia poctu vlastnych kopiek v kole.
|
||||
|
||||
Nezname karty sa v kazdej vzorke nahodne rozdelia ostatnym trom hracom
|
||||
-- kazdemu len (8 - round_number) kariet, zvysok ostava v odlozenej kope
|
||||
mimo hry (pozri DESIGN.md, pasca "discard pile"). Leader prvej kopky je
|
||||
v case tipovania neznamy (najvyssi tip), sampluje sa uniformne.
|
||||
"""
|
||||
hand = rnd.player_cards[seat]
|
||||
hand_size = 8 - rnd.round_number
|
||||
unknown = [c for c in cards if c not in hand]
|
||||
counts = Counter()
|
||||
for _ in range(n_samples):
|
||||
rng.shuffle(unknown)
|
||||
sim_hands = {seat: list(hand)}
|
||||
others = [s for s in range(4) if s != seat]
|
||||
for i, other in enumerate(others):
|
||||
sim_hands[other] = unknown[i * hand_size:(i + 1) * hand_size]
|
||||
tricks = simulate_tricks(sim_hands, rng.randrange(4), rng)
|
||||
counts[tricks[seat]] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def finish_round(hands: dict, current_cards: dict, first_player: int,
|
||||
me: int, my_card, tricks: list, rng: Random) -> list:
|
||||
"""Dohra kolo od mojho tahu: dokonci rozohranu kopku (ja hram `my_card`,
|
||||
dalsi nahodne legalne) a zvysne kopky dohra nahodnou legalnou strategiou.
|
||||
`tricks` su uz vyhrane kopky (mutuje sa kopia volajuceho); vrati final."""
|
||||
stash = Stash(first_player)
|
||||
for seat, card in current_cards.items():
|
||||
stash.add_card(seat, card)
|
||||
stash.add_card(me, my_card)
|
||||
while not stash.is_completed():
|
||||
seat = stash.get_active_player()
|
||||
card = rng.choice(legal_cards(hands[seat], stash.get_first_card()))
|
||||
hands[seat].remove(card)
|
||||
stash.add_card(seat, card)
|
||||
leader = stash.get_winner()
|
||||
tricks[leader] += 1
|
||||
while hands[leader]:
|
||||
stash = Stash(leader)
|
||||
for i in range(4):
|
||||
seat = (leader + i) % 4
|
||||
card = rng.choice(legal_cards(hands[seat], stash.get_first_card()))
|
||||
hands[seat].remove(card)
|
||||
stash.add_card(seat, card)
|
||||
leader = stash.get_winner()
|
||||
tricks[leader] += 1
|
||||
return tricks
|
||||
|
||||
|
||||
class HeuristicPlayer:
|
||||
"""MC tipper + jednoducha hracia heuristika riadena vlastnym tipom."""
|
||||
|
||||
def __init__(self, rng: Random = None, n_samples: int = 100):
|
||||
self.rng = rng if rng is not None else Random()
|
||||
self.n_samples = n_samples
|
||||
|
||||
def guess(self, rnd, seat: int) -> int:
|
||||
counts = mc_guess_distribution(rnd, seat, self.n_samples, self.rng)
|
||||
mask = guess_mask(rnd)
|
||||
# najcastejsi LEGALNY pocet kopiek (mod rozdelenia, nie priemer --
|
||||
# boduje sa len presna zhoda); pri nule vzoriek pre legalny tip
|
||||
# rozhodne blizkost k celkovemu modu
|
||||
mode = counts.most_common(1)[0][0]
|
||||
legal = [g for g in range(N_GUESS_ACTIONS) if mask[g]]
|
||||
return max(legal, key=lambda g: (counts[g], -abs(g - mode)))
|
||||
|
||||
def play(self, rnd, seat: int) -> int:
|
||||
hand = rnd.player_cards[seat]
|
||||
stash = rnd.get_last_stash()
|
||||
allowed = legal_cards(hand, stash.get_first_card() if stash else None)
|
||||
need = rnd.guesses[seat] - rnd.get_stashes_winner_summary()[seat]
|
||||
best = _current_best(stash)
|
||||
|
||||
if best is None:
|
||||
# vynasam: chcem kopku -> najsilnejsia karta; nechcem -> najslabsia
|
||||
chosen = max(allowed, key=_strength) if need > 0 else min(allowed, key=_strength)
|
||||
else:
|
||||
winning = [c for c in allowed if _beats(c, best)]
|
||||
if need > 0 and winning:
|
||||
# ber kopku co najlacnejsie
|
||||
chosen = min(winning, key=_strength)
|
||||
elif need <= 0 and len(winning) < len(allowed):
|
||||
# kopku nechcem: zbav sa najsilnejsej neberucej karty
|
||||
chosen = max((c for c in allowed if not _beats(c, best)), key=_strength)
|
||||
elif need <= 0:
|
||||
# vsetko berie -> ber co najlacnejsie (setri silne karty netreba,
|
||||
# ale nizka karta drzi sancu, ze ma este niekto prebije)
|
||||
chosen = min(allowed, key=_strength)
|
||||
else:
|
||||
# kopku chcem, ale nic neberie -> odhod najslabsiu
|
||||
chosen = min(allowed, key=_strength)
|
||||
return card_index(chosen)
|
||||
|
||||
|
||||
class McPlayer(HeuristicPlayer):
|
||||
"""Heuristika s MC hracou fazou: kazdy kandidatsky tah sa ohodnoti
|
||||
simulaciami zvysku kola nad rozdaniami neznamych kariet konzistentnymi
|
||||
s dedukovanymi voidmi (`use_voids=False` = ablacia bez dedukcie).
|
||||
Tipovanie ostava MC tipper z HeuristicPlayer (pred prvou kartou niet
|
||||
z coho voidy dedukovat)."""
|
||||
|
||||
def __init__(self, rng: Random = None, n_samples: int = 100,
|
||||
play_samples: int = 24, use_voids: bool = True):
|
||||
super().__init__(rng, n_samples)
|
||||
self.play_samples = play_samples
|
||||
self.use_voids = use_voids
|
||||
|
||||
def play(self, rnd, seat: int) -> int:
|
||||
hand = rnd.player_cards[seat]
|
||||
stash = rnd.get_last_stash()
|
||||
first_card = stash.get_first_card() if stash else None
|
||||
candidates = legal_cards(hand, first_card)
|
||||
if len(candidates) == 1:
|
||||
return card_index(candidates[0])
|
||||
|
||||
target = rnd.guesses[seat]
|
||||
base_tricks = rnd.get_stashes_winner_summary()
|
||||
voids = deduce_voids(rnd) if self.use_voids else {}
|
||||
seen = set()
|
||||
played_count = Counter()
|
||||
for st in rnd.stashes:
|
||||
for other, card in st.get_cards().items():
|
||||
seen.add(card)
|
||||
played_count[other] += 1
|
||||
unknown = [c for c in cards if c not in seen and c not in hand]
|
||||
hand_size0 = 8 - rnd.round_number
|
||||
hand_sizes = {s: hand_size0 - played_count[s] for s in range(4) if s != seat}
|
||||
current_cards = stash.get_cards()
|
||||
|
||||
# spolocne rozdanie pre vsetkych kandidatov (common random numbers --
|
||||
# porovnavame tahy na tych istych svetoch, mensia variancia)
|
||||
scores = {card_index(c): 0 for c in candidates}
|
||||
for _ in range(self.play_samples):
|
||||
world = deal_consistent(unknown, hand_sizes, voids, self.rng)
|
||||
for candidate in candidates:
|
||||
sim_hands = {s: list(h) for s, h in world.items()}
|
||||
sim_hands[seat] = [c for c in hand if c != candidate]
|
||||
tricks = finish_round(sim_hands, dict(current_cards),
|
||||
stash.first_player, seat, candidate,
|
||||
list(base_tricks), self.rng)
|
||||
if tricks[seat] == target:
|
||||
scores[card_index(candidate)] += 1
|
||||
|
||||
best_index = max(scores, key=scores.get)
|
||||
if scores[best_index] == 0:
|
||||
# tip uz je (takmer) nedosiahnutelny -> aspon rozumny pravidlovy tah
|
||||
return super().play(rnd, seat)
|
||||
return best_index
|
||||