57 Commits
Author SHA1 Message Date
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
Jakub Senderák 2281e030f6 ui enhancements 2021-04-18 17:40:38 +02:00
Jakub Senderák 889f787f29 shuffler added as explicit dependency 2021-04-18 17:40:38 +02:00
Jakub Senderák 618e632ce4 sort_card_list moved to utils and refactored 2021-04-18 17:40:38 +02:00
Jakub Senderák 834e03c172 main module name change 2021-04-18 17:40:38 +02:00
Jakub Senderák 40b18cbf41 previous stash added to display 2021-04-18 17:40:38 +02:00
Jakub Senderák 71c9e33b85 Bridzik.get_previous_stash added 2021-04-18 17:40:38 +02:00
Jakub Senderák 6ebff98db7 guesses display order fix 2021-04-18 17:40:38 +02:00
Jakub Senderák f4fd27a5c1 added points summary 2021-04-18 17:40:38 +02:00
Jakub Senderák aa7b3c1a02 requirements 2021-04-18 17:40:38 +02:00
Jakub Senderák d12f5c093c ui tweaks, admin form 2021-04-18 17:40:37 +02:00
Jakub Senderák d657dedf5f first working prototype 2021-04-18 17:40:37 +02:00
Jakub Senderák a43a7c4881 added config - form csrf tag 2021-04-18 17:40:37 +02:00
Jakub Senderák e851da47ce Flask api init 2021-04-18 17:40:37 +02:00
92 changed files with 17240 additions and 23 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__/
.flaskenv
/.vscode
/logs
/env-bridzik-dev
*.png
*.pyc
.venv/
*.db
.idea/
frontend/node_modules/
frontend/dist/
frontend/.vite/
.env
.env.*
!.env.example
geoip/*.mmdb
rl/runs/
rl/checkpoints/
+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. -->
+903
View File
@@ -0,0 +1,903 @@
import asyncio
import hmac
import json
import os
import time
import uuid
from collections import defaultdict
from json import JSONEncoder
from urllib.parse import parse_qs
import socketio
from bridzik import Bridzik, BridzikException, Card
from db.db import init_db
from api import auth as auth_module, history
from api import bots as bots_module
from api import stats as stats_module
from api.auth import AuthError, RegistrationIncomplete
from rl.encoding import index_card
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}
+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]
+348
View File
@@ -0,0 +1,348 @@
"""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, select
from user_agents import parse as parse_ua
from db.db import async_session
from db.models import Game, Guess, PageView, Player
_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()
total, finished = (
await session.execute(select(func.count(), func.count(Game.ended_at)))
).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},
"completion_rate": finished / total if total else None,
"avg_game_duration_minutes": (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},
}
+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)
+53 -14
View File
@@ -19,6 +19,10 @@ class Card_colors(Enum):
return self.name == other.name
return NotImplemented
# vlastne __eq__ rusi zdedeny __hash__ -- obnovit konzistentne s __eq__
def __hash__(self):
return hash(self.name)
class Card_values(Enum):
C7 = 1
@@ -51,6 +55,10 @@ class Card_values(Enum):
return self.name == other.name
return NotImplemented
# vlastne __eq__ rusi zdedeny __hash__ -- obnovit konzistentne s __eq__
def __hash__(self):
return hash(self.name)
class Card():
def __init__(self, color: Card_colors, value: Card_values):
@@ -63,6 +71,10 @@ class Card():
and self.value == other.value
return NotImplemented
# vlastne __eq__ rusi zdedeny __hash__ -- obnovit konzistentne s __eq__
def __hash__(self):
return hash((self.color, self.value))
def __str__(self):
return '{}_{}'.format(self.color.name, self.value.name)
@@ -82,46 +94,73 @@ class Card():
cards = [Card(color, value) for value in Card_values for color in Card_colors]
# Sturktura hry: kazda hra ma SERIES_PER_GAME serii, kazda seria ma
# ROUNDS_PER_SERIES kol. Jediny zdroj pravdy pre tieto cisla -- ina vrstva
# (napr. api/history.py) ich odvodzuje odtialto, nikdy si ich nevymysla sama.
SERIES_PER_GAME = 4
ROUNDS_PER_SERIES = 8
class Bridzik():
def __init__(self):
self.series = [Series(0, 0)]
def __init__(self, shuffler=shuffle):
self.shuffler = shuffler
self.series = [Series(0, 0, shuffler=self.shuffler)]
def play_card(self, player: int, card: Card):
if self.is_completed():
raise BridzikException('Hra je ukoncena.')
self.series[-1].play_card(player, card)
if self.series[-1].is_completed() and not self.is_completed():
self.series.append(Series(len(self.series)))
self.series.append(Series(len(self.series), shuffler=self.shuffler))
def add_player_guess(self, player: int, guess):
if self.is_completed():
raise BridzikException('Hra je ukoncena.')
self.series[-1].add_player_guess(player, guess)
def get_status(self, player: int):
def get_player_cards(self, player: int):
player_cards = self.series[-1].get_last_round().player_cards[player]
return {k: v for k, v in enumerate(player_cards)}
def get_status(self):
status = {}
last_series = self.series[-1]
if not self.is_completed():
status['active_player'] = last_series.get_last_round().get_active_player()
status['active_round_guesses'] = last_series.get_last_round().guesses
status['player_cards'] = last_series.get_last_round().player_cards[player]
if last_series.get_last_round().is_guessing_completed():
status['active_round_stashes'] = last_series.get_last_round().get_stashes_winner_summary()
status['active_stash'] = {
'first_player': last_series.get_last_round().get_last_stash().first_player,
'cards': last_series.get_last_round().get_last_stash().get_cards()
}
if self.get_previous_stash():
status['previous_stash'] = {
'first_player': self.get_previous_stash().first_player,
'cards': self.get_previous_stash().get_cards()
}
status['standings'] = [s.get_standings() for s in self.series]
return status
def is_completed(self):
return len(self.series) == 4 and self.series[-1].is_completed()
return len(self.series) == SERIES_PER_GAME and self.series[-1].is_completed()
def get_previous_stash(self):
if len(self.series[-1].get_last_round().stashes) > 1:
return self.series[-1].get_last_round().stashes[-2]
elif len(self.series[-1].rounds) > 1:
return self.series[-1].rounds[-2].get_last_stash()
elif len(self.series) > 1:
return self.series[-2].get_last_round().get_last_stash()
return None
class Series():
def __init__(self, series_number: int, first_player: int = None):
def __init__(self, series_number: int, first_player: int = None, shuffler = shuffle):
self.series_number = series_number
self.first_player = first_player if first_player else series_number
self.rounds = []
self.shuffler = shuffler
self.start_new_round()
def add_player_guess(self, player: int, guess: int):
@@ -149,7 +188,7 @@ class Series():
self.start_new_round()
def is_completed(self):
return len(self.rounds) == 8 and self.get_last_round().is_completed()
return len(self.rounds) == ROUNDS_PER_SERIES and self.get_last_round().is_completed()
def get_standings(self):
return [r.get_points_summary() for r in self.rounds if r.is_completed()]
@@ -164,14 +203,14 @@ class Series():
if round_number != 0 and not self.get_last_round().is_completed():
raise BridzikException('Predchadzajuce kolo nie je ukoncene')
self.rounds.append(
Round(round_number, (self.first_player + round_number) % 4)
Round(round_number, (self.first_player + round_number) % 4, shuffler=self.shuffler)
)
class Round():
def __init__(self, round_number: int, first_player: int, cards: []=cards, shuffler = shuffle):
def __init__(self, round_number: int, first_player: int, cards: []=cards, shuffler=shuffle):
# vyrob kopku pre toto kolo a priprav prazdne objekty
if round_number not in [i for i in range(8)]:
if round_number not in range(ROUNDS_PER_SERIES):
raise BridzikException('Neplatne cislo kola.')
if first_player not in [0, 1, 2, 3]:
raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.')
@@ -268,7 +307,7 @@ class Round():
def get_last_stash(self):
return self.stashes[-1] if self.stashes else None
def deal_starting_cards(self, cards: [], shuffler = shuffle):
def deal_starting_cards(self, cards: [], shuffler=shuffle):
self.round_cards = cards.copy()
shuffler(self.round_cards)
self.round_cards = self.round_cards[(4*self.round_number):]
@@ -291,7 +330,7 @@ class Round():
self.stashes.append(Stash(self.get_last_stash().get_winner()))
class Stash():
class Stash:
def __init__(self, first_player: int):
if first_player not in [0, 1, 2, 3]:
raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.')
+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: 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>
);
}
+84
View File
@@ -0,0 +1,84 @@
import type { Card } from '../types';
const SUIT_SYMBOL: Record<string, string> = {
HEARTS: '♥',
LEAVES: '♠',
ACORNS: '♣',
BELLS: '♦',
};
const SUIT_COLOR: Record<string, string> = {
HEARTS: '#c40000',
LEAVES: '#1e7a1e',
ACORNS: '#b87a00',
BELLS: '#0087b8',
};
const VALUE_LABEL: Record<string, string> = {
C7: 'VII', C8: 'VIII', C9: 'IX', C10: 'X',
LOWER: 'J', UPPER: 'Q', KING: 'K', ACE: 'A',
};
// Per-size geometry. sm/md sit on the table, lg/xl are hand cards.
const DIMS = {
sm: { w: 38, h: 54, radius: 4, inset: 3, label: 9, suitSm: 7, suitLg: 18 },
md: { w: 56, h: 80, radius: 6, inset: 4, label: 12, suitSm: 10, suitLg: 30 },
lg: { w: 60, h: 84, radius: 7, inset: 5, label: 12, suitSm: 9, suitLg: 30 },
xl: { w: 72, h: 100, radius: 8, inset: 6, label: 14, suitSm: 11, suitLg: 38 },
} as const;
interface Props {
card: Card;
onClick?: () => void;
disabled?: boolean;
/** Playable card on your turn — gold glow border + lift. */
highlight?: boolean;
size?: keyof typeof DIMS;
}
export default function CardView({ card, onClick, disabled = false, highlight = false, size = 'md' }: Props) {
const symbol = SUIT_SYMBOL[card.color];
const color = SUIT_COLOR[card.color];
const label = VALUE_LABEL[card.value];
const d = DIMS[size];
const interactive = !disabled && !!onClick;
const corner = (rotated: boolean) => (
<span
className="absolute flex flex-col items-center leading-none"
style={
rotated
? { bottom: d.inset, right: d.inset, transform: 'rotate(180deg)' }
: { top: d.inset, left: d.inset }
}
>
<span style={{ color, fontSize: d.label, fontFamily: 'Georgia,serif', fontWeight: 700, lineHeight: 1 }}>
{label}
</span>
<span style={{ color, fontSize: d.suitSm, lineHeight: 1.2 }}>{symbol}</span>
</span>
);
return (
<button
onClick={onClick}
disabled={disabled || !onClick}
style={{ width: d.w, height: d.h, borderRadius: d.radius }}
className={[
'relative bg-white overflow-hidden flex-none transition-transform',
highlight
? 'border-2 border-gold animate-g1 -translate-y-2'
: 'border border-[#ddd8d0] shadow-[0_2px_8px_rgba(0,0,0,.35)]',
disabled && !highlight ? 'opacity-[.35]' : '',
interactive ? 'cursor-pointer hover:-translate-y-1 active:scale-95' : 'cursor-default',
].join(' ')}
>
{corner(false)}
<span className="absolute inset-0 flex items-center justify-center">
<span style={{ color, fontSize: d.suitLg, lineHeight: 1 }}>{symbol}</span>
</span>
{corner(true)}
</button>
);
}
+43
View File
@@ -0,0 +1,43 @@
interface Props {
/** Exact number of cards the player still holds. */
count: number;
/** Row (top player) or column (side players). */
direction: 'row' | 'col';
desktop?: boolean;
}
/** Overlapping fan of face-down cards next to an opponent's circle — one card
* per card still in their hand, so the stack shrinks as they play. */
export default function FaceDownCards({ count, direction, desktop = false }: Props) {
const n = Math.max(0, Math.min(count, 8));
if (n === 0) return null;
const row = direction === 'row';
const w = desktop ? 40 : 25;
const h = desktop ? 56 : 36;
const overlap = row ? Math.round(w * 0.45) : Math.round(h * 0.5);
return (
<div className="flex" style={{ flexDirection: row ? 'row' : 'column' }}>
{Array.from({ length: n }).map((_, i) => (
<div
key={i}
style={{
width: w,
height: h,
marginLeft: row && i > 0 ? -overlap : 0,
marginTop: !row && i > 0 ? -overlap : 0,
zIndex: i,
background:
i % 2 === 0
? 'linear-gradient(150deg,#1d4a28,#0e2818)'
: 'linear-gradient(150deg,#1b4424,#0d2616)',
borderRadius: 3,
border: '1px solid rgba(201,168,76,.18)',
boxShadow: '0 2px 6px rgba(0,0,0,.5)',
}}
/>
))}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { emit } from '../lib/socket';
import { forbiddenGuess } from '../lib/gameRules';
interface Props {
cardsInRound: number;
guesses: Record<string, number>;
myOrder: number;
activePlayer: number;
activePlayerName: string;
}
export default function GuessControls({ cardsInRound, guesses, myOrder, activePlayer, activePlayerName }: Props) {
const isMyTurn = activePlayer === myOrder;
const forbidden = forbiddenGuess(cardsInRound, guesses);
if (!isMyTurn) {
return (
<p className="text-center text-sm text-green-dim py-3">
Čaká sa na tip:{' '}
<span className="font-serif text-gold">{activePlayerName}</span>
</p>
);
}
const options = Array.from({ length: cardsInRound + 1 }, (_, i) => i);
return (
<div className="flex flex-col items-center gap-3 py-3">
<p className="font-serif italic text-gold-dim text-[14px]">Zadaj svoj tip (počet kopiek)</p>
<div className="flex flex-wrap gap-2 justify-center">
{options.map((n) => {
const isForbidden = n === forbidden;
return (
<button
key={n}
disabled={isForbidden}
onClick={() => emit.addGuess(n)}
title={isForbidden ? 'Zakázaná hodnota (súčet = počet kopiek)' : undefined}
className={[
'w-11 h-11 rounded-full font-serif text-lg border-2 transition-colors',
isForbidden
? 'border-[#5a2a2a] text-[#7a4040] opacity-40 cursor-not-allowed'
: 'border-gold/50 text-gold hover:bg-gold hover:text-table hover:border-gold active:scale-95',
].join(' ')}
>
{n}
</button>
);
})}
</div>
</div>
);
}
+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>
);
}
+65
View File
@@ -0,0 +1,65 @@
interface Props {
name: string;
/** Tricks won this round. */
won: number;
/** Bid for this round (null until the player has guessed). */
guess: number | null;
/** Whether it is this player's turn — the only state that highlights a circle. */
active: boolean;
size?: number;
}
export default function PlayerCircle({ name, won, guess, active, size = 52 }: Props) {
const nameFont = Math.max(9, Math.round(size * 0.17));
const valueFont = Math.round(size * 0.32);
// Oval: 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}
<span style={{ fontSize: valueFont * 0.6, color: '#b0a585' }}>/{guess ?? '?'}</span>
</span>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
interface Props {
onClose: () => void;
}
export default function RulesModal({ onClose }: Props) {
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/70 p-4 overflow-y-auto"
onClick={onClose}
>
<div
className="relative bg-header border border-[#142018] rounded-2xl w-full max-w-lg my-6 p-6 text-sm leading-relaxed text-green-score shadow-[0_28px_88px_rgba(0,0,0,.65)]"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
className="absolute top-4 right-4 text-green-dim hover:text-gold text-xl leading-none"
>
</button>
<h1 className="font-serif text-2xl text-gold mb-4">Pravidlá hry Bridžik</h1>
<Section title="Karty">
<p>Hrá sa s <b>32-kartovým balíčkom</b> sedmových (slovenských/nemeckých) kariet.</p>
<p className="mt-2"><b>Farby:</b> červeň (), zeleň (), žaluď (), guľa ()</p>
<p className="mt-1 text-red-400 font-semibold">Červeň je vždy tromf (adut) prebíja každú inú farbu.</p>
<p className="mt-2"><b>Hodnoty</b> od najnižšej: VII · VIII · IX · X · J · Q · K · A</p>
</Section>
<Section title="Štruktúra hry">
<p>4 hráči · 4 série · 8 kôl v sérii</p>
<p className="mt-1">V každom kole dostane každý hráč <b>8 číslo_kola</b> kariet (8 1).</p>
<p className="mt-1">Sériu otvára hráč s rovnakým číslom ako séria. Každé ďalšie kolo posúva začínajúceho hráča o jedného.</p>
</Section>
<Section title="Priebeh kola">
<p className="font-semibold">1. Tipovanie</p>
<p className="mt-1">Každý hráč tipuje, koľko kopiek v kole získa (0 počet kopiek).</p>
<p className="mt-1 text-gold-dim">Pravidlo bridžika: súčet tipov nesmie presne rovnať počtu kopiek v kole posledný tipujúci nemôže zadať tip, ktorý by toto spôsobil.</p>
<p className="font-semibold mt-3">2. Hranie kariet</p>
<p className="mt-1">Prvú kopku otvára hráč s <b>najvyšším tipom</b>. Každú ďalšiu otvára víťaz predchádzajúcej kopky.</p>
<p className="font-semibold mt-3">Povinnosť priznať farbu:</p>
<ol className="mt-1 list-decimal list-inside space-y-1">
<li>Máš farbu vynesenej karty <b>musíš ju zahrať.</b></li>
<li>Nemáš ju, ale máš červeň <b>musíš zahrať červeň.</b></li>
<li>Nemáš ani jedno môžeš zahrať <b>ľubovoľnú</b> kartu.</li>
</ol>
<p className="font-semibold mt-3">Víťaz kopky:</p>
<ul className="mt-1 list-disc list-inside space-y-1">
<li>Ak padla červeň vyhráva <b>najvyššia červeň.</b></li>
<li>Ak nie vyhráva <b>najvyššia karta vynesenej farby.</b></li>
</ul>
</Section>
<Section title="Bodovanie">
<p>Po každom kole: ak sa tip <b>presne zhoduje</b> s počtom získaných kopiek <b>10 + tip</b> bodov, inak <b>0</b>.</p>
<p className="mt-1 text-green-dim">Príklad: tipoval 3, získal 3 13 bodov. Tipoval 3, získal 2 0 bodov.</p>
<p className="mt-2">Vyhráva hráč s najvyšším celkovým súčtom po 4 sériách.</p>
</Section>
<button
onClick={onClose}
className="mt-4 w-full py-2.5 rounded-xl border border-gold/30 text-gold hover:bg-gold hover:text-table font-serif font-semibold transition-colors"
>
Zavrieť
</button>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-4">
<h2 className="font-serif text-base text-gold mb-1">{title}</h2>
<div className="text-green-score">{children}</div>
</div>
);
}
+219
View File
@@ -0,0 +1,219 @@
import { useState } from 'react';
import type { PlayerInfo } from '../types';
import { computeTotal } from '../lib/standings';
import { displayName } from '../lib/names';
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.
const fz = {
head: desktop ? 11 : 10,
idx: desktop ? 13 : 9,
cell: desktop ? 19 : 14,
dot: desktop ? 18 : 13,
sigma: desktop ? 13 : 9,
total: desktop ? 20 : 18,
};
const 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); }
}
+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>
);
}
+404
View File
@@ -0,0 +1,404 @@
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 Hand from '../components/Hand';
import GuessControls from '../components/GuessControls';
import Trick from '../components/Trick';
import Standings from '../components/Standings';
import PlayerCircle from '../components/PlayerCircle';
import FaceDownCards from '../components/FaceDownCards';
import GameOver from './GameOver';
import type { 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: 11 }}>
{displayName(p.name)}
</div>
<div className="font-serif text-green-score leading-none" style={{ fontSize: compact ? 16 : 20 }}>
{computeTotal(standings, p.order)}
</div>
</div>
))}
<div className="text-center rounded-lg px-3 py-1 bg-gold/[.06] border border-gold/[.15]">
<div className="uppercase tracking-[.1em] text-gold mb-0.5" style={{ fontSize: 11 }}>
{myPlayer.name}
</div>
<div className="font-serif font-semibold text-gold-dim leading-none" style={{ fontSize: compact ? 16 : 20 }}>
{computeTotal(standings, myOrder)}
</div>
</div>
</div>
);
// Center of the oval: trick during play, guess controls during bidding.
// `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}
/>
) : null
);
const topSeat = (
<div className="flex flex-col items-center gap-1.5">
<PlayerCircle name={displayName(topP?.name) || '—'} {...seatProps(topP?.order)} size={desktop ? 64 : 52} />
<FaceDownCards count={cardsInHandOf(topP?.order)} direction="row" desktop={desktop} />
</div>
);
const sideSeat = (p?: PlayerInfo) => (
<div className="flex flex-col items-center gap-1.5">
<PlayerCircle name={displayName(p?.name) || '—'} {...seatProps(p?.order)} size={desktop ? 60 : 48} />
<FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} />
</div>
);
const meSeat = (
<div className="flex justify-center">
<PlayerCircle name={myPlayer.name} {...seatProps(myOrder)} size={desktop ? 70 : 58} />
</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-[15px] text-gold whitespace-nowrap">
Bridžik
</span>
<div className="w-px h-[22px] bg-[#1a3a22]" />
<span className="font-serif text-[12px] text-green-dim tracking-[.06em] whitespace-nowrap">
Séria {series_number + 1} · Kolo {round_number + 1}
</span>
<div className="flex-1 flex items-center justify-center">{banner}</div>
{totalsRow(true)}
<div className="w-px h-[22px] bg-[#1a3a22]" />
{canEnd && (
<button onClick={handleEnd} className="text-[11px] text-[#8a8064] hover:text-gold whitespace-nowrap">
Ukončiť
</button>
)}
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold whitespace-nowrap">
Odísť
</button>
</div>
{/* game content — players hug the edges so the felt uses full width */}
<div className="flex-1 min-h-0 flex flex-col justify-center gap-3 px-16 py-4">
{topSeat}
<div className="flex items-center justify-center gap-16">
{sideSeat(leftP)}
<div
className="flex items-center justify-center rounded-full"
style={{
width: 620,
height: 372,
background:
'radial-gradient(ellipse at 42% 38%,#306845 0%,#1e5030 38%,#122e1c 72%,#091e12 100%)',
boxShadow:
'inset 0 10px 48px rgba(0,0,0,.72),0 0 0 3px rgba(0,0,0,.55),0 0 0 6px rgba(201,168,76,.1)',
}}
>
{ovalContent}
</div>
{sideSeat(rightP)}
</div>
{meSeat}
</div>
{handArea}
</div>
{/* sidebar */}
<Standings standings={standings} guesses={standings_guesses} players={players} myOrder={myOrder} desktop />
</div>
</div>
);
}
// ── MOBILE LAYOUT ────────────────────────────────────────────────
return (
<div className="max-w-lg mx-auto min-h-screen flex flex-col">
{/* header */}
<div className="bg-header px-[18px] pt-[14px] pb-3 border-b border-[#14221a]">
<div className="flex items-center justify-between mb-2.5">
<span className="font-serif uppercase tracking-[.1em] text-[11px] text-gold">
Séria {series_number + 1} · Kolo {round_number + 1}
</span>
<div className="flex items-center gap-3">
{canEnd && (
<button onClick={handleEnd} className="text-[11px] text-[#6a3030] hover:text-red-400">
Ukončiť
</button>
)}
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold">
Odísť
</button>
</div>
</div>
{totalsRow(false)}
</div>
{/* turn banner */}
<div
className="py-[9px] px-4 border-b border-[#152a1a]"
style={{ background: 'linear-gradient(90deg,#09190d,#14301e,#09190d)' }}
>
{banner}
</div>
{/* game area */}
<div className="flex-1 bg-table px-2.5 pt-2.5 pb-1.5 flex flex-col">
<div className="flex flex-col items-center mb-1.5">{topSeat}</div>
<div className="flex items-center gap-1.5 mb-2">
<div className="w-[54px] flex-shrink-0 flex justify-center">{sideSeat(leftP)}</div>
<div
className="flex-1 flex items-center justify-center rounded-full"
style={{
minHeight: 192,
background:
'radial-gradient(ellipse at 42% 38%,#306845 0%,#1e5030 38%,#122e1c 72%,#091e12 100%)',
boxShadow:
'inset 0 6px 32px rgba(0,0,0,.7),0 0 0 2px rgba(0,0,0,.5),0 0 0 4px rgba(201,168,76,.1)',
}}
>
{ovalContent}
</div>
<div className="w-[54px] flex-shrink-0 flex justify-center">{sideSeat(rightP)}</div>
</div>
<div className="mb-1.5">{meSeat}</div>
</div>
{handArea}
{/* score */}
<div className="bg-table px-3 pb-4 pt-1">
<Standings standings={standings} guesses={standings_guesses} players={players} myOrder={myOrder} />
</div>
</div>
);
}
+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>
);
}
+201
View File
@@ -0,0 +1,201 @@
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>;
completion_rate: number | null;
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"
value={data.completion_rate != null ? `${Math.round(data.completion_rate * 100)}%` : '—'}
/>
<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
View File
@@ -0,0 +1,17 @@
# Realtime Socket.IO server running on ASGI (uvicorn).
python-socketio>=5.11
python-engineio>=4.9
uvicorn>=0.30
# Persistence layer (history + TOTP auth).
SQLAlchemy[asyncio]>=2.0
aiosqlite>=0.20 # dev / default DATABASE_URL
asyncpg>=0.29 # production (PostgreSQL)
pyotp>=2.9 # TOTP login
cryptography>=42 # Fernet encryption for Player.totp_secret at rest
user-agents>=2.2 # parse User-Agent for /track (self-hosted analytics)
geoip2>=4.8 # resolve IP -> country from a local .mmdb file (no external calls)
# NOTE: the legacy Flask/Jinja HTTP UI (api/routes.py, api/forms.py,
# api/templates/) is dormant and its dependencies (Flask, Flask-WTF, etc.)
# were removed during the ASGI migration. Re-add them only if that UI is revived.
+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
+42
View File
@@ -0,0 +1,42 @@
"""Natrenovana siet ako hrac so standardnym rozhranim guess/play.
Rovnake rozhranie ako rl/players.py, takze funguje v rl/evaluate.py aj ako
boti "mozog" v api/bots.py. Hrac vidi len observaciu + masku z rl/encoding.py
-- z principu nemoze podvadzat (do cudzich ruk sa nedostane).
"""
import torch
from rl.encoding import encode_observation, guess_mask, play_mask
from rl.model import BridzikNet, mask_tensor, masked_categorical, obs_tensor
class NeuralPlayer:
def __init__(self, net: BridzikNet, greedy: bool = True):
self.net = net
self.greedy = greedy # argmax pri evaluacii; sampling pre pestrost
def _act(self, rnd, seat: int, use_play_head: bool) -> int:
obs = obs_tensor(encode_observation(rnd, seat)).unsqueeze(0)
mask = mask_tensor(
play_mask(rnd, seat) if use_play_head else guess_mask(rnd)
).unsqueeze(0)
self.net.eval()
with torch.no_grad():
guess_logits, play_logits, _ = self.net(obs)
logits = play_logits if use_play_head else guess_logits
logits = logits.masked_fill(~mask, float('-inf'))
if self.greedy:
return int(logits.argmax(dim=-1).item())
return int(masked_categorical(logits, mask).sample().item())
def guess(self, rnd, seat: int) -> int:
return self._act(rnd, seat, use_play_head=False)
def play(self, rnd, seat: int) -> int:
return self._act(rnd, seat, use_play_head=True)
def load_player(checkpoint_path: str, greedy: bool = True) -> NeuralPlayer:
from rl.train import load_checkpoint
return NeuralPlayer(load_checkpoint(checkpoint_path), greedy=greedy)
+114
View File
@@ -0,0 +1,114 @@
"""Cisto-Python inferencia natrenovanej siete (stdlib only, bez torch/numpy).
Nacita vahy z exportu rl/export.py a implementuje forward pass MLP
(trunk 2x ReLU + guess/play hlavy). Sluzi produkcnym botom v api/bots.py --
torch ostava len trenovacia zavislost na hoste. Presnost overuje
tests/test_pure_net.py porovnanim s torch vystupmi na zivych observaciach.
Vykon: ~250k nasobeni na tah (~desiatky ms) -- pri pauze medzi tahmi bota
(BOT_MOVE_DELAY_SECONDS) nepostrehnutelne.
"""
import base64
import json
import os
import struct
from rl.encoding import (
N_GUESS_ACTIONS, N_PLAY_ACTIONS, OBS_DIM,
encode_observation, guess_mask, play_mask,
)
DEFAULT_WEIGHTS_PATH = os.path.join(
os.path.dirname(__file__), 'weights', 'neural-bot.json'
)
def _unpack(entry: dict):
"""{shape, base64 f32 LE} -> matica (list riadkov) alebo vektor."""
flat = list(struct.unpack(
f'<{_numel(entry["shape"])}f', base64.b64decode(entry['data'])
))
shape = entry['shape']
if len(shape) == 1:
return flat
rows, cols = shape
return [flat[r * cols:(r + 1) * cols] for r in range(rows)]
def _numel(shape: list) -> int:
n = 1
for dim in shape:
n *= dim
return n
def _linear(weight, bias, x):
"""weight (out x in) @ x + bias -- radove poradie ako torch.nn.Linear."""
return [sum(w * v for w, v in zip(row, x)) + b
for row, b in zip(weight, bias)]
def _relu(x):
return [v if v > 0.0 else 0.0 for v in x]
class PureNet:
def __init__(self, payload: dict):
if payload['obs_dim'] != OBS_DIM:
raise ValueError(
f'Vahy su pre obs_dim={payload["obs_dim"]}, kod ma {OBS_DIM} '
'-- treba re-export z aktualneho checkpointu.'
)
w = payload['weights']
self.trunk0_w = _unpack(w['trunk0_w'])
self.trunk0_b = _unpack(w['trunk0_b'])
self.trunk2_w = _unpack(w['trunk2_w'])
self.trunk2_b = _unpack(w['trunk2_b'])
self.guess_w = _unpack(w['guess_w'])
self.guess_b = _unpack(w['guess_b'])
self.play_w = _unpack(w['play_w'])
self.play_b = _unpack(w['play_b'])
@classmethod
def load(cls, path: str = DEFAULT_WEIGHTS_PATH) -> 'PureNet':
with open(path) as f:
return cls(json.load(f))
def _trunk(self, obs):
h = _relu(_linear(self.trunk0_w, self.trunk0_b, obs))
return _relu(_linear(self.trunk2_w, self.trunk2_b, h))
def guess_logits(self, obs) -> list:
return _linear(self.guess_w, self.guess_b, self._trunk(obs))
def play_logits(self, obs) -> list:
return _linear(self.play_w, self.play_b, self._trunk(obs))
def _masked_argmax(logits: list, mask: list) -> int:
best, best_value = None, None
for i, allowed in enumerate(mask):
if allowed and (best is None or logits[i] > best_value):
best, best_value = i, logits[i]
return best
class PureNeuralPlayer:
"""Greedy hrac nad PureNet -- rovnake rozhranie a rovnake vstupy
(observacia + maska) ako rl.policy_player.NeuralPlayer(greedy=True)."""
def __init__(self, net: PureNet):
self.net = net
@classmethod
def load(cls, path: str = DEFAULT_WEIGHTS_PATH) -> 'PureNeuralPlayer':
return cls(PureNet.load(path))
def guess(self, rnd, seat: int) -> int:
obs = encode_observation(rnd, seat)
return _masked_argmax(self.net.guess_logits(obs), guess_mask(rnd))
def play(self, rnd, seat: int) -> int:
obs = encode_observation(rnd, seat)
return _masked_argmax(self.net.play_logits(obs), play_mask(rnd, seat))
+131
View File
@@ -0,0 +1,131 @@
"""Self-play generator: jedna zdielana siet hra vsetkych 4 hracov v Round
epizodach a zbiera trajektorie pre PPO (viz rl/DESIGN.md, sekcie 4-5).
Odmena je sparse a terminalna: kazde rozhodnutie hraca v kole (tip aj vsetky
karty) dostane ako return jeho `points_summary` z konca kola, gamma = 1.
Masky sa ukladaju oddelene pre obe fazy (rozne velkosti akcneho priestoru);
`phase_play` hovori, ktora hlava/maska pre dany krok plati.
"""
from random import Random
import torch
from rl.encoding import N_GUESS_ACTIONS, N_PLAY_ACTIONS
from rl.env import PHASE_PLAY, RoundEnv
from rl.model import BridzikNet, mask_tensor, masked_categorical, obs_tensor
from rl.players import HeuristicPlayer, RandomPlayer
# Returny sa skaluju do [0, 1] (max odmena je 10+8). Bez skalovania ma value
# loss (MSE na 0-18) radovo vacsi gradient nez policy loss a cez zdielany
# trup policy ucenie prevalcuje.
REWARD_SCALE = 18.0
def _assign_seats(rng: Random, mix_random: float, mix_heuristic: float,
random_player, heuristic_player) -> dict:
"""Obsadenie sedadiel pre jednu epizodu: None = siet, inak skriptovany
supper. Aspon jedno sedadlo musi hrat siet (inak niet co zbierat)."""
seats = {}
for seat in range(4):
roll = rng.random()
if roll < mix_random:
seats[seat] = random_player
elif roll < mix_random + mix_heuristic:
seats[seat] = heuristic_player
else:
seats[seat] = None
if not any(p is None for p in seats.values()):
seats[rng.randrange(4)] = None
return seats
def collect_episodes(net: BridzikNet, n_episodes: int, rng: Random,
round_numbers: list = None, mix_random: float = 0.0,
mix_heuristic: float = 0.0,
heuristic_samples: int = 40) -> dict:
"""Odohra `n_episodes` self-play kol a vrati batch tenzorov:
obs (N, OBS_DIM), phase_play (N,) bool, action (N,), logp (N,), value (N,),
ret (N,), guess_mask (N, 9), play_mask (N, 32) -- maska nepatriacej fazy je
pre dany krok cela False a pri update sa nepouzije.
Navyse 'mean_points': priemerne body na sietove sedadlo a kolo.
Opponent mixing (robustnost na nie-self-play superov): s pravdepodobnostou
`mix_random` / `mix_heuristic` hra sedadlo RandomPlayer / HeuristicPlayer
namiesto siete. Tahy skriptovanych superov sa do batchu NEZAZNAMENAVAJU
(nie su z trenovanej policy) -- superi len obsadzuju stol.
"""
env = RoundEnv(rng)
random_player = RandomPlayer(rng)
heuristic_player = HeuristicPlayer(rng, n_samples=heuristic_samples)
mixing = mix_random > 0 or mix_heuristic > 0
obs_l, phase_l, action_l, logp_l, value_l, ret_l = [], [], [], [], [], []
gmask_l, pmask_l = [], []
total_points = 0.0
net_seat_rounds = 0
net.eval()
with torch.no_grad():
for _ in range(n_episodes):
round_number = rng.choice(round_numbers) if round_numbers else None
decision = env.reset(round_number)
seats = _assign_seats(rng, mix_random, mix_heuristic,
random_player, heuristic_player) if mixing \
else {seat: None for seat in range(4)}
net_seat_rounds += sum(1 for p in seats.values() if p is None)
# indexy krokov sietovych sedadiel -- na priradenie returnu
player_steps = {p: [] for p in range(4) if seats[p] is None}
while True:
opponent = seats[decision.player]
if opponent is not None:
# skriptovany supper: vykonaj tah, nic nezaznamenavaj
if decision.phase == PHASE_PLAY:
action_i = opponent.play(env.round, decision.player)
else:
action_i = opponent.guess(env.round, decision.player)
else:
obs = obs_tensor(decision.obs).unsqueeze(0)
mask = mask_tensor(decision.mask).unsqueeze(0)
guess_logits, play_logits, value = net(obs)
is_play = decision.phase == PHASE_PLAY
dist = masked_categorical(
play_logits if is_play else guess_logits, mask
)
action = dist.sample()
action_i = action.item()
player_steps[decision.player].append(len(obs_l))
obs_l.append(decision.obs)
phase_l.append(is_play)
action_l.append(action_i)
logp_l.append(dist.log_prob(action).item())
value_l.append(value.item())
ret_l.append(0.0) # doplni sa na konci kola
if is_play:
gmask_l.append([False] * N_GUESS_ACTIONS)
pmask_l.append(decision.mask)
else:
gmask_l.append(decision.mask)
pmask_l.append([False] * N_PLAY_ACTIONS)
decision, rewards, done = env.step(action_i)
if done:
for player, steps in player_steps.items():
for i in steps:
ret_l[i] = rewards[player] / REWARD_SCALE
total_points += rewards[player]
break
return {
'obs': torch.tensor(obs_l, dtype=torch.float32),
'phase_play': torch.tensor(phase_l, dtype=torch.bool),
'action': torch.tensor(action_l, dtype=torch.long),
'logp': torch.tensor(logp_l, dtype=torch.float32),
'value': torch.tensor(value_l, dtype=torch.float32),
'ret': torch.tensor(ret_l, dtype=torch.float32),
'guess_mask': torch.tensor(gmask_l, dtype=torch.bool),
'play_mask': torch.tensor(pmask_l, dtype=torch.bool),
'mean_points': total_points / max(net_seat_rounds, 1),
}
+230
View File
@@ -0,0 +1,230 @@
"""Self-play PPO trening (viz rl/DESIGN.md, sekcie 4-6).
Slucka: nazbieraj self-play epizody -> PPO update -> kazdych par iteracii
evaluacia GREEDY policy proti fixnym baseline-om (nahodny hrac, MC heuristika)
z rl/players.py -- self-play reward sam o sebe nie je smerodajny (hra nie je
zero-sum, protihrac sa hybe spolu so sietou).
Spustenie:
py -m rl.train --iterations 200 --episodes 512
py -m rl.train --resume rl/checkpoints/latest.pt # pokracovanie
Checkpointy: rl/checkpoints/latest.pt (kazdu iteraciu) + best.pt (najlepsi
priemer bodov proti heuristikam). Metriky sa pripisuju do rl/runs/train_log.csv.
"""
import argparse
import csv
import os
import time
from random import Random
import torch
from rl.evaluate import evaluate
from rl.model import BridzikNet, masked_categorical
from rl.players import HeuristicPlayer, RandomPlayer
from rl.policy_player import NeuralPlayer
from rl.selfplay import collect_episodes
CHECKPOINT_DIR = os.path.join('rl', 'checkpoints')
RUNS_DIR = os.path.join('rl', 'runs')
def ppo_update(net: BridzikNet, optimizer: torch.optim.Optimizer, batch: dict,
clip: float = 0.2, epochs: int = 4, minibatch: int = 1024,
vf_coef: float = 1.0, ent_coef: float = 0.01,
max_grad_norm: float = 0.5) -> dict:
"""Standardny clipped-PPO krok nad batchom zo self-play.
Advantage sa standardizuje per batch (bod 7 v DESIGN.md -- odmeny 10-18 sa
lisia medzi kolami a zvysovali by varianciu gradientu). Guess a play kroky
zdielaju trup aj value hlavu, policy loss ide vzdy cez hlavu svojej fazy.
"""
n = batch['obs'].shape[0]
adv = batch['ret'] - batch['value']
adv = (adv - adv.mean()) / (adv.std() + 1e-8)
net.train()
stats = {'policy_loss': 0.0, 'value_loss': 0.0, 'entropy': 0.0, 'updates': 0}
for _ in range(epochs):
perm = torch.randperm(n)
for start in range(0, n, minibatch):
idx = perm[start:start + minibatch]
obs = batch['obs'][idx]
guess_logits, play_logits, value = net(obs)
is_play = batch['phase_play'][idx]
logp_new = torch.empty_like(batch['logp'][idx])
entropy = torch.empty_like(logp_new)
for phase_sel, logits, mask_key in (
(~is_play, guess_logits, 'guess_mask'),
(is_play, play_logits, 'play_mask'),
):
if not bool(phase_sel.any()):
continue
dist = masked_categorical(
logits[phase_sel], batch[mask_key][idx][phase_sel]
)
logp_new[phase_sel] = dist.log_prob(batch['action'][idx][phase_sel])
entropy[phase_sel] = dist.entropy()
ratio = torch.exp(logp_new - batch['logp'][idx])
mb_adv = adv[idx]
policy_loss = -torch.min(
ratio * mb_adv,
torch.clamp(ratio, 1 - clip, 1 + clip) * mb_adv,
).mean()
value_loss = (value - batch['ret'][idx]).pow(2).mean()
loss = policy_loss + vf_coef * value_loss - ent_coef * entropy.mean()
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(net.parameters(), max_grad_norm)
optimizer.step()
stats['policy_loss'] += policy_loss.item()
stats['value_loss'] += value_loss.item()
stats['entropy'] += entropy.mean().item()
stats['updates'] += 1
for key in ('policy_loss', 'value_loss', 'entropy'):
stats[key] /= max(stats['updates'], 1)
return stats
def evaluate_against_baselines(net: BridzikNet, n_rounds: int, rng: Random,
mc_samples: int = 60) -> dict:
"""Greedy siet na sedadle 0 vs 3x random a vs 3x heuristika."""
neural = NeuralPlayer(net, greedy=True)
vs_random = evaluate(
[neural] + [RandomPlayer(rng) for _ in range(3)], n_rounds, rng
)[0]
vs_heuristic = evaluate(
[neural] + [HeuristicPlayer(rng, n_samples=mc_samples) for _ in range(3)],
n_rounds, rng,
)[0]
return {
'vs_random_points': vs_random['avg_points'],
'vs_random_hit': vs_random['hit_rate'],
'vs_heuristic_points': vs_heuristic['avg_points'],
'vs_heuristic_hit': vs_heuristic['hit_rate'],
}
def save_checkpoint(net: BridzikNet, hidden: int, path: str) -> None:
torch.save({'hidden': hidden, 'state_dict': net.state_dict()}, path)
def load_checkpoint(path: str) -> BridzikNet:
"""Nacita checkpoint; podporuje aj stary format (bare state_dict)."""
payload = torch.load(path, map_location='cpu')
if isinstance(payload, dict) and 'state_dict' in payload:
net = BridzikNet(hidden=payload['hidden'])
net.load_state_dict(payload['state_dict'])
else:
net = BridzikNet()
net.load_state_dict(payload)
return net
def train(iterations: int, episodes: int, lr: float, seed: int,
eval_every: int, eval_rounds: int, resume: str = None,
hidden: int = 384, ent_coef_start: float = 0.01,
ent_coef_final: float = 0.001, lr_final_frac: float = 0.1,
mix_random: float = 0.0, mix_heuristic: float = 0.0):
os.makedirs(CHECKPOINT_DIR, exist_ok=True)
os.makedirs(RUNS_DIR, exist_ok=True)
log_path = os.path.join(RUNS_DIR, 'train_log.csv')
log_exists = os.path.exists(log_path)
torch.manual_seed(seed)
rng = Random(seed)
if resume:
net = load_checkpoint(resume)
hidden = net.trunk[0].out_features
print(f'Pokracujem z checkpointu {resume} (hidden={hidden})')
else:
net = BridzikNet(hidden=hidden)
optimizer = torch.optim.Adam(net.parameters(), lr=lr)
best_vs_heuristic = float('-inf')
with open(log_path, 'a', newline='') as log_file:
log = csv.writer(log_file)
if not log_exists:
log.writerow(['iteration', 'selfplay_points', 'policy_loss',
'value_loss', 'entropy', 'vs_random_points',
'vs_random_hit', 'vs_heuristic_points',
'vs_heuristic_hit', 'seconds'])
for iteration in range(1, iterations + 1):
started = time.time()
# linearny decay: lr klesa k lr*lr_final_frac, entropny bonus
# k ent_coef_final -- policy sa ku koncu behu moze doostrit
frac = 1 - (iteration - 1) / max(iterations - 1, 1)
for group in optimizer.param_groups:
group['lr'] = lr * (lr_final_frac + (1 - lr_final_frac) * frac)
ent_coef = ent_coef_final + (ent_coef_start - ent_coef_final) * frac
batch = collect_episodes(net, episodes, rng,
mix_random=mix_random,
mix_heuristic=mix_heuristic)
stats = ppo_update(net, optimizer, batch, ent_coef=ent_coef)
save_checkpoint(net, hidden, os.path.join(CHECKPOINT_DIR, 'latest.pt'))
row = [iteration, f'{batch["mean_points"]:.3f}',
f'{stats["policy_loss"]:.4f}', f'{stats["value_loss"]:.2f}',
f'{stats["entropy"]:.3f}']
line = (f'it {iteration:4d} | self-play {batch["mean_points"]:5.2f} '
f'b/kolo | pi {stats["policy_loss"]:+.4f} '
f'| V {stats["value_loss"]:7.2f} | H {stats["entropy"]:.3f}')
if iteration % eval_every == 0 or iteration == iterations:
ev = evaluate_against_baselines(net, eval_rounds, rng)
row += [f'{ev["vs_random_points"]:.3f}', f'{ev["vs_random_hit"]:.3f}',
f'{ev["vs_heuristic_points"]:.3f}', f'{ev["vs_heuristic_hit"]:.3f}']
line += (f' | vs random {ev["vs_random_points"]:5.2f} '
f'({100 * ev["vs_random_hit"]:.0f} %)'
f' | vs heur {ev["vs_heuristic_points"]:5.2f} '
f'({100 * ev["vs_heuristic_hit"]:.0f} %)')
if ev['vs_heuristic_points'] > best_vs_heuristic:
best_vs_heuristic = ev['vs_heuristic_points']
save_checkpoint(net, hidden,
os.path.join(CHECKPOINT_DIR, 'best.pt'))
line += ' *best*'
else:
row += ['', '', '', '']
row.append(f'{time.time() - started:.1f}')
log.writerow(row)
log_file.flush()
print(line)
return net
def main():
parser = argparse.ArgumentParser(description='Self-play PPO trening bridzik siete')
parser.add_argument('--iterations', type=int, default=200)
parser.add_argument('--episodes', type=int, default=512,
help='self-play kol na iteraciu')
parser.add_argument('--lr', type=float, default=3e-4)
parser.add_argument('--seed', type=int, default=1)
parser.add_argument('--eval-every', type=int, default=10)
parser.add_argument('--eval-rounds', type=int, default=400)
parser.add_argument('--hidden', type=int, default=384,
help='sirka skrytych vrstiev trupu')
parser.add_argument('--resume', type=str, default=None,
help='cesta k checkpointu (.pt) na pokracovanie')
parser.add_argument('--mix-random', type=float, default=0.0,
help='pravdepodobnost RandomPlayer sedadla v epizode')
parser.add_argument('--mix-heuristic', type=float, default=0.0,
help='pravdepodobnost HeuristicPlayer sedadla v epizode')
args = parser.parse_args()
train(args.iterations, args.episodes, args.lr, args.seed,
args.eval_every, args.eval_rounds, args.resume, hidden=args.hidden,
mix_random=args.mix_random, mix_heuristic=args.mix_heuristic)
if __name__ == '__main__':
main()
File diff suppressed because one or more lines are too long
View File
+218
View File
@@ -0,0 +1,218 @@
"""Testy in-process botov (api/bots.py + tahova slucka v api/__init__.py).
Rovnaky setup ako tests/test_history.py: docasny SQLite subor, env pred
importom. Socket.IO emity idu do prazdnych roomov (ziadny klient), takze
handlery a slucka sa daju volat priamo bez klienta.
"""
import asyncio
import os
import tempfile
import unittest
import uuid
# Nastav DB/ENCRYPTION_KEY PRED importom db/api modulov.
_DB_FILE = os.path.join(tempfile.gettempdir(), f"bridzik_test_{uuid.uuid4().hex}.db")
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///" + _DB_FILE.replace("\\", "/")
from cryptography.fernet import Fernet # noqa: E402
os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode())
from random import Random # noqa: E402
import api # noqa: E402
from api import auth, bots, history # noqa: E402
from db.db import init_db # noqa: E402
from rl.players import HeuristicPlayer, RandomPlayer # noqa: E402
def run(coro):
return asyncio.run(coro)
def _make_bot_accounts(n):
exclude = set()
accounts = []
for _ in range(n):
acc = run(bots.ensure_bot_account("random", exclude))
exclude.add(acc["player_id"])
accounts.append(acc)
return accounts
def _make_game(seat_accounts, brains):
"""Postavi zacatu in-memory hru + Game riadok v DB."""
gid = str(uuid.uuid4())
game = api.Game(gid, "test")
for seat, acc in enumerate(seat_accounts):
player = api.Player(None, acc["username"], seat, acc["player_id"])
if brains[seat] is not None:
player.is_bot = True
player.brain = brains[seat]
player.connected = True
else:
player.connected = False
game.players.append(player)
game.start()
api.games[gid] = game
run(history.record_game_started(
gid, "test", [acc["player_id"] for acc in seat_accounts]
))
return game
class BotAccountCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
run(init_db())
api.BOT_MOVE_DELAY_SECONDS = 0
api.TRICK_SWEEP_SECONDS = 0
def test_username_conventions(self):
self.assertTrue(bots.is_bot_username("bot:heuristic-1"))
self.assertFalse(bots.is_bot_username("alice"))
self.assertEqual(bots.kind_of("bot:heuristic-2"), "heuristic")
self.assertEqual(bots.kind_of("bot:random-1"), "random")
self.assertEqual(bots.kind_of("bot:neural-1"), "neural")
self.assertEqual(bots.kind_of("bot:nezmysel-9"), bots.DEFAULT_KIND)
self.assertIsInstance(bots.make_brain("bot:heuristic-1"), HeuristicPlayer)
self.assertIsInstance(bots.make_brain("bot:random-3"), RandomPlayer)
@unittest.skipUnless(bots.neural_available(),
'chyba export vah (py -m rl.export)')
def test_neural_kind(self):
from rl.pure_net import PureNeuralPlayer
self.assertIn("neural", bots.available_kinds())
brain = bots.make_brain("bot:neural-1")
self.assertIsInstance(brain, PureNeuralPlayer)
# zdielana instancia PureNet (vahy sa nacitavaju len raz)
self.assertIs(brain.net, bots.make_brain("bot:neural-2").net)
def test_ensure_bot_account_reuse_and_exclude(self):
first = run(bots.ensure_bot_account("heuristic", set()))
self.assertTrue(first["username"].startswith("bot:heuristic-"))
# bez vylucenia sa ucet recykluje
again = run(bots.ensure_bot_account("heuristic", set()))
self.assertEqual(first["player_id"], again["player_id"])
# s vylucenim vznikne dalsi ucet s inym ID
second = run(bots.ensure_bot_account("heuristic", {first["player_id"]}))
self.assertNotEqual(first["player_id"], second["player_id"])
self.assertNotEqual(first["username"], second["username"])
def test_bot_account_cannot_be_hijacked(self):
acc = run(bots.ensure_bot_account("heuristic", set()))
# registracia mena zlyha -- ucet sa netvari ako nedokoncena registracia
with self.assertRaises(auth.AuthError):
run(auth.register_account(acc["username"]))
# login zlyha na kode (secret nikto nepozna), NIE na RegistrationIncomplete
with self.assertRaises(auth.AuthError) as ctx:
run(auth.login(acc["username"], "000000"))
self.assertNotIsInstance(ctx.exception, auth.RegistrationIncomplete)
class BotTurnLoopCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
run(init_db())
api.BOT_MOVE_DELAY_SECONDS = 0
api.TRICK_SWEEP_SECONDS = 0
def setUp(self):
api.games.clear()
api.sessions.clear()
api.accounts.clear()
def test_four_bots_play_whole_game(self):
accounts = _make_bot_accounts(4)
brains = [RandomPlayer(Random(seat)) for seat in range(4)]
game = _make_game(accounts, brains)
run(api._run_bot_turns(game.gid))
self.assertTrue(game.bridzik_core.is_completed())
# cela hra je zapisana: 4 serie x 8 kol, ended_at nastaveny
detail = run(history.get_game_detail(game.gid))
self.assertIsNotNone(detail["ended_at"])
self.assertEqual(len(detail["rounds"]), history.FULL_GAME_ROUNDS * 4)
def test_bots_stop_at_human_turn(self):
accounts = _make_bot_accounts(3)
# sedadlo 0 = clovek; identitu v DB mu robi dalsi (nepouzity) boti
# ucet -- pre historiu je to len player_id, wrapper bez mozgu = clovek
human_acc = run(bots.ensure_bot_account(
"random", {a["player_id"] for a in accounts}
))
game = _make_game(
[human_acc] + accounts,
[None, HeuristicPlayer(Random(1), n_samples=20),
RandomPlayer(Random(2)), RandomPlayer(Random(3))],
)
core = game.bridzik_core
rnd = core.series[-1].get_last_round()
async def scenario():
# na tahu je clovek (first_player serie 0 je sedadlo 0) -> boti nic
await api._run_bot_turns(game.gid)
self.assertEqual(len(rnd.guesses), 0)
# clovek tipne -> boti dotipuju a hraju az po dalsi tah cloveka
core.add_player_guess(0, 1)
await api._run_bot_turns(game.gid)
run(scenario())
self.assertTrue(rnd.is_guessing_completed())
self.assertEqual(rnd.get_active_player(), 0)
def test_add_and_remove_bot_handlers(self):
async def scenario():
gid = str(uuid.uuid4())
api.games[gid] = api.Game(gid, "lobby-test")
host = api.Player("sid-host", "hostiteľ", 0, 999_100)
api.games[gid].players.append(host)
api.sessions["sid-host"] = {"gid": gid, "order": 0}
api.sessions["sid-guest"] = {"gid": gid, "order": 1}
# nehostitel nesmie pridat bota
await api.add_bot("sid-guest", gid)
self.assertEqual(len(api.games[gid].players), 1)
# hostitel prida dvoch botov -> rozne ucty, najnizsie volne sedadla
await api.add_bot("sid-host", gid)
await api.add_bot("sid-host", gid, "random")
players = api.games[gid].players
self.assertEqual(len(players), 3)
bots_added = [p for p in players if p.is_bot]
self.assertEqual(len(bots_added), 2)
self.assertEqual({p.order for p in bots_added}, {1, 2})
self.assertNotEqual(bots_added[0].player_id, bots_added[1].player_id)
self.assertIsNotNone(bots_added[0].brain)
# remove_bot: odmietne cloveka, odoberie bota
await api.remove_bot("sid-host", gid, 0)
self.assertEqual(len(api.games[gid].players), 3)
await api.remove_bot("sid-host", gid, 1)
self.assertEqual(len(api.games[gid].players), 2)
self.assertIsNone(api.games[gid].player_by_order(1))
run(scenario())
def test_restore_marks_bots(self):
from bridzik import Bridzik
info = {
"gid": str(uuid.uuid4()),
"name": "obnova",
"seats": [(1, "alice"), (2, "bot:heuristic-1"),
(3, "bot:random-1"), (4, "bob")],
"core": Bridzik(),
}
game = api._load_game_into_memory(info)
self.assertFalse(game.players[0].is_bot)
self.assertFalse(game.players[0].connected)
self.assertTrue(game.players[1].is_bot)
self.assertTrue(game.players[1].connected)
self.assertIsInstance(game.players[1].brain, HeuristicPlayer)
self.assertIsInstance(game.players[2].brain, RandomPlayer)
if __name__ == '__main__':
unittest.main(verbosity=2)
+296
View File
@@ -0,0 +1,296 @@
import copy
import random
import unittest
from bridzik import cards, Card, Card_colors, Card_values, BridzikException, Round
from rl.encoding import (
COLORS, VALUES, N_CARDS, N_GUESS_ACTIONS, N_PLAY_ACTIONS,
OFF_HAND, OFF_SEEN, OFF_ROUND, OFF_PHASE, OFF_GUESSES, OFF_TRICKS,
OFF_STASH, OFF_STASH_LEADER, OFF_VOIDS, OBS_DIM,
card_index, deduce_voids, index_card, relative_seat, encode_observation,
guess_mask, play_mask,
)
class CardIndexCase(unittest.TestCase):
def test_roundtrip_and_uniqueness(self):
indexes = set()
for card in cards:
idx = card_index(card)
self.assertIn(idx, range(N_CARDS))
self.assertEqual(index_card(idx), card)
indexes.add(idx)
self.assertEqual(len(indexes), N_CARDS)
def test_layout(self):
# farba = blok po 8, hodnota = pozicia v bloku
self.assertEqual(card_index(Card(Card_colors['HEARTS'], Card_values['C7'])), 0)
self.assertEqual(card_index(Card(Card_colors['HEARTS'], Card_values['ACE'])), 7)
self.assertEqual(card_index(Card(COLORS[3], Card_values['ACE'])), 31)
class RotationCase(unittest.TestCase):
def test_relative_seat(self):
for player in range(4):
self.assertEqual(relative_seat(player, player), 0)
# smer hry = rastuce cislo sedadla mod 4
self.assertEqual(relative_seat((player + 1) % 4, player), 1)
self.assertEqual(relative_seat((player + 3) % 4, player), 3)
def test_guesses_rotated_for_all_seats(self):
r = Round(0, 2)
guesses = {2: 5, 3: 0, 0: 1, 1: 1}
for seat in [2, 3, 0, 1]:
r.add_player_guess(seat, guesses[seat])
for player in range(4):
obs = encode_observation(r, player)
for seat in range(4):
rel = relative_seat(seat, player)
self.assertEqual(obs[OFF_GUESSES + 2 * rel], 1.0)
self.assertEqual(obs[OFF_GUESSES + 2 * rel + 1], guesses[seat] / 8)
def test_partial_guesses_flags(self):
r = Round(3, 1)
r.add_player_guess(1, 2)
for player in range(4):
obs = encode_observation(r, player)
rel = relative_seat(1, player)
self.assertEqual(obs[OFF_GUESSES + 2 * rel], 1.0)
self.assertEqual(obs[OFF_GUESSES + 2 * rel + 1], 2 / 8)
for seat in [0, 2, 3]:
rel = relative_seat(seat, player)
self.assertEqual(obs[OFF_GUESSES + 2 * rel], 0.0)
self.assertEqual(obs[OFF_GUESSES + 2 * rel + 1], 0.0)
class ObservationCase(unittest.TestCase):
@staticmethod
def _deterministic_round():
# rovnaka konstrukcia ako v test_engine.RoundCase.test_play_card
shuffler = lambda l: None
c0 = [Card(Card_colors['BELLS'], Card_values['UPPER']),
Card(Card_colors['HEARTS'], Card_values['UPPER'])]
c1 = [Card(Card_colors['BELLS'], Card_values['C7']),
Card(Card_colors['HEARTS'], Card_values['C10'])]
c2 = [Card(Card_colors['BELLS'], Card_values['ACE']),
Card(Card_colors['BELLS'], Card_values['C8'])]
c3 = [Card(Card_colors['LEAVES'], Card_values['C7']),
Card(Card_colors['BELLS'], Card_values['LOWER'])]
c = ['dummy'] * 24 + c0 + c1 + c2 + c3
r = Round(6, 1, c, shuffler)
return r, [c0, c1, c2, c3]
def test_hand_multi_hot(self):
r, hands = self._deterministic_round()
for player in range(4):
obs = encode_observation(r, player)
hand_indexes = {card_index(c) for c in hands[player]}
for i in range(N_CARDS):
self.assertEqual(obs[OFF_HAND + i], 1.0 if i in hand_indexes else 0.0)
def test_round_number_and_phase(self):
r, _ = self._deterministic_round()
obs = encode_observation(r, 0)
for i in range(8):
self.assertEqual(obs[OFF_ROUND + i], 1.0 if i == 6 else 0.0)
self.assertEqual(obs[OFF_PHASE], 1.0) # tipovacia faza
for seat, guess in [(1, 0), (2, 0), (3, 1), (0, 2)]:
r.add_player_guess(seat, guess)
obs = encode_observation(r, 0)
self.assertEqual(obs[OFF_PHASE], 0.0) # hracia faza
def test_current_stash_slots_and_seen(self):
r, hands = self._deterministic_round()
for seat, guess in [(1, 0), (2, 0), (3, 1), (0, 2)]:
r.add_player_guess(seat, guess)
# rozohrana kopka: hraju 0 a 1
r.play_card(0, hands[0][0])
r.play_card(1, hands[1][0])
for player in range(4):
obs = encode_observation(r, player)
slot0 = relative_seat(0, player)
slot1 = relative_seat(1, player)
self.assertEqual(obs[OFF_STASH + slot0 * N_CARDS + card_index(hands[0][0])], 1.0)
self.assertEqual(obs[OFF_STASH + slot1 * N_CARDS + card_index(hands[1][0])], 1.0)
self.assertEqual(sum(obs[OFF_STASH:OFF_STASH + 4 * N_CARDS]), 2.0)
# leader kopky je hrac 0 (najvyssi tip)
self.assertEqual(obs[OFF_STASH_LEADER + relative_seat(0, player)], 1.0)
# nic este nie je "videne" -- prva kopka nie je dokoncena
self.assertEqual(sum(obs[OFF_SEEN:OFF_SEEN + N_CARDS]), 0.0)
# dokoncena kopka -> karty sa presunu do SEEN, sloty sa vyprazdnia
r.play_card(2, hands[2][0])
r.play_card(3, hands[3][1])
obs = encode_observation(r, 0)
played = [hands[0][0], hands[1][0], hands[2][0], hands[3][1]]
for card in played:
self.assertEqual(obs[OFF_SEEN + card_index(card)], 1.0)
self.assertEqual(sum(obs[OFF_SEEN:OFF_SEEN + N_CARDS]), 4.0)
self.assertEqual(sum(obs[OFF_STASH:OFF_STASH + 4 * N_CARDS]), 0.0)
# novu kopku vynasa vitaz (hrac 2, BELLS ACE)
self.assertEqual(obs[OFF_STASH_LEADER + relative_seat(2, 0)], 1.0)
# pocty vyhranych kopiek rotovane
for player in range(4):
obs = encode_observation(r, player)
self.assertEqual(obs[OFF_TRICKS + relative_seat(2, player)], 1 / 8)
def test_terminal_state_encodable(self):
r = Round(7, 0)
for seat, guess in [(0, 0), (1, 0), (2, 0), (3, 0)]:
try:
r.add_player_guess(seat, guess)
except BridzikException:
r.add_player_guess(seat, 1)
while not r.is_completed():
player = r.get_active_player()
mask = play_mask(r, player)
r.play_card(player, index_card(mask.index(True)))
obs = encode_observation(r, 0)
self.assertEqual(len(obs), OBS_DIM)
self.assertEqual(sum(obs[OFF_SEEN:OFF_SEEN + N_CARDS]), 4.0)
class VoidsInObservationCase(unittest.TestCase):
def test_voids_encoded_and_rotated(self):
# hrac 0 vynasa zelen; 2 tromfne (void zelen), 3 hodi gulu (void
# zelen aj cerven) -- viz deduce_voids
hand0 = [Card(Card_colors['LEAVES'], Card_values['C7']),
Card(Card_colors['LEAVES'], Card_values['C8'])]
hand1 = [Card(Card_colors['LEAVES'], Card_values['C9']),
Card(Card_colors['LEAVES'], Card_values['C10'])]
hand2 = [Card(Card_colors['HEARTS'], Card_values['C7']),
Card(Card_colors['ACORNS'], Card_values['C7'])]
hand3 = [Card(Card_colors['BELLS'], Card_values['C7']),
Card(Card_colors['BELLS'], Card_values['C8'])]
rest = [c for c in cards if c not in hand0 + hand1 + hand2 + hand3]
deck = rest[:24] + hand0 + hand1 + hand2 + hand3
r = Round(6, 0, deck, shuffler=lambda l: None)
r.add_player_guess(0, 2)
r.add_player_guess(1, 0)
r.add_player_guess(2, 0)
r.add_player_guess(3, 1)
obs = encode_observation(r, 0)
self.assertEqual(sum(obs[OFF_VOIDS:OFF_VOIDS + 16]), 0.0)
for seat, card in [(0, hand0[0]), (1, hand1[0]),
(2, hand2[0]), (3, hand3[0])]:
r.play_card(seat, card)
leaves_i = COLORS.index(Card_colors['LEAVES'])
hearts_i = COLORS.index(Card_colors['HEARTS'])
for player in range(4):
obs = encode_observation(r, player)
block = lambda seat: obs[OFF_VOIDS + relative_seat(seat, player) * 4:
OFF_VOIDS + relative_seat(seat, player) * 4 + 4]
self.assertEqual(sum(block(0)), 0.0) # vynasajuci neprezradza nic
self.assertEqual(sum(block(1)), 0.0) # priznal farbu
self.assertEqual(block(2)[leaves_i], 1.0)
self.assertEqual(sum(block(2)), 1.0)
self.assertEqual(block(3)[leaves_i], 1.0)
self.assertEqual(block(3)[hearts_i], 1.0)
self.assertEqual(sum(block(3)), 2.0)
class GuessMaskCase(unittest.TestCase):
def test_range_by_round_number(self):
for round_number in range(8):
r = Round(round_number, 0)
mask = guess_mask(r)
for g in range(N_GUESS_ACTIONS):
self.assertEqual(mask[g], g <= 8 - round_number)
def test_last_guesser_forbidden_value(self):
r = Round(0, 0)
r.add_player_guess(0, 2)
r.add_player_guess(1, 1)
r.add_player_guess(2, 3)
mask = guess_mask(r)
self.assertFalse(mask[2]) # 2+1+3+2 == 8 kopiek -> zakazane
for g in [0, 1, 3, 4, 5, 6, 7, 8]:
self.assertTrue(mask[g])
def test_forbidden_value_out_of_range(self):
# sucet tipov > pocet kopiek -> zakazana hodnota by bola zaporna,
# ziadne dodatocne maskovanie
r = Round(0, 0)
r.add_player_guess(0, 8)
r.add_player_guess(1, 5)
r.add_player_guess(2, 0)
mask = guess_mask(r)
self.assertEqual(mask, [True] * 9)
class MaskEngineConsistencyCase(unittest.TestCase):
"""Fuzz: maska presne zrkadli engine -- povolena akcia NIKDY nezlyha,
zakazana akcia VZDY vyhodi BridzikException."""
def _check_guess_mask(self, rnd, player, mask):
for g in range(N_GUESS_ACTIONS):
if mask[g]:
copy.deepcopy(rnd).add_player_guess(player, g)
else:
with self.assertRaises(BridzikException):
rnd.add_player_guess(player, g)
def _check_play_mask(self, rnd, player, mask):
self.assertIn(True, mask) # aktivny hrac ma vzdy legalny tah
for i in range(N_PLAY_ACTIONS):
if mask[i]:
copy.deepcopy(rnd).play_card(player, index_card(i))
else:
with self.assertRaises(BridzikException):
rnd.play_card(player, index_card(i))
def _check_observation(self, rnd, player):
obs = encode_observation(rnd, player)
self.assertEqual(len(obs), OBS_DIM)
for v in obs:
self.assertGreaterEqual(v, 0.0)
self.assertLessEqual(v, 1.0)
hand = {card_index(c) for c in rnd.player_cards[player]}
for i in range(N_CARDS):
self.assertEqual(obs[OFF_HAND + i], 1.0 if i in hand else 0.0)
if i in hand: # ruka a videne karty su disjunktne
self.assertEqual(obs[OFF_SEEN + i], 0.0)
# zakodovany void nikdy neprotireci realnej ruke hraca
for seat in range(4):
rel = relative_seat(seat, player)
held = {c.color for c in rnd.player_cards[seat]}
for ci, color in enumerate(COLORS):
if obs[OFF_VOIDS + rel * 4 + ci] == 1.0:
self.assertNotIn(color, held)
def _fuzz_round(self, rng, round_number, first_player):
rnd = Round(round_number, first_player)
for _ in range(4):
player = rnd.get_active_player()
self._check_observation(rnd, player)
mask = guess_mask(rnd)
self._check_guess_mask(rnd, player, mask)
rnd.add_player_guess(
player, rng.choice([g for g in range(N_GUESS_ACTIONS) if mask[g]])
)
while not rnd.is_completed():
player = rnd.get_active_player()
self._check_observation(rnd, player)
mask = play_mask(rnd, player)
self._check_play_mask(rnd, player, mask)
rnd.play_card(
player, index_card(rng.choice([i for i in range(N_PLAY_ACTIONS) if mask[i]]))
)
# kolo dohrane do konca cisto cez masky -> bodovanie funguje
self.assertEqual(len(rnd.get_points_summary()), 4)
def test_fuzz_all_round_numbers_and_seats(self):
rng = random.Random(1337)
for round_number in range(8):
for first_player in range(4):
for _ in range(3):
self._fuzz_round(rng, round_number, first_player)
if __name__ == '__main__':
unittest.main(verbosity=2)
+14 -4
View File
@@ -1,5 +1,5 @@
import unittest
from game import Stash, cards, Card_colors, Card_values,\
from bridzik import Stash, cards, Card_colors, Card_values,\
RuleException, BridzikException, Card, Round
class StashCase(unittest.TestCase):
@@ -121,6 +121,16 @@ class StashCase(unittest.TestCase):
self.assertRaises(BridzikException, s.get_active_player)
class CardCase(unittest.TestCase):
def test_hashable_consistent_with_eq(self):
# vlastne __eq__ nesmie zrusit hashovatelnost (dict/set pouzitie)
heart_7 = Card(Card_colors['HEARTS'], Card_values['C7'])
self.assertEqual(hash(heart_7), hash(Card(Card_colors['HEARTS'], Card_values['C7'])))
self.assertEqual(len(set(cards)), 32)
self.assertEqual({Card_colors['HEARTS']: 1}[Card_colors['HEARTS']], 1)
self.assertEqual({Card_values['ACE']: 1}[Card_values['ACE']], 1)
class RoundCase(unittest.TestCase):
def test_round_constructor(self):
self.assertRaises(BridzikException, Round, round_number=8, first_player=0)
@@ -185,9 +195,9 @@ class RoundCase(unittest.TestCase):
self.assertEqual(r.get_active_player(), 2)
r.add_player_guess(2, 0)
self.assertEqual(r.get_active_player(), 3)
r.add_player_guess(3, 2)
r.add_player_guess(3, 1)
self.assertEqual(r.get_active_player(), 0)
r.add_player_guess(0, 4)
r.add_player_guess(0, 2)
self.assertEqual(r.get_active_player(), 0)
r.play_card(0, r.player_cards[0][0])
@@ -256,7 +266,7 @@ class RoundCase(unittest.TestCase):
r.add_player_guess(1, 1)
r.add_player_guess(2, 0)
r.add_player_guess(3, 2)
r.add_player_guess(0, 4)
r.add_player_guess(0, 1)
self.assertTrue(r.is_guessing_completed())
def test_get_last_stash(self):
+313
View File
@@ -0,0 +1,313 @@
"""Testy persistentnej vrstvy (db/ + api/history.py + api/auth.py).
Bezia na docasnom SQLite subore. Engine sa nepouziva priamo -- pre zapisovu
logiku staci lahky stub, ktory zrkadli rozhranie bridzik.Bridzik (series ->
rounds -> guesses/get_points_summary). Cisty engine ma vlastne testy v tests.py.
"""
import asyncio
import os
import tempfile
import unittest
import uuid
from types import SimpleNamespace
# Nastav DB/ENCRYPTION_KEY PRED importom db/api modulov -- engine sa vytvara pri importe.
_DB_FILE = os.path.join(tempfile.gettempdir(), f"bridzik_test_{uuid.uuid4().hex}.db")
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///" + _DB_FILE.replace("\\", "/")
from cryptography.fernet import Fernet # noqa: E402
os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode())
import pyotp # noqa: E402
from sqlalchemy import select # noqa: E402
from api import auth, history # noqa: E402
from db import crypto # noqa: E402
from db.db import async_session, init_db # noqa: E402
from db.models import Player # noqa: E402
def run(coro):
return asyncio.run(coro)
def make_core(completed=True):
"""Stub jednej hry s jednym dohratym kolom (seria 0, kolo 0)."""
rnd = SimpleNamespace(
round_number=0,
guesses={0: 2, 1: 1, 2: 0, 3: 1},
is_completed=lambda: True,
get_points_summary=lambda: [12, 0, 10, 11],
)
series = SimpleNamespace(
series_number=0, rounds=[rnd], get_last_round=lambda: rnd
)
return SimpleNamespace(series=[series], is_completed=lambda: completed)
class HistoryCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
run(init_db())
def _make_players(self, n=4):
ids = []
for _ in range(n):
username = "u_" + uuid.uuid4().hex[:8]
data = run(auth.register_account(username))
ident = run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
ids.append(ident["player_id"])
return ids
@staticmethod
def _next_step_code(secret):
"""Kod pre NASLEDUJUCI casovy krok -- confirm uz spotreboval aktualny."""
return pyotp.TOTP(secret).at((auth._current_step() + 1) * auth.TOTP_PERIOD)
def test_register_login_token(self):
username = "alice_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username))
self.assertIn("otpauth_uri", data)
# Nedokoncena registracia: opakovany register vyda NOVY secret
data = run(auth.register_account(username))
self.assertIn("otpauth_uri", data)
# Login pred potvrdenim -> RegistrationIncomplete (novy QR namiesto chyby)
with self.assertRaises(auth.RegistrationIncomplete):
run(auth.login(username, "000000"))
code = pyotp.TOTP(data["secret"]).now()
ident = run(auth.confirm_account(username, code))
self.assertEqual(ident["username"], username)
self.assertTrue(ident["token"])
# Po potvrdeni je uz meno obsadene
with self.assertRaises(auth.AuthError):
run(auth.register_account(username))
# Token sa da spatne rozlustit na identitu
resolved = run(auth.player_by_token(ident["token"]))
self.assertEqual(resolved["player_id"], ident["player_id"])
# Zly kod neprejde
with self.assertRaises(auth.AuthError):
run(auth.login(username, "000000"))
def test_reissued_secret_invalidates_old_qr(self):
username = "fred_" + uuid.uuid4().hex[:6]
old = run(auth.register_account(username))
new = run(auth.register_account(username))
self.assertNotEqual(old["secret"], new["secret"])
# Kod zo stareho QR uz neplati, z noveho ano
with self.assertRaises(auth.AuthError):
run(auth.confirm_account(username, pyotp.TOTP(old["secret"]).now()))
ident = run(auth.confirm_account(username, pyotp.TOTP(new["secret"]).now()))
self.assertEqual(ident["username"], username)
def test_login_lockout_after_repeated_failures(self):
username = "bob_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username))
run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
for _ in range(auth._LOGIN_ATTEMPT_LIMIT):
with self.assertRaises(auth.AuthError):
run(auth.login(username, "000000"))
# Lockout odmietne aj spravny kod, kym neubehne okno
with self.assertRaises(auth.AuthError):
run(auth.login(username, self._next_step_code(data["secret"])))
def test_successful_login_clears_failed_attempts(self):
username = "carol_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username))
run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
for _ in range(auth._LOGIN_ATTEMPT_LIMIT - 1):
with self.assertRaises(auth.AuthError):
run(auth.login(username, "000000"))
ident = run(auth.login(username, self._next_step_code(data["secret"])))
self.assertEqual(ident["username"], username)
self.assertNotIn(username, auth._failed_attempts)
def test_record_rounds_and_idempotency(self):
ids = self._make_players()
gid = str(uuid.uuid4())
core = make_core()
run(history.record_game_started(gid, "Test", ids))
run(history.record_completed_rounds(gid, core))
# Opakovany zapis nesmie zalozit duplikaty.
run(history.record_completed_rounds(gid, core))
detail = run(history.get_game_detail(gid))
self.assertIsNotNone(detail)
self.assertEqual(len(detail["rounds"]), 4) # 4 hraci x 1 kolo
by_seat = {r["player_id"]: r for r in detail["rounds"]}
# Body podla get_points_summary; won = points > 0.
self.assertEqual(by_seat[ids[0]]["points"], 12)
self.assertTrue(by_seat[ids[0]]["won"])
self.assertEqual(by_seat[ids[1]]["points"], 0)
self.assertFalse(by_seat[ids[1]]["won"])
self.assertIsNotNone(detail["ended_at"]) # core.is_completed() -> ended
def test_rebuild_core_position(self):
# Z dvoch cisel sa obnovi spravna pozicia a karty sa rozdaju nanovo.
core = history.rebuild_core(2, 3)
self.assertEqual(core.series[-1].series_number, 2)
last_round = core.series[-1].get_last_round()
self.assertEqual(last_round.round_number, 3)
# V kole 3 ma kazdy hrac 8 - 3 = 5 kariet.
self.assertEqual(len(core.get_player_cards(0)), 5)
def test_restore_game_from_db(self):
ids = self._make_players()
gid = str(uuid.uuid4())
run(history.record_game_started(gid, "Test", ids))
run(history.record_completed_rounds(gid, make_core())) # zapise poziciu
restored = run(history.restore_game_core(gid))
self.assertIsNotNone(restored)
# make_core() je seria 0, kolo 0 -> pozicia 0/0
self.assertEqual(restored.series[-1].series_number, 0)
self.assertEqual(restored.series[-1].get_last_round().round_number, 0)
def test_unfinished_games_listed(self):
ids = self._make_players()
gid = str(uuid.uuid4())
run(history.record_game_started(gid, "Nedohrata", ids))
run(history.record_completed_rounds(gid, make_core(completed=False)))
rows = run(history.get_unfinished_games())
mine = next((g for g in rows if g["gid"] == gid), None)
self.assertIsNotNone(mine)
self.assertEqual(mine["name"], "Nedohrata")
self.assertEqual(len(mine["seats"]), 4)
self.assertEqual(mine["seats"][0][0], ids[0]) # player_id na sedadle 0
self.assertIsNotNone(mine["core"]) # uz postaveny Bridzik
def test_mark_game_ended_removes_from_unfinished(self):
ids = self._make_players()
gid = str(uuid.uuid4())
run(history.record_game_started(gid, "Vzdana", ids))
run(history.record_completed_rounds(gid, make_core(completed=False)))
self.assertTrue(any(g["gid"] == gid for g in run(history.get_unfinished_games())))
run(history.mark_game_ended(gid))
self.assertFalse(any(g["gid"] == gid for g in run(history.get_unfinished_games())))
def test_reopen_prematurely_ended_game(self):
ids = self._make_players()
gid = str(uuid.uuid4())
run(history.record_game_started(gid, "Vzdana", ids))
run(history.record_completed_rounds(gid, make_core(completed=False)))
run(history.mark_game_ended(gid))
# V historii sa ukazuje ako predcasne ukoncena (nie naplno dohrana).
rows = run(history.get_player_history(ids[0]))
mine = next(g for g in rows if g["gid"] == gid)
self.assertFalse(mine["completed"])
# Cudzi hrac ju obnovit nemoze.
outsider = self._make_players(1)[0]
self.assertIsNone(run(history.reopen_game(gid, outsider)))
# Clen ju obnovi -> ended_at sa zmaze a hra je zas medzi nedohratymi.
info = run(history.reopen_game(gid, ids[0]))
self.assertIsNotNone(info)
self.assertEqual(len(info["seats"]), 4)
self.assertTrue(any(g["gid"] == gid for g in run(history.get_unfinished_games())))
# A teda uz nie je v historii (zobrazuju sa iba ukoncene hry).
self.assertFalse(any(g["gid"] == gid for g in run(history.get_player_history(ids[0]))))
def test_standings_from_db(self):
ids = self._make_players()
gid = str(uuid.uuid4())
run(history.record_game_started(gid, "Test", ids))
run(history.record_completed_rounds(gid, make_core()))
standings, guesses = run(history.get_standings(gid))
# 1 seria, 1 kolo. Body podla sedadiel zo stubu [12, 0, 10, 11],
# tipy zo stubu {0: 2, 1: 1, 2: 0, 3: 1}.
self.assertEqual(standings, [[[12, 0, 10, 11]]])
self.assertEqual(guesses, [[[2, 1, 0, 1]]])
def test_player_history(self):
ids = self._make_players()
gid = str(uuid.uuid4())
run(history.record_game_started(gid, "Test", ids))
run(history.record_completed_rounds(gid, make_core()))
rows = run(history.get_player_history(ids[0]))
self.assertTrue(any(g["gid"] == gid for g in rows))
mine = next(g for g in rows if g["gid"] == gid)
self.assertEqual(mine["my_points"], 12)
self.assertEqual(len(mine["players"]), 4)
def test_totp_secret_stored_encrypted_not_plaintext(self):
username = "dave_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username))
async def _raw_secret():
async with async_session() as session:
return await session.scalar(
select(Player.totp_secret).where(Player.username == username)
)
stored = run(_raw_secret())
self.assertNotEqual(stored, data["secret"])
self.assertEqual(crypto.decrypt(stored), data["secret"])
def test_auth_token_stored_hashed_not_plaintext(self):
username = "erin_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username))
ident = run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
async def _raw_token():
async with async_session() as session:
return await session.scalar(
select(Player.auth_token).where(Player.username == username)
)
stored = run(_raw_token())
self.assertNotEqual(stored, ident["token"])
self.assertEqual(stored, crypto.hash_token(ident["token"]))
class CryptoCase(unittest.TestCase):
def test_encrypt_decrypt_roundtrip(self):
secret = "JBSWY3DPEHPK3PXP"
ciphertext = crypto.encrypt(secret)
self.assertNotEqual(ciphertext, secret)
self.assertEqual(crypto.decrypt(ciphertext), secret)
def test_hash_token_is_deterministic_and_distinct(self):
self.assertEqual(crypto.hash_token("abc"), crypto.hash_token("abc"))
self.assertNotEqual(crypto.hash_token("abc"), crypto.hash_token("abd"))
def test_missing_key_raises(self):
saved = os.environ.pop("ENCRYPTION_KEY")
try:
with self.assertRaises(RuntimeError):
crypto.encrypt("x")
finally:
os.environ["ENCRYPTION_KEY"] = saved
def tearDownModule():
from db.db import engine
run(engine.dispose())
try:
os.remove(_DB_FILE)
except OSError:
pass
if __name__ == "__main__":
unittest.main()
+155
View File
@@ -0,0 +1,155 @@
"""Testy presnosti cisto-Python inferencie (rl/pure_net.py) voci torch.
Jadro suity: na zivych observaciach z nahodne rozohranych kol sa porovnavaju
logity a zvolene akcie pure-Python siete s torch sietou nacitanou z toho
isteho checkpointu. Case bez torch (cisty beh, legalnost, determinizmus)
bezia vzdy; porovnavacie case sa preskocia, ak torch nie je nainstalovany.
"""
import copy
import os
import unittest
from random import Random
from bridzik import Round
from rl.encoding import encode_observation, guess_mask, index_card, play_mask
from rl.env import PHASE_GUESS, RoundEnv
from rl.evaluate import play_round
from rl.players import RandomPlayer
from rl.pure_net import DEFAULT_WEIGHTS_PATH, PureNet, PureNeuralPlayer
WEIGHTS_AVAILABLE = os.path.exists(DEFAULT_WEIGHTS_PATH)
try:
import torch
from rl.policy_player import NeuralPlayer
from rl.train import load_checkpoint
TORCH_AVAILABLE = True
except ImportError: # pragma: no cover
TORCH_AVAILABLE = False
CHECKPOINT = os.path.join('rl', 'checkpoints', 'latest.pt')
def _random_decision_points(rng, n_rounds=12):
"""Vygeneruje zive rozhodovacie body (rnd, seat, faza) nahodnou hrou."""
env = RoundEnv(rng)
points = []
for i in range(n_rounds):
decision = env.reset(round_number=i % 8)
while True:
# snapshot -- env.round sa dalsou hrou mutuje
points.append((copy.deepcopy(env.round), decision.player, decision.phase))
action = rng.choice([a for a, ok in enumerate(decision.mask) if ok])
decision, rewards, done = env.step(action)
if done:
break
return points
@unittest.skipUnless(WEIGHTS_AVAILABLE, 'chyba export vah (py -m rl.export)')
class PureOnlyCase(unittest.TestCase):
"""Bezi aj bez torch -- presne to, co pobezi v produkcii."""
@classmethod
def setUpClass(cls):
cls.player = PureNeuralPlayer.load()
def test_plays_legal_full_rounds(self):
env = RoundEnv(Random(1))
players = [self.player, self.player,
RandomPlayer(Random(2)), RandomPlayer(Random(3))]
for round_number in range(8):
rewards = play_round(players, env, round_number)
self.assertEqual(len(rewards), 4)
def test_deterministic(self):
r = Round(2, 0)
self.assertEqual(self.player.guess(r, 0), self.player.guess(r, 0))
def test_respects_masks(self):
rng = Random(4)
for rnd, seat, phase in _random_decision_points(rng, n_rounds=8):
if phase == PHASE_GUESS:
self.assertTrue(guess_mask(rnd)[self.player.guess(rnd, seat)])
else:
self.assertTrue(play_mask(rnd, seat)[self.player.play(rnd, seat)])
@unittest.skipUnless(WEIGHTS_AVAILABLE and TORCH_AVAILABLE
and os.path.exists(CHECKPOINT),
'treba torch + checkpoint + export vah')
class TorchParityCase(unittest.TestCase):
"""Zhoda pure-Python inferencie s torch na tom istom checkpointe."""
@classmethod
def setUpClass(cls):
cls.pure = PureNet.load()
cls.torch_net = load_checkpoint(CHECKPOINT)
cls.torch_net.eval()
cls.points = _random_decision_points(Random(7), n_rounds=16)
def _torch_logits(self, obs, phase):
with torch.no_grad():
guess_logits, play_logits, _ = self.torch_net(
torch.tensor(obs, dtype=torch.float32).unsqueeze(0)
)
t = guess_logits if phase == PHASE_GUESS else play_logits
return t.squeeze(0).tolist()
def test_logits_match(self):
"""Logity sa zhoduju na ~1e-4 (rozdiel = len poradie scitovania
float32 vs float64, ziadna strata z exportu -- vahy su bit-exact)."""
worst = 0.0
for rnd, seat, phase in self.points:
obs = encode_observation(rnd, seat)
pure = self.pure.guess_logits(obs) if phase == PHASE_GUESS \
else self.pure.play_logits(obs)
ref = self._torch_logits(obs, phase)
for a, b in zip(pure, ref):
worst = max(worst, abs(a - b))
self.assertLess(worst, 1e-3, f'najvacsi rozdiel logitov: {worst}')
def test_actions_match(self):
"""Zvolena akcia je identicka vzdy, ked nejde o numericku remizu
(top-2 logity blizsie nez 1e-3 -- prakticky nenastava)."""
player = PureNeuralPlayer(self.pure)
torch_player = NeuralPlayer(self.torch_net, greedy=True)
compared = ties = 0
for rnd, seat, phase in self.points:
obs = encode_observation(rnd, seat)
if phase == PHASE_GUESS:
a, b = player.guess(rnd, seat), torch_player.guess(rnd, seat)
mask = guess_mask(rnd)
logits = self.pure.guess_logits(obs)
else:
a, b = player.play(rnd, seat), torch_player.play(rnd, seat)
mask = play_mask(rnd, seat)
logits = self.pure.play_logits(obs)
allowed = sorted((logits[i] for i in range(len(mask)) if mask[i]),
reverse=True)
if len(allowed) > 1 and allowed[0] - allowed[1] < 1e-3:
ties += 1 # numericka remiza -- volba je legitimne lubovolna
continue
compared += 1
self.assertEqual(a, b, f'akcie sa lisia mimo remizy ({phase})')
self.assertGreater(compared, 50) # test realne porovnaval
def test_full_rounds_identical_trajectories(self):
"""Dve identicke partie: pure aj torch hrac na vsetkych 4 sedadlach
s rovnakym rozdanim musia zahrat uplne rovnake kolo."""
pure_player = PureNeuralPlayer(self.pure)
torch_player = NeuralPlayer(self.torch_net, greedy=True)
for round_number in range(8):
results = []
for player in (pure_player, torch_player):
env = RoundEnv(Random(100 + round_number))
rewards = play_round([player] * 4, env, round_number)
results.append((rewards,
sorted(str(s.get_cards())
for s in env.round.stashes)))
self.assertEqual(results[0], results[1])
if __name__ == '__main__':
unittest.main(verbosity=2)
+316
View File
@@ -0,0 +1,316 @@
import unittest
from random import Random
from bridzik import cards, Card, Card_colors, Card_values, Round
from rl.encoding import card_index, index_card, legal_cards
from rl.env import Decision, PHASE_GUESS, PHASE_PLAY, RoundEnv
from rl.evaluate import evaluate, play_round
from rl.players import (
HeuristicPlayer, McPlayer, RandomPlayer,
_beats, _current_best, deal_consistent, deduce_voids,
mc_guess_distribution, simulate_tricks,
)
class RoundEnvCase(unittest.TestCase):
def test_episode_structure(self):
env = RoundEnv(Random(42))
decision = env.reset(round_number=6, first_player=1)
rng = Random(0)
# prve 4 rozhodnutia su tipy, v poradi od first_player
expected_guessers = [1, 2, 3, 0]
for expected in expected_guessers:
self.assertIsInstance(decision, Decision)
self.assertEqual(decision.phase, PHASE_GUESS)
self.assertEqual(decision.player, expected)
action = rng.choice([g for g in range(9) if decision.mask[g]])
decision, rewards, done = env.step(action)
self.assertIsNone(rewards)
self.assertFalse(done)
# potom hracie rozhodnutia az po terminal: 2 karty x 4 hraci
steps = 0
while True:
self.assertEqual(decision.phase, PHASE_PLAY)
self.assertEqual(decision.player, env.round.get_active_player())
action = rng.choice([i for i in range(32) if decision.mask[i]])
decision, rewards, done = env.step(action)
steps += 1
if done:
break
self.assertEqual(steps, 8)
self.assertIsNone(decision)
self.assertEqual(rewards, env.round.get_points_summary())
self.assertEqual(len(rewards), 4)
# po done sa step neda volat, reset zacne novu epizodu
self.assertRaises(RuntimeError, env.step, 0)
self.assertIsInstance(env.reset(), Decision)
def test_reset_samples_round_and_seat(self):
env = RoundEnv(Random(7))
seen_rounds, seen_seats = set(), set()
for _ in range(100):
env.reset()
seen_rounds.add(env.round.round_number)
seen_seats.add(env.round.first_player)
self.assertEqual(seen_rounds, set(range(8)))
self.assertEqual(seen_seats, set(range(4)))
def test_deterministic_with_seed(self):
rewards = []
for _ in range(2):
env = RoundEnv(Random(123))
players = [RandomPlayer(Random(5)) for _ in range(4)]
rewards.append(play_round(players, env, round_number=0))
self.assertEqual(rewards[0], rewards[1])
class SimulationHelpersCase(unittest.TestCase):
def test_beats(self):
heart_7 = Card(Card_colors['HEARTS'], Card_values['C7'])
heart_8 = Card(Card_colors['HEARTS'], Card_values['C8'])
leaves_ace = Card(Card_colors['LEAVES'], Card_values['ACE'])
leaves_king = Card(Card_colors['LEAVES'], Card_values['KING'])
bells_ace = Card(Card_colors['BELLS'], Card_values['ACE'])
self.assertTrue(_beats(leaves_ace, leaves_king)) # vyssia vo farbe
self.assertFalse(_beats(leaves_king, leaves_ace))
self.assertTrue(_beats(heart_7, leaves_ace)) # tromf bije farbu
self.assertFalse(_beats(bells_ace, leaves_king)) # cudzia farba neberie
self.assertTrue(_beats(heart_8, heart_7)) # tromfy medzi sebou
self.assertFalse(_beats(leaves_ace, heart_7)) # farba nebije tromf
def test_current_best_tracks_stash(self):
from bridzik import Stash
leaves_7 = Card(Card_colors['LEAVES'], Card_values['C7'])
leaves_ace = Card(Card_colors['LEAVES'], Card_values['ACE'])
heart_7 = Card(Card_colors['HEARTS'], Card_values['C7'])
self.assertIsNone(_current_best(None))
s = Stash(0)
self.assertIsNone(_current_best(s))
s.add_card(0, leaves_7)
self.assertEqual(_current_best(s), leaves_7)
s.add_card(1, leaves_ace)
self.assertEqual(_current_best(s), leaves_ace)
s.add_card(2, heart_7)
self.assertEqual(_current_best(s), heart_7)
def test_simulate_tricks_consumes_hands(self):
rng = Random(3)
deck = list(cards)
rng.shuffle(deck)
hands = {seat: deck[seat * 8:(seat + 1) * 8] for seat in range(4)}
tricks = simulate_tricks(hands, leader=2, rng=rng)
self.assertEqual(sum(tricks), 8)
for seat in range(4):
self.assertEqual(hands[seat], [])
class HeuristicPlayerCase(unittest.TestCase):
@staticmethod
def _round_with_hand(player0_hand):
# deterministicke rozdanie: player0_hand ide hracovi 0, zvysok dalej;
# deal_starting_cards najprv zahodi 4*round_number kariet, preto
# treba ruku umiestnit az ZA odkladaciu kopu
round_number = 8 - len(player0_hand)
rest = [c for c in cards if c not in player0_hand]
skip = 4 * round_number
deck = rest[:skip] + list(player0_hand) + rest[skip:]
return Round(round_number, 0, deck, shuffler=lambda l: None)
def test_mc_guess_all_hearts_is_certain(self):
# 8 cerveni = tromfy beru kazdu kopku bez ohladu na rozdanie a hru
all_hearts = [Card(Card_colors['HEARTS'], v) for v in Card_values]
r = self._round_with_hand(all_hearts)
counts = mc_guess_distribution(r, 0, n_samples=30, rng=Random(1))
self.assertEqual(counts, {8: 30})
self.assertEqual(HeuristicPlayer(Random(1), n_samples=30).guess(r, 0), 8)
def test_mc_guess_weak_hand_low(self):
# dve najnizsie necervene karty -> tip 0 s prehladom
weak = [Card(Card_colors['LEAVES'], Card_values['C7']),
Card(Card_colors['BELLS'], Card_values['C7'])]
r = self._round_with_hand(weak)
self.assertEqual(HeuristicPlayer(Random(2), n_samples=60).guess(r, 0), 0)
def test_mc_guess_respects_mask(self):
# posledny tipujuci: zakazana hodnota nesmie byt vratena, ani ked
# je modom rozdelenia
all_hearts = [Card(Card_colors['HEARTS'], v) for v in Card_values]
r = self._round_with_hand(all_hearts)
r.add_player_guess(0, 0)
r.add_player_guess(1, 0)
r.add_player_guess(2, 0)
# zakazany tip pre hraca 3 je 8; jeho ruka je nahodna, ale nech by
# simulacia vratila cokolvek, vysledok musi byt legalny
guess = HeuristicPlayer(Random(3), n_samples=20).guess(r, 3)
self.assertNotEqual(guess, 8)
self.assertIn(guess, range(8))
def test_play_takes_trick_when_needed(self):
hand = [Card(Card_colors['LEAVES'], Card_values['ACE']),
Card(Card_colors['LEAVES'], Card_values['C7']),
Card(Card_colors['BELLS'], Card_values['C7'])]
r = self._round_with_hand(hand)
r.add_player_guess(0, 3) # najvyssi tip -> hrac 0 vynasa
r.add_player_guess(1, 0)
r.add_player_guess(2, 0)
r.add_player_guess(3, 1)
# hrac 0 potrebuje kopky -> vynasa najsilnejsiu kartu (LEAVES ACE)
action = HeuristicPlayer(Random(4)).play(r, 0)
self.assertEqual(index_card(action), hand[0])
def test_play_ducks_when_satisfied(self):
hand = [Card(Card_colors['LEAVES'], Card_values['ACE']),
Card(Card_colors['LEAVES'], Card_values['C7']),
Card(Card_colors['BELLS'], Card_values['C7'])]
r = self._round_with_hand(hand)
r.add_player_guess(0, 0) # hrac 0 nechce ziadnu kopku
r.add_player_guess(1, 2) # najvyssi tip -> vynasa hrac 1
r.add_player_guess(2, 0)
r.add_player_guess(3, 0)
first_card = legal_cards(r.player_cards[1], None)[0]
r.play_card(1, first_card)
action = HeuristicPlayer(Random(5)).play(r, 2)
# legalnost staci overit enginom; strategiu netestujeme natvrdo,
# lebo zavisi od nahodnej ruky hraca 2
r.play_card(2, index_card(action))
def test_play_duck_scenario_deterministic(self):
# hrac 0 tipol 0, ma na ruke LEAVES ACE aj C7; kopku vedie LEAVES C8
# -> musi priznat farbu a spravne je podliezt (C7), nie zobrat esom
hand0 = [Card(Card_colors['LEAVES'], Card_values['ACE']),
Card(Card_colors['LEAVES'], Card_values['C7'])]
hand1 = [Card(Card_colors['LEAVES'], Card_values['C8']),
Card(Card_colors['LEAVES'], Card_values['C9'])]
rest = [c for c in cards if c not in hand0 + hand1]
deck = rest[:24] + hand0 + hand1 + rest[24:] # 24 = odkladacia kopa
r = Round(6, 0, deck, shuffler=lambda l: None)
r.add_player_guess(0, 0)
r.add_player_guess(1, 2) # vynasa hrac 1
r.add_player_guess(2, 0)
r.add_player_guess(3, 1) # 0+2+0+0 by bol zakazany sucet (2 kopky)
r.play_card(1, hand1[0])
action = HeuristicPlayer(Random(6)).play(r, 0)
self.assertEqual(index_card(action), hand0[1])
class VoidDeductionCase(unittest.TestCase):
def test_deduce_voids_from_stash(self):
# hrac 0 vynasa zelen; 1 prizna farbu (nic), 2 tromfne cervenou
# (void zelen), 3 hodi gulu (void zelen AJ cerven)
hand0 = [Card(Card_colors['LEAVES'], Card_values['C7']),
Card(Card_colors['LEAVES'], Card_values['C8'])]
hand1 = [Card(Card_colors['LEAVES'], Card_values['C9']),
Card(Card_colors['LEAVES'], Card_values['C10'])]
hand2 = [Card(Card_colors['HEARTS'], Card_values['C7']),
Card(Card_colors['ACORNS'], Card_values['C7'])]
hand3 = [Card(Card_colors['BELLS'], Card_values['C7']),
Card(Card_colors['BELLS'], Card_values['C8'])]
rest = [c for c in cards if c not in hand0 + hand1 + hand2 + hand3]
deck = rest[:24] + hand0 + hand1 + hand2 + hand3
r = Round(6, 0, deck, shuffler=lambda l: None)
r.add_player_guess(0, 2) # najvyssi tip -> vynasa 0
r.add_player_guess(1, 0)
r.add_player_guess(2, 0)
r.add_player_guess(3, 1)
self.assertEqual(deduce_voids(r), {0: set(), 1: set(), 2: set(), 3: set()})
r.play_card(0, hand0[0])
r.play_card(1, hand1[0]) # priznal farbu -> nic
r.play_card(2, hand2[0]) # cerven -> void zelen
r.play_card(3, hand3[0]) # gula -> void zelen aj cerven
voids = deduce_voids(r)
self.assertEqual(voids[0], set()) # vynasajuci neprezradza nic
self.assertEqual(voids[1], set())
self.assertEqual(voids[2], {Card_colors['LEAVES']})
self.assertEqual(voids[3], {Card_colors['LEAVES'], Card_colors['HEARTS']})
def test_deduced_voids_never_contradict_hands(self):
# fuzz: dedukovany void NIKDY neprotireci realnej ruke hraca
rng = Random(21)
for _ in range(30):
r = Round(rng.randrange(4), rng.randrange(4))
players = [RandomPlayer(Random(rng.random())) for _ in range(4)]
for _ in range(4):
seat = r.get_active_player()
r.add_player_guess(seat, players[seat].guess(r, seat))
while not r.is_completed():
seat = r.get_active_player()
r.play_card(seat, index_card(players[seat].play(r, seat)))
for other, banned in deduce_voids(r).items():
held = {c.color for c in r.player_cards[other]}
self.assertFalse(held & banned,
f'void {banned} vs ruka {held}')
def test_deal_consistent_respects_voids(self):
rng = Random(22)
unknown = [c for c in cards][:20]
voids = {1: {Card_colors['HEARTS']}, 2: set(),
3: {Card_colors['LEAVES'], Card_colors['BELLS']}}
for _ in range(20):
hands = deal_consistent(unknown, {1: 4, 2: 4, 3: 4}, voids, rng)
self.assertTrue(all(len(h) == 4 for h in hands.values()))
for seat, banned in voids.items():
self.assertFalse({c.color for c in hands[seat]} & banned)
class McPlayerCase(unittest.TestCase):
def test_plays_legal_full_rounds(self):
rng = Random(23)
env = RoundEnv(rng)
players = [McPlayer(Random(24), n_samples=20, play_samples=8),
McPlayer(Random(25), n_samples=20, play_samples=8,
use_voids=False)] \
+ [RandomPlayer(Random(s)) for s in (26, 27)]
for round_number in range(8):
rewards = play_round(players, env, round_number)
self.assertEqual(len(rewards), 4)
def test_duck_scenario(self):
# tip 0, kopku vedie sused LEAVES C8 a ja som HNED na tahu (MC hrac
# stavia kopku poctivo, takze na rozdiel od pravidlovej heuristiky
# vyzaduje konzistentne poradie): mam ACE aj C7 -> podlezt sedmickou
hand0 = [Card(Card_colors['LEAVES'], Card_values['ACE']),
Card(Card_colors['LEAVES'], Card_values['C7'])]
hand3 = [Card(Card_colors['LEAVES'], Card_values['C8']),
Card(Card_colors['LEAVES'], Card_values['C9'])]
rest = [c for c in cards if c not in hand0 + hand3]
deck = rest[:24] + hand0 + rest[24:26] + rest[26:28] + hand3
r = Round(6, 0, deck, shuffler=lambda l: None)
r.add_player_guess(0, 0)
r.add_player_guess(1, 0)
r.add_player_guess(2, 0)
r.add_player_guess(3, 1) # najvyssi tip -> vynasa hrac 3, po nom ja
r.play_card(3, hand3[0])
self.assertEqual(r.get_active_player(), 0)
action = McPlayer(Random(28), play_samples=30).play(r, 0)
self.assertEqual(index_card(action), hand0[1])
class EvaluateCase(unittest.TestCase):
def test_full_random_matchup_runs(self):
rng = Random(11)
stats = evaluate([RandomPlayer(rng) for _ in range(4)], 40, rng)
for s in stats:
self.assertEqual(s['rounds'], 40)
self.assertGreaterEqual(s['avg_points'], 0)
self.assertLessEqual(s['hit_rate'], 1)
def test_heuristic_beats_random(self):
rng = Random(13)
players = [HeuristicPlayer(rng, n_samples=40)] \
+ [RandomPlayer(rng) for _ in range(3)]
stats = evaluate(players, 120, rng)
heuristic, randoms = stats[0], stats[1:]
best_random = max(s['avg_points'] for s in randoms)
self.assertGreater(heuristic['avg_points'], best_random)
self.assertGreater(heuristic['hit_rate'],
max(s['hit_rate'] for s in randoms))
if __name__ == '__main__':
unittest.main(verbosity=2)
+200
View File
@@ -0,0 +1,200 @@
"""Testy PPO pipeline (rl/model, rl/selfplay, rl/policy_player, rl/train).
Vyzaduju torch (requirements-rl.txt); bez neho sa cely modul preskoci --
ostatne suity (engine, encoding, boti) na torchi nezavisia.
"""
import unittest
from random import Random
try:
import torch
except ImportError: # pragma: no cover
raise unittest.SkipTest('torch nie je nainstalovany (requirements-rl.txt)')
from bridzik import Round
from rl.encoding import (
N_GUESS_ACTIONS, N_PLAY_ACTIONS, OBS_DIM,
encode_observation, guess_mask, play_mask,
)
from rl.evaluate import evaluate, play_round
from rl.env import RoundEnv
from rl.model import BridzikNet, mask_tensor, masked_categorical, obs_tensor
from rl.players import RandomPlayer
from rl.policy_player import NeuralPlayer
from rl.selfplay import _assign_seats, collect_episodes
from rl.train import ppo_update
class ModelCase(unittest.TestCase):
def test_output_shapes(self):
net = BridzikNet(hidden=32)
obs = torch.zeros((5, OBS_DIM))
guess_logits, play_logits, value = net(obs)
self.assertEqual(guess_logits.shape, (5, N_GUESS_ACTIONS))
self.assertEqual(play_logits.shape, (5, N_PLAY_ACTIONS))
self.assertEqual(value.shape, (5,))
def test_masked_categorical_never_samples_illegal(self):
torch.manual_seed(0)
logits = torch.zeros((1, 9))
mask = torch.tensor([[False, True, False, True, False,
False, False, False, False]])
dist = masked_categorical(logits, mask)
samples = dist.sample((200,))
self.assertTrue(set(samples.flatten().tolist()) <= {1, 3})
# entropia a log_prob su konecne aj s -inf logitmi
self.assertTrue(torch.isfinite(dist.entropy()).all())
self.assertTrue(torch.isfinite(dist.log_prob(torch.tensor([1]))).all())
def test_encoding_tensors(self):
r = Round(3, 0)
obs = obs_tensor(encode_observation(r, 0))
self.assertEqual(obs.shape, (OBS_DIM,))
self.assertEqual(mask_tensor(guess_mask(r)).shape, (N_GUESS_ACTIONS,))
self.assertEqual(mask_tensor(play_mask(r, 0)).shape, (N_PLAY_ACTIONS,))
class NeuralPlayerCase(unittest.TestCase):
def test_untrained_net_plays_legal_full_rounds(self):
torch.manual_seed(1)
net = BridzikNet(hidden=32)
env = RoundEnv(Random(2))
players = [NeuralPlayer(net, greedy=True),
NeuralPlayer(net, greedy=False),
RandomPlayer(Random(3)), RandomPlayer(Random(4))]
# dohratie kola bez BridzikException = vsetky tahy legalne
for round_number in range(8):
rewards = play_round(players, env, round_number)
self.assertEqual(len(rewards), 4)
class SelfPlayCase(unittest.TestCase):
def test_collect_episodes_batch_consistency(self):
torch.manual_seed(5)
net = BridzikNet(hidden=32)
batch = collect_episodes(net, n_episodes=6, rng=Random(6))
n = batch['obs'].shape[0]
self.assertGreater(n, 0)
for key, width in (('guess_mask', N_GUESS_ACTIONS),
('play_mask', N_PLAY_ACTIONS)):
self.assertEqual(batch[key].shape, (n, width))
for key in ('phase_play', 'action', 'logp', 'value', 'ret'):
self.assertEqual(batch[key].shape, (n,))
# kazda epizoda ma prave 4 guess kroky -> pocet guess krokov = 4*epizody
self.assertEqual(int((~batch['phase_play']).sum()), 4 * 6)
# return je bud 0 alebo (10+tip)/REWARD_SCALE, cize v (0.55, 1.0]
for r in batch['ret'].tolist():
self.assertTrue(r == 0.0 or 10.0 / 18.0 <= r <= 1.0)
# akcia bola vzdy legalna podla ulozenej masky svojej fazy
for i in range(n):
mask = batch['play_mask'][i] if batch['phase_play'][i] \
else batch['guess_mask'][i]
self.assertTrue(bool(mask[batch['action'][i]]))
self.assertGreaterEqual(batch['mean_points'], 0.0)
class OpponentMixingCase(unittest.TestCase):
def test_assign_seats_always_keeps_a_net_seat(self):
rng = Random(20)
marker = object()
for _ in range(200):
seats = _assign_seats(rng, 1.0, 0.0, marker, marker)
self.assertIn(None, seats.values()) # aj pri mix_random=1.0
self.assertEqual(set(seats), {0, 1, 2, 3})
def test_mixed_episodes_record_only_net_seats(self):
torch.manual_seed(21)
net = BridzikNet(hidden=32)
# mix_random=1.0 -> presne jedno sietove sedadlo na epizodu
batch = collect_episodes(net, n_episodes=5, rng=Random(22),
mix_random=1.0)
# 1 sietove sedadlo = presne 1 guess krok na epizodu
self.assertEqual(int((~batch['phase_play']).sum()), 5)
for i in range(batch['obs'].shape[0]):
mask = batch['play_mask'][i] if batch['phase_play'][i] \
else batch['guess_mask'][i]
self.assertTrue(bool(mask[batch['action'][i]]))
for r in batch['ret'].tolist():
self.assertTrue(r == 0.0 or 10.0 / 18.0 <= r <= 1.0)
def test_mixed_episodes_with_heuristic(self):
torch.manual_seed(23)
net = BridzikNet(hidden=32)
batch = collect_episodes(net, n_episodes=4, rng=Random(24),
mix_heuristic=0.5, heuristic_samples=10)
n_guess = int((~batch['phase_play']).sum())
self.assertGreaterEqual(n_guess, 4) # aspon 1 sietove sedadlo/epizodu
self.assertLessEqual(n_guess, 16)
self.assertGreaterEqual(batch['mean_points'], 0.0)
class PpoUpdateCase(unittest.TestCase):
def test_update_changes_params_and_is_finite(self):
torch.manual_seed(7)
net = BridzikNet(hidden=32)
optimizer = torch.optim.Adam(net.parameters(), lr=1e-3)
batch = collect_episodes(net, n_episodes=8, rng=Random(8))
before = [p.detach().clone() for p in net.parameters()]
stats = ppo_update(net, optimizer, batch, epochs=2, minibatch=64)
for key in ('policy_loss', 'value_loss', 'entropy'):
self.assertTrue(torch.isfinite(torch.tensor(stats[key])))
changed = any(
not torch.equal(b, a.detach())
for b, a in zip(before, net.parameters())
)
self.assertTrue(changed)
def test_value_head_learns_constant_reward(self):
# sanity uciaceho kroku: na batchi s konstantnym returnom sa value
# loss po par updatoch zmensi
torch.manual_seed(9)
net = BridzikNet(hidden=32)
optimizer = torch.optim.Adam(net.parameters(), lr=3e-3)
batch = collect_episodes(net, n_episodes=8, rng=Random(10))
batch['ret'] = torch.full_like(batch['ret'], 12.0 / 18.0)
first = ppo_update(net, optimizer, batch, epochs=1, minibatch=4096)
for _ in range(10):
last = ppo_update(net, optimizer, batch, epochs=1, minibatch=4096)
self.assertLess(last['value_loss'], first['value_loss'])
class CheckpointCase(unittest.TestCase):
def test_save_load_roundtrip_with_hidden(self):
import os
import tempfile
from rl.train import load_checkpoint, save_checkpoint
torch.manual_seed(13)
net = BridzikNet(hidden=48)
path = os.path.join(tempfile.gettempdir(), 'bridzik_ckpt_test.pt')
try:
save_checkpoint(net, 48, path)
loaded = load_checkpoint(path)
self.assertEqual(loaded.trunk[0].out_features, 48)
obs = torch.zeros((1, OBS_DIM))
for a, b in zip(net(obs), loaded(obs)):
self.assertTrue(torch.equal(a, b))
finally:
os.remove(path)
class EvaluateIntegrationCase(unittest.TestCase):
def test_neural_player_in_harness(self):
torch.manual_seed(11)
net = BridzikNet(hidden=32)
rng = Random(12)
stats = evaluate(
[NeuralPlayer(net)] + [RandomPlayer(rng) for _ in range(3)],
n_rounds=20, rng=rng,
)
self.assertEqual(stats[0]['rounds'], 20)
if __name__ == '__main__':
unittest.main(verbosity=2)
+234
View File
@@ -0,0 +1,234 @@
import unittest
import api
class SocketLayerCase(unittest.IsolatedAsyncioTestCase):
"""Drives the socket handlers directly with a fake emit, asserting the
identity-binding, privacy, lifecycle and error-handling guarantees."""
def setUp(self):
api.games.clear()
api.sessions.clear()
self.emits = []
async def fake_emit(event, data=None, room=None, to=None, **kw):
self.emits.append({"event": event, "data": data, "room": room, "to": to})
async def noop(*a, **kw):
pass
# Patch the network-facing primitives; logic under test is untouched.
api.sio.emit = fake_emit
api.sio.enter_room = noop
api.sio.leave_room = noop
# --- helpers ----------------------------------------------------------
def last(self, event):
return next(e for e in reversed(self.emits) if e["event"] == event)
def events(self, event):
return [e for e in self.emits if e["event"] == event]
async def make_game(self):
await api.create_game("host", "Game")
return list(api.games.keys())[0]
async def make_started_game(self):
gid = await self.make_game()
for i in range(4):
await api.register_player(f"s{i}", gid, f"P{i}")
await api.start_game("s0", gid)
return gid
# --- creation & lobby -------------------------------------------------
async def test_create_game_mints_uuid(self):
gid = await self.make_game()
self.assertNotEqual(gid, "a")
self.assertEqual(len(gid), 36)
self.assertEqual(self.last("create_game")["data"]["gid"], gid)
async def test_lobby_payload_hides_sids_and_tokens(self):
gid = await self.make_game()
await api.register_player("s0", gid, "P0")
lobby = self.last("get_games")["data"]["games"]
blob = repr(lobby)
self.assertNotIn("sid", blob)
self.assertNotIn("token", blob)
self.assertEqual(lobby[0]["players"][0], {"order": 0, "name": "P0", "connected": True})
async def test_register_returns_private_token_to_caller_only(self):
gid = await self.make_game()
await api.register_player("s0", gid, "P0")
reg = self.last("register_player")
self.assertEqual(reg["to"], "s0")
self.assertIn("token", reg["data"])
self.assertEqual(reg["data"]["player"], {"order": 0, "name": "P0"})
async def test_register_rejects_unknown_game_fifth_and_started(self):
await api.register_player("x", "nope", "P") # unknown gid
self.assertEqual(self.last("error")["data"]["error"], "Hra neexistuje.")
gid = await self.make_game()
for i in range(4):
await api.register_player(f"s{i}", gid, f"P{i}")
await api.register_player("s4", gid, "P4") # fifth
self.assertEqual(self.last("error")["data"]["error"], "Prekroceny pocet hracov.")
await api.start_game("s0", gid)
await api.register_player("s5", gid, "late") # already started
self.assertEqual(self.last("error")["data"]["error"], "Hra uz zacala.")
# --- start lifecycle --------------------------------------------------
async def test_start_deals_and_emits_private_hands(self):
gid = await self.make_started_game()
self.assertTrue(api.games[gid].started)
self.assertEqual(self.last("game_status")["room"], gid)
hands = self.events("player_cards")
self.assertEqual(sorted(h["to"] for h in hands), ["s0", "s1", "s2", "s3"])
async def test_start_game_twice_is_rejected_and_does_not_reset(self):
gid = await self.make_started_game()
core_before = api.games[gid].bridzik_core
await api.start_game("s0", gid)
self.assertEqual(self.last("error")["data"]["error"], "Hra uz zacala.")
self.assertIs(api.games[gid].bridzik_core, core_before)
# --- identity binding (the anti-cheat guarantee) ----------------------
async def test_player_cards_serves_only_your_own_seat(self):
gid = await self.make_started_game()
# The handler takes no player argument: a connection can only ever
# request the hand bound to its own session.
self.emits.clear()
await api.player_cards("s2")
sent = self.last("player_cards")
self.assertEqual(sent["to"], "s2")
expected = api.games[gid].bridzik_core.get_player_cards(2)
self.assertEqual(len(sent["data"]["cards"]), len(expected))
async def test_action_without_session_is_rejected(self):
await self.make_started_game()
await api.add_guess("stranger", 1)
self.assertEqual(self.last("error")["data"]["error"], "Nie ste v rozohratej hre.")
# --- full play flow ---------------------------------------------------
async def test_guess_and_play_flow(self):
gid = await self.make_started_game()
core = api.games[gid].bridzik_core
# Round 0 leads with seat 0; guess 0 all round (sum 0 != 8 tricks).
for i in range(4):
await api.add_guess(f"s{i}", 0)
rnd = core.series[-1].get_last_round()
self.assertTrue(rnd.is_guessing_completed())
leader = rnd.get_active_player()
before = len(core.get_player_cards(leader))
self.emits.clear()
await api.play_card(f"s{leader}", 0)
after = len(core.get_player_cards(leader))
self.assertEqual(after, before - 1)
status = self.last("game_status")["data"]
self.assertIn("completed", status)
self.assertIn("active_stash", status["status"])
async def test_bad_input_does_not_crash(self):
await self.make_started_game()
await api.add_guess("s0", "not-a-number")
self.assertEqual(self.last("error")["data"]["error"], "Neplatny tip.")
await api.play_card("s0", "xx")
self.assertEqual(self.last("error")["data"]["error"], "Neplatna karta.")
async def test_engine_error_message_is_forwarded(self):
gid = await self.make_started_game()
# Seat 0 leads guessing; seat 1 acting out of turn -> engine rejects,
# and the real (Slovak) reason is forwarded, not a generic message.
await api.add_guess("s1", 1)
self.assertEqual(self.last("error")["data"]["error"], "Hrac nie je na tahu")
# --- reconnect --------------------------------------------------------
async def test_secure_reconnect_with_token_rebinds_seat(self):
gid = await self.make_game()
await api.register_player("s0", gid, "P0")
token = self.last("register_player")["data"]["token"]
for i in range(1, 4):
await api.register_player(f"s{i}", gid, f"P{i}")
await api.start_game("s0", gid)
await api.reconnect_to_game("s0-new", gid, token)
self.assertEqual(api.games[gid].player_by_token(token).sid, "s0-new")
self.assertEqual(api.sessions["s0-new"], {"gid": gid, "order": 0})
self.emits.clear()
await api.reconnect_to_game("zzz", gid, "wrong-token")
self.assertEqual(self.last("error")["data"]["error"], "Neplatny token pre pripojenie.")
# --- self-contained game_status --------------------------------------
async def test_game_status_carries_roster_and_round_meta(self):
gid = await self.make_started_game()
self.emits.clear()
await api.game_status("s0")
data = self.last("game_status")["data"]
self.assertEqual(data["round_number"], 0)
self.assertEqual(data["cards_in_round"], 8)
self.assertEqual(data["series_number"], 0)
roster = data["players"]
self.assertEqual([p["order"] for p in roster], [0, 1, 2, 3])
self.assertEqual([p["name"] for p in roster], ["P0", "P1", "P2", "P3"])
self.assertTrue(all(p["connected"] for p in roster))
# --- leave_game -------------------------------------------------------
async def test_leave_before_start_frees_the_seat(self):
gid = await self.make_game()
await api.register_player("s0", gid, "P0")
await api.register_player("s1", gid, "P1")
await api.leave_game("s0") # order 0 leaves
self.assertNotIn("s0", api.sessions)
self.assertEqual([p.order for p in api.games[gid].players], [1])
# the freed seat 0 is reused by the next joiner
await api.register_player("s2", gid, "P2")
self.assertEqual(api.sessions["s2"]["order"], 0)
async def test_leave_last_lobby_player_drops_game(self):
gid = await self.make_game()
await api.register_player("s0", gid, "P0")
await api.leave_game("s0")
self.assertNotIn(gid, api.games)
async def test_leave_started_game_keeps_seat_marks_offline(self):
gid = await self.make_started_game()
await api.leave_game("s1")
self.assertNotIn("s1", api.sessions)
player = api.games[gid].player_by_order(1)
self.assertIsNotNone(player) # seat kept for reconnect
self.assertFalse(player.connected)
self.assertIn(gid, api.games) # others still in
async def test_leave_without_session_is_a_noop(self):
await api.leave_game("ghost")
# no exception, and no error emitted to a non-participant
self.assertEqual(self.events("error"), [])
# --- disconnect & cleanup --------------------------------------------
async def test_disconnect_marks_player_then_drops_empty_game(self):
gid = await self.make_started_game()
await api.disconnect("s0")
self.assertFalse(api.games[gid].player_by_sid("s0").connected)
self.assertNotIn("s0", api.sessions)
self.assertIn(gid, api.games) # others still connected
for i in range(1, 4):
await api.disconnect(f"s{i}")
self.assertNotIn(gid, api.games) # everyone gone -> game removed
if __name__ == "__main__":
unittest.main()
+391
View File
@@ -0,0 +1,391 @@
"""Testy self-hosted usage analytics (api/stats.py).
Bezia na docasnom SQLite subore, rovnaky pattern ako tests/test_history.py.
"""
import asyncio
import os
import tempfile
import unittest
import uuid
from types import SimpleNamespace
# Nastav DB/ENCRYPTION_KEY PRED importom db/api modulov -- engine sa vytvara pri importe.
_DB_FILE = os.path.join(tempfile.gettempdir(), f"bridzik_test_{uuid.uuid4().hex}.db")
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///" + _DB_FILE.replace("\\", "/")
from cryptography.fernet import Fernet # noqa: E402
os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode())
import pyotp # noqa: E402
import api as api_module # noqa: E402
from api import auth, history, stats # noqa: E402
from db.db import init_db # noqa: E402
CHROME_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36"
)
def run(coro):
return asyncio.run(coro)
def make_core(completed=True):
"""Stub jednej hry s jednym dohratym kolom (seria 0, kolo 0)."""
rnd = SimpleNamespace(
round_number=0,
guesses={0: 2, 1: 1, 2: 0, 3: 1},
is_completed=lambda: True,
get_points_summary=lambda: [12, 0, 10, 11],
)
series = SimpleNamespace(
series_number=0, rounds=[rnd], get_last_round=lambda: rnd
)
return SimpleNamespace(series=[series], is_completed=lambda: completed)
class StatsCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
run(init_db())
def _make_players(self, n=4):
ids = []
for _ in range(n):
username = "u_" + uuid.uuid4().hex[:8]
data = run(auth.register_account(username))
ident = run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
ids.append(ident["player_id"])
return ids
def test_record_pageview_parses_user_agent(self):
run(stats.record_pageview(
path="/history", referrer="https://example.com", user_agent=CHROME_UA, ip="203.0.113.5",
))
data = run(stats.get_daily_stats())
self.assertGreaterEqual(sum(data["pageviews_per_day"].values()), 1)
self.assertIn("Chrome", data["browsers"])
self.assertIn("Windows", data["operating_systems"])
self.assertGreaterEqual(data["device_types"].get("pc", 0), 1)
self.assertGreaterEqual(data["top_referrers"].get("https://example.com", 0), 1)
self.assertGreaterEqual(data["top_paths"].get("/history", 0), 1)
# Bez GEOIP_DB_PATH (v testoch nenastaveny) sa krajina jednoducho nerozlusi,
# ale IP sa uz do page_views ulozi.
async def _last_ip():
from sqlalchemy import select as sa_select
from db.db import async_session
from db.models import PageView
async with async_session() as session:
row = (
await session.execute(
sa_select(PageView).order_by(PageView.id.desc()).limit(1)
)
).scalar_one()
return row.ip, row.country
ip, country = run(_last_ip())
self.assertEqual(ip, "203.0.113.5")
self.assertEqual(country, "")
def test_country_for_ip_without_geoip_db_path_returns_empty(self):
os.environ.pop("GEOIP_DB_PATH", None)
stats._geoip_reader = None
stats._geoip_load_attempted = False
self.assertEqual(stats._country_for_ip("203.0.113.5"), "")
self.assertEqual(stats._country_for_ip(""), "")
def test_dynamic_path_segments_normalized(self):
self.assertEqual(stats._normalize_path("/lobby/abc-123"), "/lobby")
self.assertEqual(stats._normalize_path("/game/abc-123"), "/game")
self.assertEqual(stats._normalize_path("/lobby"), "/lobby")
self.assertEqual(stats._normalize_path("/history"), "/history")
# /gameXYZ nie je /game/<id> -- nesmie sa orezat
self.assertEqual(stats._normalize_path("/gamex"), "/gamex")
def test_skipped_paths_not_recorded(self):
# "/", "/lobby" a "/game" (aj s dynamickym ID) sa vobec nezapisuju --
# vysoka frekvencia bez analytickej hodnoty; "/auth" zas preto, ze je
# to vzdy 1:1 duplicita eventu "landing" (api/stats.py _SKIPPED_PATHS).
async def _count():
from sqlalchemy import func, select as sa_select
from db.db import async_session
from db.models import PageView
async with async_session() as session:
return (
await session.execute(sa_select(func.count()).select_from(PageView))
).scalar()
before = run(_count())
for path in ("/", "/auth", "/lobby", "/lobby/abc-123", "/game", "/game/xyz-789"):
run(stats.record_pageview(path=path, referrer="", user_agent=CHROME_UA))
after = run(_count())
self.assertEqual(after, before)
def test_event_path_stores_player_id(self):
# Konvencia: nazov bez "/" na zaciatku = pomenovany event (napr. "login"
# zapisovany zo socket handlera po uspesnom prihlaseni), nie URL cesta.
ids = self._make_players(n=1)
run(stats.record_pageview(
path="login", referrer="", user_agent=CHROME_UA, player_id=ids[0],
))
async def _last():
from sqlalchemy import select as sa_select
from db.db import async_session
from db.models import PageView
async with async_session() as session:
row = (
await session.execute(
sa_select(PageView).order_by(PageView.id.desc()).limit(1)
)
).scalar_one()
return row.path, row.player_id
path, player_id = run(_last())
self.assertEqual(path, "login")
self.assertEqual(player_id, ids[0])
data = run(stats.get_daily_stats())
self.assertGreaterEqual(data["top_paths"].get("login", 0), 1)
def test_landing_event_preserves_referrer(self):
# "landing" event (main.tsx, jeden na kazdy plny load stranky) nesie
# referrer prveho dotyku -- jediny zaznam z navstevy, ktora zacina na
# "/" (preklik z FB a pod.), kedze "/" aj REPLACE redirecty sa skipuju.
run(stats.record_pageview(
path="landing",
referrer="https://facebook.com/",
user_agent=CHROME_UA,
ip="203.0.113.99",
))
data = run(stats.get_daily_stats())
self.assertGreaterEqual(data["top_paths"].get("landing", 0), 1)
self.assertGreaterEqual(data["top_referrers"].get("https://facebook.com/", 0), 1)
def test_rules_view_event_recorded_without_player_id(self):
run(stats.record_pageview(path="rules_view", referrer="", user_agent=CHROME_UA))
data = run(stats.get_daily_stats())
self.assertGreaterEqual(data["top_paths"].get("rules_view", 0), 1)
def test_logged_in_only_scope_counts_login_events_only(self):
# Bezne beacony uz neposielaju player_id vobec -- scope "logged_in"
# preto filtruje priamo podla path == "login", nie podla pritomnosti
# player_id. Obycajna navsteva (aj s player_id) sa do neho nepocita.
ids = self._make_players(n=1)
ip = "203.0.113.99"
before_all = run(stats.get_daily_stats(logged_in_only=False))
before_logged_in = run(stats.get_daily_stats(logged_in_only=True))
all_before = sum(before_all["pageviews_per_day"].values())
logged_in_before = sum(before_logged_in["pageviews_per_day"].values())
# Bezna navsteva -- ma pribudnut len vo "vsetci", aj keby mala player_id.
run(stats.record_pageview(
path="/history", referrer="", user_agent=CHROME_UA, ip=ip, player_id=ids[0],
))
# Login event -- ma pribudnut v oboch.
run(stats.record_pageview(
path="login", referrer="", user_agent=CHROME_UA, ip=ip, player_id=ids[0],
))
after_all = run(stats.get_daily_stats(logged_in_only=False))
after_logged_in = run(stats.get_daily_stats(logged_in_only=True))
self.assertEqual(sum(after_all["pageviews_per_day"].values()), all_before + 2)
self.assertEqual(sum(after_logged_in["pageviews_per_day"].values()), logged_in_before + 1)
def test_logged_in_only_breakdown_tables_count_each_login_not_deduped(self):
# browsers/os/device_types/top_referrers/countries pouzivaju v scope
# "logged_in" priamy pocet login-eventov, nie unikatny navstevnicky
# den -- 2x prihlasenie tym istym prehliadacom/IP v ten isty den sa
# ma prejavit ako 2, rovnako ako v grafe pageviews_per_day.
ids = self._make_players(n=1)
before = run(stats.get_daily_stats(logged_in_only=True))
before_browsers = sum(before["browsers"].values())
run(stats.record_pageview(path="login", referrer="", user_agent=CHROME_UA, player_id=ids[0]))
run(stats.record_pageview(path="login", referrer="", user_agent=CHROME_UA, player_id=ids[0]))
data = run(stats.get_daily_stats(logged_in_only=True))
self.assertEqual(sum(data["browsers"].values()), before_browsers + 2)
def test_logged_in_visitors_per_day_counts_distinct_players_not_ip(self):
# V scope "logged_in" ma visitors_per_day znamenat unikatnych HRACOV
# (player_id) za den, nie unikatne IP+UA -- 2x prihlasenie toho
# isteho hraca (aj z inej IP/prehliadaca) sa ma pocitat len raz, na
# rozdiel od pageviews_per_day, kde sa kazdy login pocita zvlast.
ids = self._make_players(n=2)
before = run(stats.get_daily_stats(logged_in_only=True))
before_visitors = sum(before["visitors_per_day"].values())
before_pageviews = sum(before["pageviews_per_day"].values())
# Ten isty hrac, 2x prihlasenie z roznych "zariadeni" (rozne IP/UA).
run(stats.record_pageview(
path="login", referrer="", user_agent=CHROME_UA, ip="203.0.113.10", player_id=ids[0],
))
run(stats.record_pageview(
path="login", referrer="", user_agent=CHROME_UA, ip="203.0.113.11", player_id=ids[0],
))
# Iny hrac, 1x prihlasenie.
run(stats.record_pageview(
path="login", referrer="", user_agent=CHROME_UA, ip="203.0.113.12", player_id=ids[1],
))
data = run(stats.get_daily_stats(logged_in_only=True))
self.assertEqual(sum(data["pageviews_per_day"].values()), before_pageviews + 3)
self.assertEqual(sum(data["visitors_per_day"].values()), before_visitors + 2)
def test_visitors_counted_once_per_day_per_ip(self):
# 3 kliky z tej istej IP v ten isty den -> pageviews +3, visitors iba 1.
ip = "198.51.100.77"
for path in ("/history", "/history", "/history"):
run(stats.record_pageview(path=path, referrer="", user_agent=CHROME_UA, ip=ip))
data = run(stats.get_daily_stats())
today = next(iter(data["visitors_per_day"]))
# V testovej DB su vsetky zaznamy z dneska; unikatnych IP je menej nez klikov.
self.assertLess(data["visitors_per_day"][today], data["pageviews_per_day"][today])
# Tabulky zlozenia publika pocitaju den+IP raz -- 3 kliky tej istej IP
# nesmu zdvihnut "Chrome" o 3. Overime, ze pocet je mensi nez pocet klikov.
self.assertLess(data["browsers"].get("Chrome", 0), sum(data["pageviews_per_day"].values()))
def test_same_ip_different_user_agent_counts_as_two_visitors(self):
ip = "192.0.2.44"
firefox_ua = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) "
"Gecko/20100101 Firefox/126.0"
)
before = run(stats.get_daily_stats())
today_before = sum(before["visitors_per_day"].values())
# Ta ista IP, dva rozne prehliadace -> 2 navstevnici (kazdy klikne 2x).
for _ in range(2):
run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA, ip=ip))
run(stats.record_pageview(path="/history", referrer="", user_agent=firefox_ua, ip=ip))
after = run(stats.get_daily_stats())
self.assertEqual(sum(after["visitors_per_day"].values()), today_before + 2)
def test_pageviews_per_day_breakdown_by_dimension(self):
run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA))
data = run(stats.get_daily_stats())
today = next(iter(data["pageviews_per_day_by_device"]))
self.assertGreaterEqual(data["pageviews_per_day_by_device"][today].get("pc", 0), 1)
self.assertIn("Chrome", data["pageviews_per_day_by_browser"][today])
self.assertIn("Windows", data["pageviews_per_day_by_os"][today])
def test_all_scope_chart_breakdown_matches_composition_tables(self):
# Bug hlaseny uzivatelom: v scope "vsetci" graf (pageviews_per_day_by_*)
# pocital kazdy klik zvlast, zatial co tabulky nizsie (browsers/os/
# device_types) pocitali unikatny "navstevnicky den" -- cisla si tak
# nesedeli. Oboje ma teraz rovnaku dedup logiku (jeden navstevnik +
# kategoria + den = 1), takze sucty musia byt zhodne.
ip = "203.0.113.50"
for _ in range(3):
run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA, ip=ip))
data = run(stats.get_daily_stats(logged_in_only=False))
chart_pc_total = sum(day.get("pc", 0) for day in data["pageviews_per_day_by_device"].values())
chart_chrome_total = sum(
day.get("Chrome", 0) for day in data["pageviews_per_day_by_browser"].values()
)
self.assertEqual(chart_pc_total, data["device_types"].get("pc", 0))
self.assertEqual(chart_chrome_total, data["browsers"].get("Chrome", 0))
def test_daily_stats_reflect_games_and_players(self):
before = run(stats.get_daily_stats())
base_total_players = before["total_players"]
ids = self._make_players()
gid = str(uuid.uuid4())
run(history.record_game_started(gid, "Test", ids))
run(history.record_completed_rounds(gid, make_core()))
data = run(stats.get_daily_stats())
self.assertEqual(data["total_players"], base_total_players + 4)
self.assertEqual(data["completion_rate"], 1.0)
self.assertGreaterEqual(sum(data["games_per_day"].values()), 1)
self.assertGreaterEqual(sum(data["rounds_per_day"].values()), 4)
def test_unconfirmed_players_counted_separately(self):
before = run(stats.get_daily_stats())
# Registracia bez potvrdenia kodu -> nedokonceny ucet
username = "ghost_" + uuid.uuid4().hex[:8]
data = run(auth.register_account(username))
after = run(stats.get_daily_stats())
self.assertEqual(after["total_players"], before["total_players"])
self.assertEqual(after["unconfirmed_players"], before["unconfirmed_players"] + 1)
# Po potvrdeni sa presunie medzi potvrdenych hracov
run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
confirmed = run(stats.get_daily_stats())
self.assertEqual(confirmed["total_players"], before["total_players"] + 1)
self.assertEqual(confirmed["unconfirmed_players"], before["unconfirmed_players"])
def test_empty_referrer_excluded_from_top_referrers(self):
run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA))
data = run(stats.get_daily_stats())
self.assertNotIn("", data["top_referrers"])
def _scope(ip="1.2.3.4", token=None):
headers = [(b"x-forwarded-for", ip.encode())]
if token is not None:
headers.append((b"authorization", f"Bearer {token}".encode()))
return {"headers": headers, "client": ("9.9.9.9", 0)}
class AdminAuthCase(unittest.TestCase):
def setUp(self):
os.environ["ADMIN_TOKEN"] = "secret-token"
api_module._admin_failed_attempts.clear()
def tearDown(self):
os.environ.pop("ADMIN_TOKEN", None)
def test_client_ip_prefers_x_forwarded_for(self):
self.assertEqual(api_module._client_ip(_scope(ip="5.6.7.8")), "5.6.7.8")
self.assertEqual(api_module._client_ip({"headers": [], "client": ("9.9.9.9", 0)}), "9.9.9.9")
def test_wrong_token_rejected_and_locked_out_after_repeated_failures(self):
ip = "1.1.1.1"
self.assertFalse(api_module._admin_authorized(_scope(ip=ip, token="wrong")))
for _ in range(api_module._ADMIN_ATTEMPT_LIMIT):
self.assertFalse(api_module._admin_locked_out(ip))
api_module._register_admin_failure(ip)
self.assertTrue(api_module._admin_locked_out(ip))
# Iny IP nie je zamknuty
self.assertFalse(api_module._admin_locked_out("2.2.2.2"))
def test_correct_token_authorized(self):
self.assertTrue(api_module._admin_authorized(_scope(token="secret-token")))
def tearDownModule():
from db.db import engine
run(engine.dispose())
try:
os.remove(_DB_FILE)
except OSError:
pass
if __name__ == "__main__":
unittest.main()