47 Commits
Author SHA1 Message Date
timandClaude Opus 4.8 04185a0d97 Frontend: prepinac vzhladu kariet (znaky vs suit-icon)
Toggle v hlavicke prepina znaky farieb medzi textovymi glyfmi a suit-icon obrazkami; volba perzistuje v localStorage. Gula ma v icon rezime hnedy popis, stredove ikonky srdca/gule/listu jemne zmensene aby sa neprekryvali s rohmi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:37:57 +02:00
timandClaude Opus 4.8 97c71cf6e1 Frontend: rozmery a farby dosky centralizovane do boardTheme
Sizing konstanty (seat/standings/guess/totals) presunute do boardTheme.ts a farebne odlisenie tipu v PlayerCircle (nad tip = terakota).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:37:48 +02:00
timandClaude Opus 4.8 1636757217 Statistiky: dokoncene hry rozdelene na s ludmi / s botmi
Karta "Dokoncene hry" (%) nahradena dvomi poctami dokoncenych hier
podla toho, ci na niektorom sedadle sedel bot (username "bot:...").
Zaroven oprava serializacie avg_game_duration_minutes -- Postgres
vracia z func.avg Decimal, ktory json.dumps nevie serializovat (500
na /api/admin/stats).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:25:32 +02:00
timandClaude Opus 4.8 8e18325660 Frontend: hamburger menu v hlavicke na mobile
Hlavicka GameListu sa na uzkych displejoch nezmestila do jedneho
riadku. Pod sm: breakpointom sa polozky zbalia do dropdownu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 01:16:40 +02:00
timandClaude Opus 4.8 973c279cbd Boti: pauza pred vedenim kopky, spravne poradie broadcastov po tahu
Bot doteraz vedel zahodit prvu kartu novej kopky uz po BOT_MOVE_DELAY_SECONDS
(0.8s), zatial co frontend zmetaciu animaciu predoslej kopky prehraval
1650ms -- karta tak "vyletela" uprostred zmetania. _run_bot_turns teraz pri
vedeni novej kopky caka TRICK_SWEEP_SECONDS (1.7s), zhodne s SETTLE_MS+
COLLECT_MS v GameTable.tsx.

_run_bot_turns tiez posielal player_cards PRED game_status (opacne ako
human play_card handler) -- pri prechode do noveho kola tak klient dostal
novu ruku skor, nez vedel, ze zacalo nove kolo, a fixne ju hned zobrazil.
Poradie broadcastov je teraz zhodne s human handlerom.

GameTable/Hand: ruka noveho kola sa zobrazi az po dobehnuti zmetacej
animacie poslednej kopky predchadzajuceho kola (displayedHand + freeze
efekt), a Hand.tsx rezervuje fixny priestor pre karty aj ked je ruka
docasne prazdna, aby layout neposkakoval.

tests/test_bots.py: nulovanie TRICK_SWEEP_SECONDS v setUpClass, aby testy
zostali rychle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 01:09:59 +02:00
timandClaude Fable 5 b1010ae008 Frontend: pridavanie botov v lobby
Hostitel prida na volne sedadlo bota (+ Bot = heuristika, + AI bot =
natrenovana siet) alebo bota odoberie; boti maju vlastnu ikonu a flag
is_bot v rosteroch. Surove ucty bot:<kind>-<n> sa vsade zobrazuju cez
displayName ako "Bot 2" / "AI bot 1" (stol, kopka, tabulky, historia).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:51:14 +02:00
timandClaude Fable 5 23de3ae3ac API: in-process boti ako hraci
Boti su sedadla bez socketu: ucty bot:<kind>-<n> v tabulke players
(nehijacknutelne, recyklovane medzi hrami), handlery add_bot/remove_bot
(len hostitel, pred startom) a tahova slucka _run_bot_turns s pauzou
BOT_MOVE_DELAY_SECONDS a MC/inferenciou v executori. Druhy: heuristic,
random, neural (pure-Python siet; bez suboru vah jasna chyba a pri
restore fallback na heuristiku). Botie sedadla preziju restart servera.

Oprava po ceste: emit kariet hracovi so sid=None (offline sedadlo po
restore) broadcastoval jeho karty vsetkym klientom -- preskakuje sa.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:51:14 +02:00
timandClaude Fable 5 9a750756c5 RL: pure-Python inferencia natrenovanej siete
py -m rl.export vyexportuje checkpoint do rl/weights/neural-bot.json
(bit-exact float32, 1.3 MB) a rl/pure_net.py ho hra bez torch/numpy
(stdlib forward pass, ~16 ms/tah). Testy parity: logity aj akcie sa
zhoduju s torch, identicke trajektorie celych kol. Natrenovany model:
6.7-6.9 b/kolo proti vsetkym baseline-om (heuristika prekonana).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:49:55 +02:00
timandClaude Fable 5 8f2449a408 RL: self-play PPO trening siete
BridzikNet (trup + guess/play/value hlavy s maskovanim), self-play
generator so zdielanou sietou na 4 sedadlach a opponent mixingom
(random/heuristicke sedadla pre robustnost), vlastny clipped-PPO
so skalovanim odmien a lr/entropy annealom. Torch je len trenovacia
zavislost na hoste (requirements-rl.txt); checkpointy a logy su
gitignorovane. Spustenie: py -m rl.train.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:49:55 +02:00
timandClaude Fable 5 e1733f4943 RL: baseline boti a evaluacny harness
RandomPlayer, HeuristicPlayer (MC tipper nad rozdaniami neznamych kariet
+ tipom riadena hracia heuristika) a McPlayer (MC ohodnotenie kazdeho
kandidatskeho tahu nad rozdaniami konzistentnymi s dedukovanymi voidmi).
Evaluacia s rotaciou sedadiel: py -m rl.evaluate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:49:55 +02:00
timandClaude Fable 5 3710a68e37 RL: encoding observacii, akcne masky a Round prostredie
Zaklad RL vrstvy (rl/DESIGN.md): egocentricky rotovana observacia
(ruka, videne karty, tipy, kopka, dedukovane voidy -- 233 dim), masky
legalnych tipov/kariet zrkadliace pravidla enginu a RoundEnv (jedno kolo
= jedna self-play epizoda). Fuzz-testy vynucuju zhodu masiek s enginom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:49:55 +02:00
timandClaude Fable 5 1b14ace2cd Engine: Card a farby/hodnoty su hashovatelne
Vlastne __eq__ rusilo zdedeny __hash__, takze karty ani enumy sa nedali
pouzit ako kluce dictov a v setoch. Hash doplneny konzistentne s __eq__.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:49:55 +02:00
timandClaude Opus 4.8 f17f85ebd9 Trik: zmetanie po kazdom stichu, ovalne meno, dohrata hra mizne z lobby
Zmetacia animacia doteraz dobehla len na konci kola/serie (dlha pauza) --
mid-round ju hned prerusila dalsia zahrata karta, lebo `finishing` zavisela
na activeCards === 0. Teraz drzi kopku v strede az do konca animacie a
zablokuje hranie karty, kym sa nezmetie; boot-guard zabrani falosnej
animacii pri reconnecte na uz rozohratu hru.

PlayerCircle: ovalne oramovanie mena teraz rastie s dlzkou mena
(fit-content + strop + ellipsis) namiesto pevnej sirky.

api: hra dohrata do konca (4 serie) sa hned vytrati z lobby zoznamu,
namiesto toho aby visela navzdy ako "Začatá"/"Pokračovať" aj ked uz ma
v DB ended_at.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:49:40 +02:00
timandClaude Opus 4.8 558c8ec2d9 TASKS.md: drobne formatovanie zoznamu Hotovo
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:01:30 +02:00
timandClaude Opus 4.8 b1ca4bc78d TASKS.md: presun dokoncenej ulohy (scrollbar skore) do Hotovo
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:01:14 +02:00
timandClaude Opus 4.8 6c95c64398 Skore: slim scrollbar v style hry namiesto OS defaultu
Zoznam kol v Standings (herny sidebar aj mobilny panel) pouzival hrubu
systemovu lavicu scrollbaru, ktora nesedela do velvet/gold temy. Novy
.velvet-scroll (index.css) da tenky priehladny track a zlaty polopriehladny
thumb (webkit + Firefox scrollbar-color).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:00:56 +02:00
timandClaude Opus 4.8 ca0cf413ee TASKS.md: presun dokoncenej ulohy (trik animacia) do Hotovo
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:00:04 +02:00
timandClaude Opus 4.8 fb90737944 Trik: posledna karta sa prida bez blikania, kopka sa zmetie k vitazovi
Doteraz sa pri hodeni 4. karty cela kopka na frame stratila (displayedStash
padol na lingeredStash nastaveny az v useEffekte) a nasledne sa vsetky karty
znovu vlietli. Teraz sa previous_stash cita synchronne, takze posledna karta
len pribudne k trom uz leziacim, a potom sa cela kopka odsunie animaciou
smerom k sedadlu, ktore kopku vyhralo (stashWinner podla pravidiel enginu).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 10:59:42 +02:00
tim 1fbba5a7e1 TASKS.md: presun dvoch dokoncenych ulohy do Hotovo 2026-07-07 00:21:49 +02:00
tim 80929e7fed Skore: scrollovatelny zoznam s collapse serii, zrusenie preciarkavania
Standings.tsx rozdeleny na fixnu hlavicku, scrollovatelny zoznam kol
(overflow-y-auto, na mobile max-h-45vh) a fixne sucty, takze pri viacerych
odohratych kolach je vidno cely priebeh. Dokoncena seria (riadok Sigma) je
teraz klikatelna a zbaluje/rozbaluje svoje kola.

Zaroven zruseny line-through pri neuspesnom tipe v hernom pohlade aj v
detaile hry (History.tsx) -- neuspesny tip sa teraz len zobrazuje tlmenou farbou.
2026-07-07 00:21:34 +02:00
tim 9ade92d7fe Postgres kontajner: nastavenie timezone na Europe/Bratislava 2026-07-04 19:48:40 +02:00
timandClaude Fable 5 5b9d6342ad Auth: nedokoncena registracia sa da dokoncit novym QR kodom
Ucet bez potvrdeneho kodu (auth_token is NULL a totp_last_step == 0)
uz neblokuje meno: opakovany register_account vyda novy secret (stary
QR prestane platit) a login vyhodi RegistrationIncomplete, na ktoru
server odpovie novym QR -- klient sa prepne na registracny tab.
Admin statistiky vykazuju nedokoncene registracie osobitne, Hraci
celkom pocita len potvrdene ucty. Novy event register v analytike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:34:03 +02:00
timandClaude Fable 5 53274607b1 Donate stranka: PAY by square QR s vyberom sumy
Linka Na kavu v hlavicke lobby vedie na /donate. QR sa generuje
dynamicky (balicek bysquare) pre presety 1/2/5 EUR alebo vlastnu sumu;
IBAN sa da skopirovat do schranky. Navstevy loguje existujuci
pageview beacon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:54:49 +02:00
timandClaude Fable 5 aed0cd5d7f Analytika: /auth sa uz nesleduje -- 1:1 duplicita landing eventu
Na /auth sa da dostat len plnym loadom stranky (vsetky interne
redirecty nan su REPLACE a beacon ich skipuje) a kazdy plny load uz
posiela event landing. Kazdy riadok /auth by tak mal dvojicku z toho
isteho loadu. Skip na frontende aj v _SKIPPED_PATHS (pokryje aj stare
cache-ovane verzie frontendu). Stare /auth riadky v DB ostavaju.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:06:39 +02:00
timandClaude Fable 5 42f8dfdcf0 Analytika: landing event zachytava prvu navstevu aj s referrerom
Navsteva zacinajuca na / (preklik z FB/Messengeru, natukanie domeny)
doteraz nezanechala v page_views ziadny zaznam: / je v skip liste
beaconu a gate redirect na /auth je REPLACE navigacia, takze sa skipli
oba hopy a referrer sa stratil. trackLanding() v main.tsx posle raz za
kazdy plny load stranky pomenovany event landing s document.referrer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 15:39:11 +02:00
tim 652a5aa66e Merge branch 'fixy' 2026-07-04 14:05:19 +02:00
tim 7be89e70c8 pridanie pgAdmin 2026-07-04 13:45:41 +02:00
timandClaude Sonnet 5 28bb045274 Rozsirenie analytiky: login event, prepinac vsetci/prihlaseni, plny nazov krajiny
Pageview beacony uz neposielaju player_id z klienta (nedovereny vstup na
neautentifikovanom endpointe) -- prihlasenie sa eviduje server-side ako
event "login" (aj z registracie), s player_id skutocneho uctu. Admin
dashboard dostal prepinac scope vsetci/prihlaseni, ktory konzistentne
pocita grafy aj tabulky (kazdy login samostatne, navstevnicky den pre
anonymnu navstevnost). PageView.country teraz uklada cely anglicky nazov
krajiny namiesto ISO kodu (potrebna zmena schemy). Pridane sledovanie
klikov na "Pravidla hry" aj z GameList.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 12:43:37 +02:00
tim 7886b3a6b8 fix rusenie hier ked vsetkych odpoji 2026-07-03 19:16:41 +02:00
tim c4800fca0e Oprava hashu commitu v TASKS.md pre hotovu ulohu 2026-07-03 10:41:51 +02:00
tim 49ac0d3e78 Kopirovat celu URL linku na hru namiesto holeho gid v Lobby
TASKS.md: uloha presunuta z Nedokoncene do Hotovo.
2026-07-03 10:41:40 +02:00
tim 3615fa2bb1 frontendove optimalizacie 2026-07-03 01:11:26 +02:00
tim 7a5202195b docker-compose update external network 2026-07-01 21:04:04 +02:00
tim 4f0b706c28 docker-compose pre prod (non final) 2026-07-01 20:05:40 +02:00
timandClaude Sonnet 5 fbe0c3aa18 Add login lockout and encrypt Player.totp_secret/auth_token at rest
Per-username lockout (5 failed TOTP attempts / 5 min) stops account-targeted
brute force regardless of source IP. Player.totp_secret is now Fernet-
encrypted (ENCRYPTION_KEY env, db/crypto.py) instead of stored in plaintext,
and auth_token is stored as a SHA-256 hash rather than the raw session token.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 19:49:56 +02:00
timandClaude Sonnet 5 0845562a21 Add self-hosted usage analytics: pageview tracking + admin stats dashboard
Records pageviews (path, referrer, browser/OS/device, IP, GeoIP country) via
a POST /api/track beacon into a new PageView table, and exposes aggregated
daily/breakdown stats behind a token-gated GET /api/admin/stats endpoint with
brute-force lockout. Frontend gets a /admin dashboard (charts + breakdown
tables) built on recharts, with a switchable per-day pageviews chart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 19:43:10 +02:00
timandClaude Sonnet 5 c59dca754f Tidy up root-level files: drop dead config.py, group tests into tests/
- Remove config.py, an unused Flask SECRET_KEY leftover from before the
  legacy HTTP backend was replaced by the Socket.IO/ASGI server.
- Move tests.py / tests_history.py / test_socket.py into a tests/
  package as test_engine.py / test_history.py / test_socket.py, and
  update CLAUDE.md's documented commands to match.

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 00:11:42 +02:00
timandClaude Opus 4.8 30c32b7714 Add persistence layer: TOTP auth, game history, restore
- db/ package: async SQLAlchemy engine + Player/Game/Guess models
- api/auth.py: passwordless TOTP login (pyotp), session token via socket auth
- api/history.py: record guesses/points, DB-backed standings, restore
  unfinished games on startup, host-only end_game
- api/__init__.py: auth-gated handlers, accounts map, rejoin via account
- frontend: Auth (QR + code) and History pages, resume/end-game in lobby/table
- docker-compose: real PostgreSQL service wired via DATABASE_URL
- tests_history.py for the persistence/auth layer; refresh CLAUDE.md

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:40:13 +02:00
tim 00937aa89f Add Bridzik.get_player_cards and make get_status player-agnostic
Engine support for the per-connection Socket.IO API: hands are fetched
explicitly via get_player_cards(player) and the shared status no longer
embeds a single player's cards.
2026-06-13 23:40:04 +02:00
Frantisek F cd3d84319e Add main socket communication 2022-07-05 21:28:05 +02:00
Frantisek F 3053040da9 Add docker and sockets 2022-07-05 15:58:23 +02:00
116 changed files with 17440 additions and 466 deletions
+32
View File
@@ -0,0 +1,32 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/docker-existing-dockerfile
{
"name": "Existing Dockerfile",
// Sets the run context to one level up instead of the .devcontainer folder.
"context": "..",
// Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename.
"dockerFile": "../Dockerfile",
// Set *default* container specific settings.json values on container create.
"settings": {},
// Add the IDs of extensions you want installed when the container is created.
"extensions": []
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Uncomment the next line to run commands after the container is created - for example installing curl.
// "postCreateCommand": "apt-get update && apt-get install -y curl",
// Uncomment when using a ptrace-based debugger like C++, Go, and Rust
// "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ],
// Uncomment to use the Docker CLI from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker.
// "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" ],
// Uncomment to connect as a non-root user if you've added one. See https://aka.ms/vscode-remote/containers/non-root.
// "remoteUser": "vscode"
}
+15
View File
@@ -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
+30
View File
@@ -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 "".
+13 -5
View File
@@ -1,6 +1,14 @@
__pycache__/ __pycache__/
.flaskenv *.pyc
/.vscode .venv/
/logs *.db
/env-bridzik-dev .idea/
*.png frontend/node_modules/
frontend/dist/
frontend/.vite/
.env
.env.*
!.env.example
geoip/*.mmdb
rl/runs/
rl/checkpoints/
+81
View File
@@ -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.
+24
View File
@@ -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"]
+129
View File
@@ -0,0 +1,129 @@
# Pravidlá hry Bridžik
Tento dokument popisuje pravidlá hry tak, ako sú implementované v hernom jadre
(`bridzik.py`). Slúži ako referencia pre hráčov aj vývojárov.
## Karty
Hrá sa s **32-kartovým balíčkom** sedmových (slovenských/nemeckých) kariet.
### Farby (4)
| Názov v kóde | Slovenský názov |
| ------------ | --------------- |
| `HEARTS` | červeň |
| `LEAVES` | zeleň |
| `ACORNS` | žaluď |
| `BELLS` | guľa |
**Červeň je vždy tromf (adut).** Červeň prebíja každú inú farbu.
### Hodnoty (8) — od najnižšej po najvyššiu
`sedmička (7) < osmička (8) < deviatka (9) < desiatka (10) < dolník < horník < kráľ < eso`
V kóde: `C7 < C8 < C9 < C10 < LOWER < UPPER < KING < ACE`.
## Štruktúra hry
Hru hrajú **4 hráči** (očíslovaní 0, 1, 2, 3). Celá hra má pevnú štruktúru:
- **Hra** = 4 **série** (séria 0 až 3).
- **Séria** = 8 **kôl** (kolo 0 až 7).
- **Kolo** = `8 číslo_kola` **kopiek** (zdvihov).
V každom kole dostane každý hráč `8 číslo_kola` kariet, takže počet kariet
postupne klesá:
| Číslo kola | Kariet na hráča | Počet kopiek v kole |
| ---------: | --------------: | ------------------: |
| 0 | 8 | 8 |
| 1 | 7 | 7 |
| 2 | 6 | 6 |
| 3 | 5 | 5 |
| 4 | 4 | 4 |
| 5 | 3 | 3 |
| 6 | 2 | 2 |
| 7 | 1 | 1 |
Celá hra teda obsahuje 4 × 8 = 32 kôl.
## Rozdávanie
Na začiatku každého kola sa balíček zamieša. Z vrchu sa odloží `4 × číslo_kola`
kariet a zvyšok sa rozdá: každý zo 4 hráčov dostane `8 číslo_kola` kariet.
## Kto začína
- **Sériu** otvára hráč s rovnakým číslom, ako je číslo série (séria 0 → hráč 0,
séria 1 → hráč 1, …).
- **Kolo** otvára hráč `(prvý_hráč_série + číslo_kola) mod 4`, čiže začínajúci
hráč sa medzi kolami posúva.
## Priebeh kola
Každé kolo má dve fázy: **tipovanie** a následne **hranie kariet**.
### 1. Tipovanie (bridžik)
Hráči postupne (počnúc začínajúcim hráčom kola, v smere poradia) zadávajú **tip**
koľko kopiek (zdvihov) v tomto kole získajú.
- Tip musí byť v rozsahu `0``počet kopiek v kole` (`8 číslo_kola`).
- **Pravidlo bridžika:** posledný (štvrtý) tipujúci hráč nesmie zadať taký tip,
pri ktorom by sa **súčet všetkých štyroch tipov rovnal počtu kopiek** v kole.
Inými slovami, súčet tipov sa nikdy nesmie presne rovnať počtu zdvihov — vždy
musí niekto „prebrať" alebo „nedobrať".
- Každý hráč zadáva tip iba raz a iba keď je na rade.
### 2. Hranie kariet
Po dokončení tipovania sa hrá `8 číslo_kola` kopiek (zdvihov).
- **Prvú kopku** otvára hráč s **najvyšším tipom**. Pri zhode tipov začína ten,
kto je skôr v poradí (počítané od začínajúceho hráča kola).
- **Každú ďalšiu kopku** otvára **víťaz predchádzajúcej kopky**.
#### Povinnosť priznať farbu
Keď je na stole vynesená (prvá) karta kopky, ďalší hráči musia dodržať:
1. Ak má hráč farbu vynesenej karty → **musí priznať farbu** (zahrať kartu tej
istej farby).
2. Ak farbu vynesenej karty nemá, ale má **červeň****musí zahrať červeň**
(tromf).
3. Ak nemá ani vynesenú farbu, ani červeň → môže zahrať **ľubovoľnú** kartu.
Hráč môže zahrať len kartu, ktorú má v ruke, a iba keď je na rade.
#### Vyhodnotenie kopky
Po zahraní všetkých 4 kariet sa určí víťaz kopky:
- **Červeň prebíja** každú inú farbu. Ak v kopke padla aspoň jedna červeň,
vyhráva **najvyššia červeň**.
- Ak nepadla žiadna červeň, vyhráva **najvyššia karta vynesenej farby**.
- Karty iných farieb (ktoré nie sú ani vynesená farba, ani červeň) kopku
vyhrať nemôžu.
Víťaz kopky vynáša do nasledujúcej kopky.
## Bodovanie
Body sa počítajú po každom dokončenom kole:
- Ak sa hráčov **tip presne zhoduje** s počtom kopiek, ktoré v kole získal,
dostane **`10 + tip`** bodov.
- Ak sa tip nezhoduje (získal viac alebo menej kopiek), dostane **0 bodov**.
Príklad: hráč tipoval 3 a získal presne 3 kopky → 13 bodov. Ak by získal 2 alebo
4 kopky → 0 bodov.
Celkové skóre hráča je súčet bodov zo všetkých kôl všetkých sérií. Vyhráva hráč
s najvyšším celkovým súčtom po dohraní všetkých 4 sérií.
## Ukončenie
- **Kolo** je ukončené, keď sú odohrané všetky kopky.
- **Séria** je ukončená po 8 kolách.
- **Hra** je ukončená po 4 sériách.
+20
View File
@@ -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. -->
+898 -18
View File
@@ -1,23 +1,903 @@
from flask import Flask import asyncio
import hmac
import json
import os import os
import logging import time
from logging.handlers import RotatingFileHandler import uuid
from config import Config from collections import defaultdict
from json import JSONEncoder
from urllib.parse import parse_qs
app = Flask(__name__) import socketio
app.config.from_object(Config)
if not app.debug: from bridzik import Bridzik, BridzikException, Card
if not os.path.exists('logs'): from db.db import init_db
os.mkdir('logs') from api import auth as auth_module, history
file_handler = RotatingFileHandler('logs/bridzik_api.log', maxBytes=10240, backupCount=10) from api import bots as bots_module
file_handler.setFormatter(logging.Formatter( from api import stats as stats_module
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]' from api.auth import AuthError, RegistrationIncomplete
)) from rl.encoding import index_card
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('Bridzik_API startup')
from api import routes def _env_bool(name: str, default: bool) -> bool:
val = os.environ.get(name)
if val is None:
return default
return val.lower() in ("1", "true", "yes", "on")
# --- configuration (env-driven, dev-friendly defaults) --------------------
_cors = os.environ.get("CORS_ALLOWED_ORIGINS", "*")
CORS_ALLOWED_ORIGINS = "*" if _cors == "*" else [o.strip() for o in _cors.split(",")]
SIO_LOGGER = _env_bool("SOCKETIO_LOGGER", False)
LOBBY = "lobby" # room every connection joins to receive the public game list
sio = socketio.AsyncServer(
async_mode="asgi",
cors_allowed_origins=CORS_ALLOWED_ORIGINS,
logger=SIO_LOGGER,
engineio_logger=SIO_LOGGER,
)
async def _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)
+163
View File
@@ -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}
+120
View File
@@ -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}
-22
View File
@@ -1,22 +0,0 @@
from flask_wtf import FlaskForm
from wtforms import SubmitField, IntegerField, RadioField, StringField
from wtforms.validators import DataRequired, NumberRange
class GuessForm(FlaskForm):
guess = IntegerField('Tip', validators=[DataRequired(message='Zadaj tip'), NumberRange(min=0, max=8)])
submit = SubmitField('Zadaj tip')
def __init__(self, max_guess: int = 8, *args, **kwargs):
super(GuessForm, self).__init__(*args, **kwargs)
self.max_guess = max_guess
class PlayForm(FlaskForm):
card = RadioField('Vyber kartu', validators=[DataRequired(message='Musíš vybrať kartu')])
submit = SubmitField('Zahraj')
class AdminForm(FlaskForm):
player0 = StringField('0', validators=[DataRequired()])
player1 = StringField('1', validators=[DataRequired()])
player2 = StringField('2', validators=[DataRequired()])
player3 = StringField('3', validators=[DataRequired()])
submit = SubmitField()
+331
View File
@@ -0,0 +1,331 @@
"""Zapis a citanie historie hier nad `db/`.
Cita hodnoty z ciste-Python enginu (bridzik.Bridzik) a uklada ich do DB.
Engine sa neupravuje -- pouzivame len jeho existujuce metody.
"""
from datetime import datetime, timezone
from random import shuffle
from sqlalchemy import func, or_, select
from bridzik import Bridzik, ROUNDS_PER_SERIES, Round, SERIES_PER_GAME, Series
from db.db import async_session
from db.models import Game, Guess, Player
# Naplno dohrana hra ma zapisanych SERIES_PER_GAME * ROUNDS_PER_SERIES
# dokoncenych kol -- tvar hry je definovany v bridzik.py, tu sa len cita.
FULL_GAME_ROUNDS = SERIES_PER_GAME * ROUNDS_PER_SERIES
def _utcnow_naive() -> datetime:
"""Naive UTC `datetime` na zapis do `ended_at`. Stlpec je TIMESTAMP WITHOUT
TIME ZONE (ako `created_at`), tz-aware hodnotu by asyncpg/Postgres odmietol."""
return datetime.now(timezone.utc).replace(tzinfo=None)
async def record_game_started(gid: str, name: str, player_ids: list[int]) -> None:
"""Zapise riadok Game so 4 ID hracov (podla sedadla). Idempotentne."""
async with async_session() as session:
if await session.get(Game, gid) is not None:
return
session.add(
Game(
id=gid,
name=name,
player0_id=player_ids[0],
player1_id=player_ids[1],
player2_id=player_ids[2],
player3_id=player_ids[3],
)
)
await session.commit()
async def record_completed_rounds(gid: str, core) -> None:
"""Zapise 4 Guess-y za kazde nove dohrate kolo; po dohrani hry vyplni ended_at.
Idempotentne: kola uz zapisane v DB sa preskakuju.
"""
async with async_session() as session:
game = await session.get(Game, gid)
if game is None:
return
rows = await session.execute(
select(Guess.series_number, Guess.round_number)
.where(Guess.game_id == gid)
.distinct()
)
already = {(r.series_number, r.round_number) for r in rows}
for series in core.series:
for rnd in series.rounds:
if not rnd.is_completed():
continue
key = (series.series_number, rnd.round_number)
if key in already:
continue
points = rnd.get_points_summary() # list[4], body za kolo
for seat in range(4):
session.add(
Guess(
game_id=gid,
player_id=game.player_id_for_seat(seat),
series_number=series.series_number,
round_number=rnd.round_number,
guess=rnd.guesses[seat],
points=points[seat],
)
)
# Priebezne uloz aktualnu poziciu hry (na restore).
last_series = core.series[-1]
game.series = last_series.series_number
game.round = last_series.get_last_round().round_number
if core.is_completed() and game.ended_at is None:
game.ended_at = _utcnow_naive()
await session.commit()
async def get_standings(gid: str) -> tuple[list[list[list[int]]], list[list[list[int]]]]:
"""Body aj tipy po seriach/kolach z DB, oboje v tvare ktory caka frontend:
`[serie][kolo][sedadlo 0..3]`. Vracia dvojicu `(points, guesses)` -- tipy
su tam, aby frontend pri 0 bodoch ukazal preskrtnuty tip namiesto nuly.
Citaju sa z tych istych `Guess` riadkov, takze jeden dotaz staci.
"""
async with async_session() as session:
game = await session.get(Game, gid)
if game is None:
return [], []
seat_of = {
game.player0_id: 0,
game.player1_id: 1,
game.player2_id: 2,
game.player3_id: 3,
}
rows = (
await session.scalars(
select(Guess)
.where(Guess.game_id == gid)
.order_by(Guess.series_number, Guess.round_number)
)
).all()
# series_map[serie][kolo] = ([body sedadiel], [tipy sedadiel])
series_map: dict[int, dict[int, tuple[list[int], list[int]]]] = {}
for gz in rows:
rounds = series_map.setdefault(gz.series_number, {})
points, tips = rounds.setdefault(gz.round_number, ([0, 0, 0, 0], [0, 0, 0, 0]))
seat = seat_of.get(gz.player_id)
if seat is not None:
points[seat] = gz.points
tips[seat] = gz.guess
points_table: list[list[list[int]]] = []
guesses_table: list[list[list[int]]] = []
for s in sorted(series_map):
round_nums = sorted(series_map[s])
points_table.append([series_map[s][r][0] for r in round_nums])
guesses_table.append([series_map[s][r][1] for r in round_nums])
return points_table, guesses_table
async def get_player_history(player_id: int) -> list[dict]:
"""Zoznam hier daneho hraca (najnovsie prve) so sumarom jeho bodov."""
async with async_session() as session:
stmt = (
select(Game)
.where(
Game.ended_at.is_not(None), # iba ukoncene hry
or_(
Game.player0_id == player_id,
Game.player1_id == player_id,
Game.player2_id == player_id,
Game.player3_id == player_id,
),
)
.order_by(Game.created_at.desc())
)
games = (await session.scalars(stmt)).all()
# Pocet dokoncenych kol na hru (na rozlisenie naplno dohranej hry od
# predcasne ukoncenej) -- jeden batch dotaz pre vsetky hry hraca.
completed_rounds = await _completed_rounds_per_game(
session, [g.id for g in games]
)
result = []
for g in games:
seat_ids = [g.player0_id, g.player1_id, g.player2_id, g.player3_id]
usernames = await _usernames_for(session, seat_ids)
total = await session.scalar(
select(func.coalesce(func.sum(Guess.points), 0)).where(
Guess.game_id == g.id, Guess.player_id == player_id
)
)
result.append(
{
"gid": g.id,
"name": g.name,
"created_at": g.created_at.isoformat() if g.created_at else None,
"ended_at": g.ended_at.isoformat() if g.ended_at else None,
"players": [usernames[pid] for pid in seat_ids],
"my_points": int(total or 0),
# True = dohrana naplno; False = predcasne ukoncena (da sa obnovit).
"completed": completed_rounds.get(g.id, 0) >= FULL_GAME_ROUNDS,
}
)
return result
async def get_game_detail(gid: str) -> dict | None:
"""Detail hry: tipy a body po kolach (won = points > 0)."""
async with async_session() as session:
game = await session.get(Game, gid)
if game is None:
return None
seat_ids = [game.player0_id, game.player1_id, game.player2_id, game.player3_id]
usernames = await _usernames_for(session, seat_ids)
guesses = (
await session.scalars(
select(Guess)
.where(Guess.game_id == gid)
.order_by(Guess.series_number, Guess.round_number, Guess.player_id)
)
).all()
return {
"gid": game.id,
"name": game.name,
"created_at": game.created_at.isoformat() if game.created_at else None,
"ended_at": game.ended_at.isoformat() if game.ended_at else None,
"players": [
{"player_id": pid, "username": usernames[pid]} for pid in seat_ids
],
"rounds": [
{
"series_number": gz.series_number,
"round_number": gz.round_number,
"player_id": gz.player_id,
"username": usernames.get(gz.player_id),
"guess": gz.guess,
"points": gz.points,
"won": gz.points > 0,
}
for gz in guesses
],
}
async def _usernames_for(session, player_ids: list[int]) -> dict[int, str]:
rows = await session.scalars(
select(Player).where(Player.id.in_(set(player_ids)))
)
return {p.id: p.username for p in rows}
async def _completed_rounds_per_game(session, gids: list[str]) -> dict[str, int]:
"""Pocet dokoncenych (series, round) kol na hru. Guess sa zapisuje len za
dohrate kola, takze pocet unikatnych dvojic = pocet dokoncenych kol."""
if not gids:
return {}
rows = await session.execute(
select(Guess.game_id, Guess.series_number, Guess.round_number)
.where(Guess.game_id.in_(gids))
.distinct()
)
counts: dict[str, int] = {}
for r in rows:
counts[r.game_id] = counts.get(r.game_id, 0) + 1
return counts
async def _restore_info(session, game: Game) -> dict:
"""Postavi restore-payload pre jednu hru: gid, name, sedadla (player_id +
username podla poradia 0..3) a uz postaveny Bridzik na ulozenej pozicii.
Spolocny tvar pre `reopen_game` aj `get_unfinished_games`."""
seat_ids = [game.player0_id, game.player1_id, game.player2_id, game.player3_id]
usernames = await _usernames_for(session, seat_ids)
return {
"gid": game.id,
"name": game.name,
"seats": [(pid, usernames.get(pid, "?")) for pid in seat_ids],
"core": rebuild_core(game.series, game.round),
}
async def reopen_game(gid: str, player_id: int) -> dict | None:
"""Znovu otvori predcasne ukoncenu hru: vymaze `ended_at` a vrati info na
obnovu do pamate (rovnaky tvar ako polozka z `get_unfinished_games`).
Vrati None, ak hra neexistuje, hrac v nej nie je, alebo uz bola dohrana
naplno (vtedy nie je co pokracovat).
"""
async with async_session() as session:
game = await session.get(Game, gid)
if game is None:
return None
seat_ids = [game.player0_id, game.player1_id, game.player2_id, game.player3_id]
if player_id not in seat_ids:
return None
counts = await _completed_rounds_per_game(session, [gid])
if counts.get(gid, 0) >= FULL_GAME_ROUNDS:
return None # naplno dohrana hra sa neobnovuje
game.ended_at = None
info = await _restore_info(session, game)
await session.commit()
return info
# --- restore ---------------------------------------------------------------
def rebuild_core(series_number: int, round_number: int, shuffler=shuffle) -> Bridzik:
"""Postavi Bridzik na danu poziciu (zaciatok kola), karty rozda nanovo.
Vsetko ostatne (rotacia first_player, pocet kariet) je deterministicke z
dvojice (series_number, round_number), takze tieto dve cisla staci na obnovu
hracej kostry. Tipy a rozohrate kopky aktualneho kola sa NEobnovuju -- kolo
sa zacne odznova; historicke body si vola get_game_detail z tabulky Guess.
"""
core = Bridzik(shuffler=shuffler)
# Doplnaj serie az po cielovu (kazda nova zacne svojim kolom 0).
while core.series[-1].series_number < series_number:
core.series.append(Series(len(core.series), shuffler=shuffler))
# V poslednej serii doplnaj kola az po cielove (Round rozda karty nanovo).
last = core.series[-1]
while last.get_last_round().round_number < round_number:
rn = len(last.rounds)
last.rounds.append(Round(rn, (last.first_player + rn) % 4, shuffler=shuffler))
return core
async def restore_game_core(gid: str, shuffler=shuffle) -> Bridzik | None:
"""Nacita poziciu hry z DB a vrati obnoveny Bridzik (alebo None)."""
async with async_session() as session:
game = await session.get(Game, gid)
if game is None:
return None
return rebuild_core(game.series, game.round, shuffler=shuffler)
async def mark_game_ended(gid: str) -> None:
"""Natrvalo ukonci hru (host ju zrusil, ked sa nedohra). Uz sa neobnovi."""
async with async_session() as session:
game = await session.get(Game, gid)
if game is not None and game.ended_at is None:
game.ended_at = _utcnow_naive()
await session.commit()
async def get_unfinished_games() -> list[dict]:
"""Nedohrate hry (ended_at IS NULL) aj s obnovenym jadrom -- na obnovu pri starte."""
async with async_session() as session:
games = (
await session.scalars(select(Game).where(Game.ended_at.is_(None)))
).all()
return [await _restore_info(session, g) for g in games]
-87
View File
@@ -1,87 +0,0 @@
from api import app
from bridzik import Bridzik, Card, Card_colors, Card_values, BridzikException
import json
from flask import render_template, url_for, flash, redirect
from api.forms import GuessForm, PlayForm, AdminForm
from api.utils import get_points_sums, sort_card_list
b = Bridzik()
players = [
'Jakub',
'Timo',
'Katka',
'Ondrej'
]
@app.route('/bridzik_api/get_status/<id>')
def get_status(id: int):
return json.dumps(b.get_status(int(id)), cls=Card.JSONEncoder)
@app.route('/bridzik/<player>/status')
def status(player):
player = int(player)
game_status = b.get_status(player)
action = None
form = None
player_cards = sort_card_list(b.series[-1].get_last_round().player_cards[player])
game_status['player_cards'] = [str(c) for c in player_cards]
points_sums = get_points_sums(game_status['standings'])
if b.is_completed() or b.series[-1].get_last_round().get_active_player() != player:
pass
elif not b.series[-1].get_last_round().is_guessing_completed():
form = GuessForm(max_guess= 8 - b.series[-1].get_last_round().round_number)
action = 'guess'
else:
form = PlayForm()
form.card.choices = [(str(c), str(c)) for c in player_cards]
action = 'play'
return render_template(
'status.html', status=game_status, player=player, action=action,
form=form, players=players, points_sums=points_sums
)
@app.route('/bridzik/<player>/guess', methods=['POST'])
def guess(player):
player = int(player)
form = GuessForm()
try:
b.add_player_guess(player, int(form.guess.data))
except BridzikException:
flash('Nie je možné zadať tip.')
return redirect(url_for('status', player=player))
@app.route('/bridzik/<player>/play_card', methods=['POST'])
def play_card(player):
player = int(player)
player_cards = b.series[-1].get_last_round().player_cards[player]
form = PlayForm()
form.card.choices = [(str(c), str(c)) for c in player_cards]
color, value = form.card.data.split('_')
try:
card = Card(Card_colors[color], Card_values[value])
except KeyError:
flash('Chyba. Opakuj pokus znovu.')
try:
b.play_card(player, card)
except BridzikException:
flash('Nie je možné zahrať kartu.')
return redirect(url_for('status', player=player))
@app.route('/bridzik/admin', methods=['GET', 'POST'])
def admin():
form = AdminForm()
if form.validate_on_submit():
players[0] = form.player0.data
players[1] = form.player1.data
players[2] = form.player2.data
players[3] = form.player3.data
return redirect(url_for('admin'))
else:
form.player0.data = players[0]
form.player1.data = players[1]
form.player2.data = players[2]
form.player3.data = players[3]
return render_template('admin.html', form=form)
-51
View File
@@ -1,51 +0,0 @@
#header {
display: flex;
}
#header>div {
padding: 10px;
border: 2px solid grey;
}
.state_row_header {
white-space: nowrap;
text-align: right;
}
.points {
text-align: center;
}
#wrapper {
display: grid;
grid-template-columns: 1fr 250px;
grid-template-rows: 1fr;
grid-gap: 10px;
}
.card_display {
width: 82px;
height: 150px;
}
#points_summary_row {
border-top: 4px double black;
}
table {
border-collapse: collapse;
}
.active_player_highlight {
background: grey;
color: white;
}
.player_name_highlight {
background: grey;
color: white;
}
.standings_round_final_row {
border-bottom: 1px solid black;
}
+369
View File
@@ -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},
}
-11
View File
@@ -1,11 +0,0 @@
<form action="{{ url_for('guess', player=player) }}" method="post">
{{ form.hidden_tag() }}
<p>
{{ form.guess.label }}:
{{ form.guess(size=15, autocomplete="off") }}
{% for error in form.guess.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
{{ form.submit() }}
</p>
</form>
-39
View File
@@ -1,39 +0,0 @@
<html>
<head>
<title>Bridžik admin</title>
</head>
<body>
<form action="" method="post">
{{ form.hidden_tag() }}
<p>
{{ form.player0.label }}:
{{ form.player0(size=15) }}
{% for error in form.player0.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form.player1.label }}:
{{ form.player1(size=15) }}
{% for error in form.player1.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form.player2.label }}:
{{ form.player2(size=15) }}
{% for error in form.player2.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form.player3.label }}:
{{ form.player3(size=15) }}
{% for error in form.player3.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>{{ form.submit() }}</p>
</form>
</body>
</html>
-176
View File
@@ -1,176 +0,0 @@
<html>
<head>
<title>Bridžik</title>
<link href="/static/style.css" rel="stylesheet">
{% if not action%}
<meta http-equiv="refresh" content="2">
{% endif %}
</head>
<body>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
<hr>
{% endif %}
{% endwith %}
{% if 'active_player' not in status %}
<h1>Hra sa skončila.</h1>
{% else %}
<div id="wrapper">
<div id="table">
<div id="header">
<div {% if player == status['active_player'] %}class="active_player_highlight"{% endif %}>
<p id="active_player" >Na ťahu je: {{ players[status['active_player']] }}</p>
</div>
<div id="active_round_guesses">
<p></p>
<table style="width: auto;">
<thead>
<th></th>
<th class="state_column_header">{{ players[0] }}</th>
<th class="state_column_header">{{ players[1] }}</th>
<th class="state_column_header">{{ players[2] }}</th>
<th class="state_column_header">{{ players[3] }}</th>
</thead>
<tbody>
<tr>
<th class="state_row_header">Tipy v tomto kole:</th>
{% for player in range(4) %}
<td class="points">{{ status['active_round_guesses'][player] }}</td>
{% endfor %}
</tr>
{% if status['active_round_stashes'] %}
<tr>
<th class="state_row_header">Kôpky v tomto kole:</th>
{% for player in status['active_round_stashes'] %}
<td class="points">{{ player }}</td>
{% endfor %}
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<hr>
{% if action == 'guess' %}
<h1>Zadaj tip:</h1>
{% include "_guess_form.html" %}
{% endif %}
{% if status['active_stash'] %}
<h1>Aktuálna kôpka:</h1>
<table style="width: auto;">
{% for player in range(status['active_stash']['first_player'], status['active_stash']['first_player'] + 4) %}
<th>{{ players[player % 4] }}</th>
{% endfor %}
<tr>
{% for player in range(status['active_stash']['first_player'], status['active_stash']['first_player'] + 4) %}
<td class="card_display">
{% if status['active_stash']['cards'][player % 4] %}
<img src="/static/cards/{{ status['active_stash']['cards'][player % 4] }}.png" width="80" height="auto">
{% endif %}
</td>
{% endfor %}
</tr>
</table>
{% endif %}
<hr>
{% if action != 'play' %}
<h1>Moje karty:</h1>
<table id="player_hand">
{% for card in status['player_cards'] %}
<td>
<img src="/static/cards/{{ card }}.png" width="80" height="auto">
</td>
{% endfor %}
</table>
{% else %}
<h1>{{ form.card.label }}:</h1>
<form action="{{ url_for('play_card', player=player) }}" method="post">
{{ form.hidden_tag() }}
<p>
<table style="width: auto">
{% for card in status['player_cards'] %}
<td>
<img src="/static/cards/{{ card }}.png" width="80" height="auto">
</td>
{% endfor %}
<tr>
{% for card in status['player_cards'] %}
<td>
<input id="card-{{ loop.index }}" name="card" type="radio" value="{{ card }}">
</td>
{% endfor %}
</tr>
</table>
{% for error in form.card.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
{{ form.submit() }}
</p>
</form>
{% endif %}
{% endif %}
{% if status['previous_stash'] %}
<h1>Predchádzajúca kôpka:</h1>
<table style="width: auto;">
{% for player in range(status['previous_stash']['first_player'], status['previous_stash']['first_player'] + 4) %}
<th>{{ players[player % 4] }}</th>
{% endfor %}
<tr>
{% for player in range(status['previous_stash']['first_player'], status['previous_stash']['first_player'] + 4) %}
<td class="card_display">
{% if status['previous_stash']['cards'][player % 4] %}
<img src="/static/cards/{{ status['previous_stash']['cards'][player % 4] }}.png" width="80" height="auto">
{% endif %}
</td>
{% endfor %}
</tr>
</table>
{% endif %}
</div>
<div id="standings">
<h1>Výsledky</h1>
<table style="width: auto;">
<tr>
<th>{{ players[0] }}</th>
<th>{{ players[1] }}</th>
<th>{{ players[2] }}</th>
<th>{{ players[3] }}</th>
</tr>
{% for series in status['standings'] %}
{% if series %}
{% for round in series %}
{% if round %}
<tr {% if loop.index == 7 %}class="standings_round_final_row"{% endif %} >
{% for player in round %}
<td class="points">{{ player }}</td>
{% endfor %}
</tr>
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
<tr id="points_summary_row">
{% for player in points_sums %}
<td class="points">{{ player }}</td>
{% endfor %}
</tr>
</table>
</div>
</div>
</body>
</html>
-23
View File
@@ -1,23 +0,0 @@
from bridzik import Card_colors
def get_points_sums(standings: []):
sums = [0] * 4
for series in standings:
for round in series:
for player, points in enumerate(round):
sums[player] += points
return sums
def sort_card_list(input_card_set: []) -> []:
color_paritions = [
[c for c in input_card_set if c.color == Card_colors['HEARTS']],
[c for c in input_card_set if c.color == Card_colors['LEAVES']],
[c for c in input_card_set if c.color == Card_colors['ACORNS']],
[c for c in input_card_set if c.color == Card_colors['BELLS']]
]
output_list = []
for color_list in color_paritions:
color_list.sort(key=lambda a : a.value)
output_list.extend(color_list)
return output_list
+6
View File
@@ -0,0 +1,6 @@
import uvicorn
if __name__ == "__main__":
# Local dev entrypoint: `python -m app`
# (production / Docker runs `uvicorn api:app` directly).
uvicorn.run("api:app", host="0.0.0.0", port=5000, reload=True)
+32 -10
View File
@@ -19,6 +19,10 @@ class Card_colors(Enum):
return self.name == other.name return self.name == other.name
return NotImplemented return NotImplemented
# vlastne __eq__ rusi zdedeny __hash__ -- obnovit konzistentne s __eq__
def __hash__(self):
return hash(self.name)
class Card_values(Enum): class Card_values(Enum):
C7 = 1 C7 = 1
@@ -51,6 +55,10 @@ class Card_values(Enum):
return self.name == other.name return self.name == other.name
return NotImplemented return NotImplemented
# vlastne __eq__ rusi zdedeny __hash__ -- obnovit konzistentne s __eq__
def __hash__(self):
return hash(self.name)
class Card(): class Card():
def __init__(self, color: Card_colors, value: Card_values): def __init__(self, color: Card_colors, value: Card_values):
@@ -63,6 +71,10 @@ class Card():
and self.value == other.value and self.value == other.value
return NotImplemented return NotImplemented
# vlastne __eq__ rusi zdedeny __hash__ -- obnovit konzistentne s __eq__
def __hash__(self):
return hash((self.color, self.value))
def __str__(self): def __str__(self):
return '{}_{}'.format(self.color.name, self.value.name) 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] cards = [Card(color, value) for value in Card_values for color in Card_colors]
# Sturktura hry: kazda hra ma SERIES_PER_GAME serii, kazda seria ma
# ROUNDS_PER_SERIES kol. Jediny zdroj pravdy pre tieto cisla -- ina vrstva
# (napr. api/history.py) ich odvodzuje odtialto, nikdy si ich nevymysla sama.
SERIES_PER_GAME = 4
ROUNDS_PER_SERIES = 8
class Bridzik(): class Bridzik():
def __init__(self, shuffler = shuffle): def __init__(self, shuffler=shuffle):
self.shuffler = shuffler self.shuffler = shuffler
self.series = [Series(0, 0, shuffler=self.shuffler)] self.series = [Series(0, 0, shuffler=self.shuffler)]
@@ -98,14 +117,17 @@ class Bridzik():
if self.is_completed(): if self.is_completed():
raise BridzikException('Hra je ukoncena.') raise BridzikException('Hra je ukoncena.')
self.series[-1].add_player_guess(player, guess) self.series[-1].add_player_guess(player, guess)
def get_status(self, player: int): def get_player_cards(self, player: int):
player_cards = self.series[-1].get_last_round().player_cards[player]
return {k: v for k, v in enumerate(player_cards)}
def get_status(self):
status = {} status = {}
last_series = self.series[-1] last_series = self.series[-1]
if not self.is_completed(): if not self.is_completed():
status['active_player'] = last_series.get_last_round().get_active_player() status['active_player'] = last_series.get_last_round().get_active_player()
status['active_round_guesses'] = last_series.get_last_round().guesses status['active_round_guesses'] = last_series.get_last_round().guesses
status['player_cards'] = last_series.get_last_round().player_cards[player]
if last_series.get_last_round().is_guessing_completed(): if last_series.get_last_round().is_guessing_completed():
status['active_round_stashes'] = last_series.get_last_round().get_stashes_winner_summary() status['active_round_stashes'] = last_series.get_last_round().get_stashes_winner_summary()
status['active_stash'] = { status['active_stash'] = {
@@ -121,7 +143,7 @@ class Bridzik():
return status return status
def is_completed(self): def is_completed(self):
return len(self.series) == 4 and self.series[-1].is_completed() return len(self.series) == SERIES_PER_GAME and self.series[-1].is_completed()
def get_previous_stash(self): def get_previous_stash(self):
if len(self.series[-1].get_last_round().stashes) > 1: if len(self.series[-1].get_last_round().stashes) > 1:
@@ -166,7 +188,7 @@ class Series():
self.start_new_round() self.start_new_round()
def is_completed(self): def is_completed(self):
return len(self.rounds) == 8 and self.get_last_round().is_completed() return len(self.rounds) == ROUNDS_PER_SERIES and self.get_last_round().is_completed()
def get_standings(self): def get_standings(self):
return [r.get_points_summary() for r in self.rounds if r.is_completed()] return [r.get_points_summary() for r in self.rounds if r.is_completed()]
@@ -186,9 +208,9 @@ class Series():
class Round(): class Round():
def __init__(self, round_number: int, first_player: int, cards: []=cards, shuffler = shuffle): def __init__(self, round_number: int, first_player: int, cards: []=cards, shuffler=shuffle):
# vyrob kopku pre toto kolo a priprav prazdne objekty # vyrob kopku pre toto kolo a priprav prazdne objekty
if round_number not in [i for i in range(8)]: if round_number not in range(ROUNDS_PER_SERIES):
raise BridzikException('Neplatne cislo kola.') raise BridzikException('Neplatne cislo kola.')
if first_player not in [0, 1, 2, 3]: if first_player not in [0, 1, 2, 3]:
raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.') raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.')
@@ -285,7 +307,7 @@ class Round():
def get_last_stash(self): def get_last_stash(self):
return self.stashes[-1] if self.stashes else None return self.stashes[-1] if self.stashes else None
def deal_starting_cards(self, cards: [], shuffler = shuffle): def deal_starting_cards(self, cards: [], shuffler=shuffle):
self.round_cards = cards.copy() self.round_cards = cards.copy()
shuffler(self.round_cards) shuffler(self.round_cards)
self.round_cards = self.round_cards[(4*self.round_number):] self.round_cards = self.round_cards[(4*self.round_number):]
@@ -308,7 +330,7 @@ class Round():
self.stashes.append(Stash(self.get_last_stash().get_winner())) self.stashes.append(Stash(self.get_last_stash().get_winner()))
class Stash(): class Stash:
def __init__(self, first_player: int): def __init__(self, first_player: int):
if first_player not in [0, 1, 2, 3]: if first_player not in [0, 1, 2, 3]:
raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.') raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.')
-2
View File
@@ -1,2 +0,0 @@
class Config(object):
SECRET_KEY = 'nbusr123'
+9
View File
@@ -0,0 +1,9 @@
"""Datova vrstva (persistencia). Nezavisla od Socket.IO/Flask, ako engine.
Exportuje pripojenie a ORM modely; aplikacna logika (api/) ich pouziva.
"""
from db.db import Base, engine, async_session, init_db
from db import models
__all__ = ["Base", "engine", "async_session", "init_db", "models"]
+42
View File
@@ -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()
+35
View File
@@ -0,0 +1,35 @@
"""Async SQLAlchemy pripojenie a inicializacia schemy.
Connection string z env DATABASE_URL. Default je lokalny SQLite subor (dev);
v produkcii staci nastavit DATABASE_URL na PostgreSQL (asyncpg), kod sa nemeni.
"""
import os
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///bridzik.db")
class Base(DeclarativeBase):
pass
engine = create_async_engine(DATABASE_URL, echo=False)
async_session: async_sessionmaker[AsyncSession] = async_sessionmaker(
engine, expire_on_commit=False
)
async def init_db() -> None:
"""Vytvori tabulky, ak este neexistuju. Vola sa pri starte servera."""
# Import modelov registruje tabulky na Base.metadata.
from db import models # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+112
View File
@@ -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()
)
+69
View File
@@ -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
+56
View File
@@ -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:
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.git
+20
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="sk">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#090e0b" />
<title>Bridžik</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;1,400&family=DM+Sans:wght@300;400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+38
View File
@@ -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;
}
}
+7512
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+131
View File
@@ -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>
);
}
+123
View File
@@ -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>
);
}
+43
View File
@@ -0,0 +1,43 @@
interface Props {
/** Exact number of cards the player still holds. */
count: number;
/** Row (top player) or column (side players). */
direction: 'row' | 'col';
desktop?: boolean;
}
/** Overlapping fan of face-down cards next to an opponent's circle — one card
* per card still in their hand, so the stack shrinks as they play. */
export default function FaceDownCards({ count, direction, desktop = false }: Props) {
const n = Math.max(0, Math.min(count, 8));
if (n === 0) return null;
const row = direction === 'row';
const w = desktop ? 40 : 25;
const h = desktop ? 56 : 36;
const overlap = row ? Math.round(w * 0.45) : Math.round(h * 0.5);
return (
<div className="flex" style={{ flexDirection: row ? 'row' : 'column' }}>
{Array.from({ length: n }).map((_, i) => (
<div
key={i}
style={{
width: w,
height: h,
marginLeft: row && i > 0 ? -overlap : 0,
marginTop: !row && i > 0 ? -overlap : 0,
zIndex: i,
background:
i % 2 === 0
? 'linear-gradient(150deg,#1d4a28,#0e2818)'
: 'linear-gradient(150deg,#1b4424,#0d2616)',
borderRadius: 3,
border: '1px solid rgba(201,168,76,.18)',
boxShadow: '0 2px 6px rgba(0,0,0,.5)',
}}
/>
))}
</div>
);
}
+57
View File
@@ -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>
);
}
+109
View File
@@ -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>
);
}
+100
View File
@@ -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>
</>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket';
interface Props {
onClose: () => void;
}
/** Prompt for a new game's name. Identity is taken from the logged-in session,
* so no player name is asked here. */
export default function NameModal({ onClose }: Props) {
const [name, setName] = useState('');
const myPlayer = useGameStore((s) => s.myPlayer);
// Close once we've joined the just-created game (create → register chain done).
useEffect(() => {
if (myPlayer) onClose();
}, [myPlayer, onClose]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
// Stores the name; the socket listener auto-chains register_player.
emit.createGame(trimmed);
};
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-40 p-4">
<div className="bg-header border border-[#142018] rounded-2xl p-6 w-full max-w-sm shadow-[0_28px_88px_rgba(0,0,0,.65)]">
<h2 className="font-serif text-xl mb-4 text-gold">Názov hry</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={30}
placeholder="Napr. Večerná partia"
className="bg-circle text-green-score rounded-lg px-4 py-2 border border-gold/20 outline-none focus:border-gold/60 focus:ring-1 focus:ring-gold/30 placeholder:text-green-dim/60"
/>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg text-green-dim hover:text-gold"
>
Zrušiť
</button>
<button
type="submit"
disabled={!name.trim()}
className="px-4 py-2 rounded-lg bg-gold text-table font-serif font-semibold disabled:opacity-40 hover:bg-gold-bright transition-colors"
>
Vytvoriť
</button>
</div>
</form>
</div>
</div>
);
}
+73
View File
@@ -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>
);
}
+83
View File
@@ -0,0 +1,83 @@
interface Props {
onClose: () => void;
}
export default function RulesModal({ onClose }: Props) {
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/70 p-4 overflow-y-auto"
onClick={onClose}
>
<div
className="relative bg-header border border-[#142018] rounded-2xl w-full max-w-lg my-6 p-6 text-sm leading-relaxed text-green-score shadow-[0_28px_88px_rgba(0,0,0,.65)]"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
className="absolute top-4 right-4 text-green-dim hover:text-gold text-xl leading-none"
>
</button>
<h1 className="font-serif text-2xl text-gold mb-4">Pravidlá hry Bridžik</h1>
<Section title="Karty">
<p>Hrá sa s <b>32-kartovým balíčkom</b> sedmových (slovenských/nemeckých) kariet.</p>
<p className="mt-2"><b>Farby:</b> červeň (), zeleň (), žaluď (), guľa ()</p>
<p className="mt-1 text-red-400 font-semibold">Červeň je vždy tromf (adut) prebíja každú inú farbu.</p>
<p className="mt-2"><b>Hodnoty</b> od najnižšej: VII · VIII · IX · X · J · Q · K · A</p>
</Section>
<Section title="Štruktúra hry">
<p>4 hráči · 4 série · 8 kôl v sérii</p>
<p className="mt-1">V každom kole dostane každý hráč <b>8 číslo_kola</b> kariet (8 1).</p>
<p className="mt-1">Sériu otvára hráč s rovnakým číslom ako séria. Každé ďalšie kolo posúva začínajúceho hráča o jedného.</p>
</Section>
<Section title="Priebeh kola">
<p className="font-semibold">1. Tipovanie</p>
<p className="mt-1">Každý hráč tipuje, koľko kopiek v kole získa (0 počet kopiek).</p>
<p className="mt-1 text-gold-dim">Pravidlo bridžika: súčet tipov nesmie presne rovnať počtu kopiek v kole posledný tipujúci nemôže zadať tip, ktorý by toto spôsobil.</p>
<p className="font-semibold mt-3">2. Hranie kariet</p>
<p className="mt-1">Prvú kopku otvára hráč s <b>najvyšším tipom</b>. Každú ďalšiu otvára víťaz predchádzajúcej kopky.</p>
<p className="font-semibold mt-3">Povinnosť priznať farbu:</p>
<ol className="mt-1 list-decimal list-inside space-y-1">
<li>Máš farbu vynesenej karty <b>musíš ju zahrať.</b></li>
<li>Nemáš ju, ale máš červeň <b>musíš zahrať červeň.</b></li>
<li>Nemáš ani jedno môžeš zahrať <b>ľubovoľnú</b> kartu.</li>
</ol>
<p className="font-semibold mt-3">Víťaz kopky:</p>
<ul className="mt-1 list-disc list-inside space-y-1">
<li>Ak padla červeň vyhráva <b>najvyššia červeň.</b></li>
<li>Ak nie vyhráva <b>najvyššia karta vynesenej farby.</b></li>
</ul>
</Section>
<Section title="Bodovanie">
<p>Po každom kole: ak sa tip <b>presne zhoduje</b> s počtom získaných kopiek <b>10 + tip</b> bodov, inak <b>0</b>.</p>
<p className="mt-1 text-green-dim">Príklad: tipoval 3, získal 3 13 bodov. Tipoval 3, získal 2 0 bodov.</p>
<p className="mt-2">Vyhráva hráč s najvyšším celkovým súčtom po 4 sériách.</p>
</Section>
<button
onClick={onClose}
className="mt-4 w-full py-2.5 rounded-xl border border-gold/30 text-gold hover:bg-gold hover:text-table font-serif font-semibold transition-colors"
>
Zavrieť
</button>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-4">
<h2 className="font-serif text-base text-gold mb-1">{title}</h2>
<div className="text-green-score">{children}</div>
</div>
);
}
+214
View File
@@ -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>
);
}
+67
View File
@@ -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>
);
}
+111
View File
@@ -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); }
}
+39
View File
@@ -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;
+58
View File
@@ -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`;
}
+52
View File
@@ -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;
}
+10
View File
@@ -0,0 +1,10 @@
import { NavigateFunction } from 'react-router-dom';
import { useGameStore } from '../store/gameStore';
import { emit } from './socket';
export function leaveGame(navigate: NavigateFunction) {
emit.leaveGame();
useGameStore.getState().reset();
localStorage.removeItem('bridzik_player');
navigate('/', { replace: true });
}
+22
View File
@@ -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})`;
}
+144
View File
@@ -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}`);
});
}
+3
View File
@@ -0,0 +1,3 @@
export function computeTotal(standings: number[][][], playerOrder: number): number {
return standings.flat().reduce((sum, round) => sum + (round[playerOrder] ?? 0), 0);
}
+23
View File
@@ -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);
}
+45
View File
@@ -0,0 +1,45 @@
import { useLayoutEffect, useRef, useState } from 'react';
/**
* Zooms the desktop board to fill the whole window. The board is a canvas of
* fixed height (`designHeight`) whose width is computed to span the viewport,
* and the scale is driven by height — so everything (cards, circles, text,
* header) grows and shrinks together while the felt always uses the full width.
*
* Returns the container ref (the viewport), the `scale` for `transform`, and
* `contentWidth` — the pre-scale canvas width (`viewportWidth / scale`) so that
* after scaling it exactly fills the viewport width.
*
* `deps` should change when the layout swaps (mobile↔desktop) so the observer
* re-attaches to the freshly rendered element.
*/
export function useFitScale(deps: unknown[] = [], designHeight = 860, maxScale = 2.6) {
const containerRef = useRef<HTMLDivElement>(null);
const [box, setBox] = useState({ scale: 1, contentWidth: 1280 });
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const measure = () => {
const availW = el.clientWidth;
const availH = el.clientHeight;
if (!availW || !availH) return;
const scale = Math.min(maxScale, availH / designHeight);
const contentWidth = availW / scale;
setBox((prev) =>
Math.abs(prev.scale - scale) > 0.004 || Math.abs(prev.contentWidth - contentWidth) > 1
? { scale, contentWidth }
: prev,
);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { containerRef, scale: box.scale, contentWidth: box.contentWidth };
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
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;
}
+20
View File
@@ -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>
);
+192
View File
@@ -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>
);
}
+113
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+50
View File
@@ -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>
);
}
+411
View File
@@ -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>
);
}
+293
View File
@@ -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>
);
}
+112
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+200
View File
@@ -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>
);
}
+108
View File
@@ -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>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { create } from 'zustand';
import type {
Account,
GameDetail,
GameInfo,
GameStatusPayload,
Hand,
HistoryGame,
MyPlayer,
Registration,
} from '../types';
interface GameStore {
games: GameInfo[];
account: Account | null;
registration: Registration | null;
history: HistoryGame[];
gameDetail: GameDetail | null;
myPlayer: MyPlayer | null;
gameStatus: GameStatusPayload | null;
hand: Hand;
error: string | null;
setGames: (games: GameInfo[]) => void;
setAccount: (account: Account | null) => void;
setRegistration: (registration: Registration | null) => void;
setHistory: (history: HistoryGame[]) => void;
setGameDetail: (detail: GameDetail | null) => void;
setMyPlayer: (player: MyPlayer | null) => void;
setGameStatus: (status: GameStatusPayload) => void;
setHand: (hand: Hand) => void;
setError: (error: string | null) => void;
clearError: () => void;
updatePlayerConnection: (order: number, connected: boolean) => void;
reset: () => void;
logout: () => void;
}
export const useGameStore = create<GameStore>((set) => ({
games: [],
account: null,
registration: null,
history: [],
gameDetail: null,
myPlayer: null,
gameStatus: null,
hand: {},
error: null,
setGames: (games) => set({ games }),
setAccount: (account) => set({ account }),
setRegistration: (registration) => set({ registration }),
setHistory: (history) => set({ history }),
setGameDetail: (gameDetail) => set({ gameDetail }),
setMyPlayer: (myPlayer) => set({ myPlayer }),
setGameStatus: (gameStatus) => set({ gameStatus }),
setHand: (hand) => set({ hand }),
setError: (error) => set({ error }),
clearError: () => set({ error: null }),
updatePlayerConnection: (order, connected) =>
set((state) => ({
gameStatus: state.gameStatus
? {
...state.gameStatus,
players: state.gameStatus.players.map((p) =>
p.order === order ? { ...p, connected } : p
),
}
: null,
})),
reset: () => set({ myPlayer: null, gameStatus: null, hand: {}, error: null }),
logout: () =>
set({
account: null,
registration: null,
history: [],
gameDetail: null,
myPlayer: null,
gameStatus: null,
hand: {},
error: null,
}),
}));
+103
View File
@@ -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[];
}
+63
View File
@@ -0,0 +1,63 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// Velvet table palette (design handoff: "01 — Sametový stôl")
table: '#090e0b',
header: '#070c09',
circle: '#0c1a0f',
'circle-active': '#0e2015',
gold: '#c9a84c',
'gold-bright': '#f0d060',
'gold-dim': '#e8c14a',
// Secondary text is warm cream (not green) for legibility on the dark
// table — the green is reserved for structure (felt, opponent cards).
'green-dim': '#9c906c',
'green-score': '#d8cba6',
'green-circle': '#c2b58c',
},
fontFamily: {
serif: ['"Playfair Display"', 'Georgia', 'serif'],
sans: ['"DM Sans"', 'system-ui', 'sans-serif'],
},
keyframes: {
// turn-pulse — blinking dot / placeholder slot
tp: { '0%,100%': { opacity: '1' }, '50%': { opacity: '.5' } },
// card-in — card lands on the table
ci: {
from: { opacity: '0', transform: 'translateY(-6px) scale(.9)' },
to: { opacity: '1', transform: 'none' },
},
// active-ring — glowing gold ring around the active player circle
ar: {
'0%,100%': {
boxShadow:
'0 0 0 3px rgba(201,168,76,.18),0 0 18px rgba(201,168,76,.5),0 0 42px rgba(201,168,76,.2)',
},
'50%': {
boxShadow:
'0 0 0 5px rgba(201,168,76,.34),0 0 32px rgba(201,168,76,.85),0 0 56px rgba(201,168,76,.3)',
},
},
// glow-1 — gold glow border on a playable card
g1: {
'0%,100%': {
boxShadow: '0 0 18px rgba(201,168,76,.55),0 6px 18px rgba(0,0,0,.55)',
},
'50%': {
boxShadow: '0 0 34px rgba(201,168,76,.85),0 6px 18px rgba(0,0,0,.55)',
},
},
},
animation: {
tp: 'tp 1.8s ease-in-out infinite',
ci: 'ci .3s ease both',
ar: 'ar 2.2s ease-in-out infinite',
g1: 'g1 2.2s ease-in-out infinite',
},
},
},
plugins: [],
};
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+51
View File
@@ -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,
},
},
},
});
+5
View File
@@ -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
+17 -18
View File
@@ -1,18 +1,17 @@
astroid==2.3.3 # Realtime Socket.IO server running on ASGI (uvicorn).
click==7.1.1 python-socketio>=5.11
colorama==0.4.3 python-engineio>=4.9
Flask==1.1.1 uvicorn>=0.30
Flask-WTF==0.14.3
isort==4.3.21 # Persistence layer (history + TOTP auth).
itsdangerous==1.1.0 SQLAlchemy[asyncio]>=2.0
Jinja2==2.11.1 aiosqlite>=0.20 # dev / default DATABASE_URL
lazy-object-proxy==1.4.3 asyncpg>=0.29 # production (PostgreSQL)
MarkupSafe==1.1.1 pyotp>=2.9 # TOTP login
mccabe==0.6.1 cryptography>=42 # Fernet encryption for Player.totp_secret at rest
pylint==2.4.4 user-agents>=2.2 # parse User-Agent for /track (self-hosted analytics)
python-dotenv==0.12.0 geoip2>=4.8 # resolve IP -> country from a local .mmdb file (no external calls)
six==1.14.0
typed-ast==1.4.1 # NOTE: the legacy Flask/Jinja HTTP UI (api/routes.py, api/forms.py,
Werkzeug==1.0.0 # api/templates/) is dormant and its dependencies (Flask, Flask-WTF, etc.)
wrapt==1.11.2 # were removed during the ASGI migration. Re-add them only if that UI is revived.
WTForms==2.2.1
+181
View File
@@ -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
View File
+168
View File
@@ -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
+70
View File
@@ -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))
+89
View File
@@ -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()
+69
View File
@@ -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()
+49
View File
@@ -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)
+264
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More