From 04846aee26b340ff0b8bb34d8877abae3281724e Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 26 Oct 2026 22:06:50 +0100 Subject: [PATCH] pridanie rebricka (leaderboard) Nova polozka menu Rebricek a stranka /leaderboard: top 10 podla najlepsej hry (hry len s ludmi / s aspon jednym botom) a podla hodin v hre (hry do 3 h). Backend api/leaderboard.py + socket udalost get_leaderboard, testy v tests/test_history.py. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019YzEAACE9VgnLtXPuWGjwy --- CLAUDE.md | 1 + api/__init__.py | 13 +- api/leaderboard.py | 153 ++++++++++++++++++ frontend/src/App.tsx | 2 + frontend/src/components/HeaderMenu.tsx | 14 +- frontend/src/lib/socket.ts | 7 + frontend/src/pages/GameList.tsx | 3 +- frontend/src/pages/Leaderboard.tsx | 214 +++++++++++++++++++++++++ frontend/src/store/gameStore.ts | 6 + frontend/src/types.ts | 30 ++++ tests/test_history.py | 183 +++++++++++++++++++-- 11 files changed, 614 insertions(+), 12 deletions(-) create mode 100644 api/leaderboard.py create mode 100644 frontend/src/pages/Leaderboard.tsx diff --git a/CLAUDE.md b/CLAUDE.md index f1a413c..0f80184 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,7 @@ Async SQLAlchemy 2.0, independent of Socket.IO (mirrors how the engine is kept c - 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. +- **`api/leaderboard.py`** — rebríčky (socket udalosť `get_leaderboard`, iba čítanie). Len z naplno dohraných hier (`FULL_GAME_ROUNDS` kôl): top 10 podľa najlepšej hry hráča osobitne pre hry len s ľuďmi a hry s aspoň jedným botom (konto `bot:*`; boti sa v rebríčku nezobrazujú) a rebríček hodín (`ended_at - created_at`, aj hry s botmi, len hry do `MAX_GAME_DURATION` = 3 h, takže hra cez polnoc sa počíta, obnovená po dlhej pauze nie). Pozor: `Game.created_at` zapisuje DB (`server_default=now()`) v jej časovej zóne (prod Postgres Europe/Bratislava), `ended_at` Python ako naivný UTC, preto `_created_at_utc` na PostgreSQL prevádza `created_at` na UTC. Každý rebríček vracia `top` aj `me` (poradie prihláseného hráča). `get_leaderboard` číta DB, `build_leaderboard` radí čisté dáta (testy v `tests/test_history.py`, trieda `LeaderboardCase`). ### Frontend — `frontend/` diff --git a/api/__init__.py b/api/__init__.py index bae606f..e3ae768 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -12,7 +12,7 @@ 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 auth as auth_module, history, leaderboard from api import bots as bots_module from api import stats as stats_module from api.auth import AuthError, RegistrationIncomplete @@ -1016,3 +1016,14 @@ async def get_game_detail(sid, gid): if detail is None: return await send_error(sid, "Hra neexistuje.") await sio.emit("get_game_detail", detail, to=sid) + + +# --- leaderboard (read-only) ---------------------------------------------- + +@sio.on("get_leaderboard") +async def get_leaderboard(sid, *args): + account = accounts.get(sid) + if account is None: + return await send_error(sid, "Musíte byť prihlásený.") + data = await leaderboard.get_leaderboard(account["player_id"]) + await sio.emit("get_leaderboard", data, to=sid) diff --git a/api/leaderboard.py b/api/leaderboard.py new file mode 100644 index 0000000..47ae1f2 --- /dev/null +++ b/api/leaderboard.py @@ -0,0 +1,153 @@ +"""Rebricky (leaderboard) nad `db/`. + +Tri rebricky, vsetky len z naplno dohranych hier (rovnako ako `get_player_history`): + - najviac bodov za jednu hru -- hry len s ludmi, + - najviac bodov za jednu hru -- hry, v ktorych hral aspon jeden bot, + - hodiny straveny v hrach (`ended_at - created_at`, aj hry s botmi). +Boti sa v rebricku nikdy nezobrazuju. Pri bodoch ma kazdy hrac najviac jeden +riadok (jeho najlepsia hra). Kazdy rebricek vrati `top` (TOP_N riadkov) a `me` +(riadok prihlaseneho hraca s jeho poradim, alebo None ak v rebricku nie je). + +Citanie z DB (`get_leaderboard`) je oddelene od radenia (`build_leaderboard`), +aby sa pravidla dali testovat bez DB. +""" + +from datetime import timedelta + +from sqlalchemy import DateTime, cast, func, select + +from api.bots import is_bot_username +from api.history import FULL_GAME_ROUNDS, _usernames_for +from db.db import async_session, engine +from db.models import Game, Guess + +TOP_N = 10 + +# Do hodin sa pocita len hra, ktora trvala najviac tolko. Plynula hra cez polnoc +# sa tym zapocita; obnovena hra dohravana o hodiny ci dni neskor (ended_at sa pri +# obnove znovu nastavi) by do casu pridala celu pauzu, tak sa vynecha. +MAX_GAME_DURATION = timedelta(hours=3) + + +def _ranked(entries: list[dict], player_id: int) -> dict: + """Ocisluje uz zoradeny zoznam, vrati {top, me}.""" + for i, entry in enumerate(entries): + entry["rank"] = i + 1 + me = next((e for e in entries if e["player_id"] == player_id), None) + return {"top": entries[:TOP_N], "me": me} + + +def _points_board(results, games, usernames, player_id: int, with_bots: bool) -> dict: + """Najlepsia hra kazdeho cloveka; `with_bots` vyberie hry s botom / bez bota.""" + # vyssie body vyhrava; pri rovnosti skorsi koniec hry, potom player_id + rows = sorted( + ( + (points, games[gid]["ended_at"], pid) + for gid, pid, points in results + if games[gid]["has_bot"] == with_bots + ), + key=lambda r: (-r[0], r[1], r[2]), + ) + entries, seen = [], set() + for points, ended_at, pid in rows: + if pid in seen: + continue # hrac uz ma riadok so svojou najlepsou hrou + seen.add(pid) + entries.append( + { + "player_id": pid, + "username": usernames.get(pid, "?"), + "points": points, + "ended_at": ended_at.isoformat(), + } + ) + return _ranked(entries, player_id) + + +def _hours_board(results, games, usernames, player_id: int) -> dict: + """Sucet dlzky hier hraca (len hry do MAX_GAME_DURATION) a ich pocet.""" + totals: dict[int, list[int]] = {} # player_id -> [sekundy, pocet hier] + for gid, pid, _points in results: + duration = games[gid]["ended_at"] - games[gid]["created_at"] + if duration < timedelta(0) or duration > MAX_GAME_DURATION: + continue + agg = totals.setdefault(pid, [0, 0]) + agg[0] += int(duration.total_seconds()) + agg[1] += 1 + + ordered = sorted(totals.items(), key=lambda kv: (-kv[1][0], kv[0])) + entries = [ + { + "player_id": pid, + "username": usernames.get(pid, "?"), + "seconds": seconds, + "games": count, + } + for pid, (seconds, count) in ordered + ] + return _ranked(entries, player_id) + + +def build_leaderboard(results, games, usernames, player_id: int) -> dict: + """Poskladaj vsetky rebricky. + + `results` = zoznam (game_id, player_id, sucet bodov hraca v hre) len za dohrane + hry; `games[gid]` = {created_at, ended_at, has_bot}; `usernames` = player_id -> meno. + """ + # boti hraju, ale v rebricku sa nezobrazuju + results = [r for r in results if not is_bot_username(usernames.get(r[1], ""))] + return { + "points": { + "humans": _points_board(results, games, usernames, player_id, False), + "bots": _points_board(results, games, usernames, player_id, True), + }, + "hours": _hours_board(results, games, usernames, player_id), + } + + +def _created_at_utc(): + """`Game.created_at` ako naivny UTC, porovnatelny s `ended_at`. + + `created_at` zapisuje DB (`server_default=func.now()`) v casovej zone DB + session -- v produkcii PostgreSQL s TZ Europe/Bratislava -- kym `ended_at` + zapisuje Python ako naivny UTC (history._utcnow_naive). Na PostgreSQL preto + cas prevedieme cez zonu session do UTC (spravne aj cez letny cas). SQLite + CURRENT_TIMESTAMP je uz UTC. + """ + if engine.dialect.name == "postgresql": + return func.timezone("UTC", cast(Game.created_at, DateTime(timezone=True))) + return Game.created_at + + +async def get_leaderboard(player_id: int) -> dict: + """Rebricky z DB + poradie hraca `player_id` (aj ked je mimo top).""" + created_at = _created_at_utc().label("created_at") + async with async_session() as session: + # Kazdy hrac ma jeden Guess na dohrate kolo (unikat v Guess), takze pocet + # riadkov (hra, hrac) = pocet dohratych kol -- rovnake pravidlo "dohrana + # hra" ako history._completed_rounds_per_game, bez druheho dotazu. + rows = ( + await session.execute( + select( + Guess.game_id, + Guess.player_id, + func.sum(Guess.points), + created_at, + Game.ended_at, + ) + .join(Game, Game.id == Guess.game_id) + .where(Game.ended_at.is_not(None)) + .group_by(Guess.game_id, Guess.player_id, Game.created_at, Game.ended_at) + .having(func.count() >= FULL_GAME_ROUNDS) + ) + ).all() + usernames = await _usernames_for(session, [r[1] for r in rows]) + + games: dict[str, dict] = {} + for gid, pid, _points, created, ended in rows: + game = games.setdefault(gid, {"created_at": created, "ended_at": ended, "has_bot": False}) + # dohrana hra ma Guess pre vsetkych 4 hracov, takze vidime kazde sedadlo + if is_bot_username(usernames.get(pid, "")): + game["has_bot"] = True + results = [(gid, pid, int(points or 0)) for gid, pid, points, _c, _e in rows] + return build_leaderboard(results, games, usernames, player_id) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0eab3d0..fc1e711 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import Lobby from './pages/Lobby'; import GameTable from './pages/GameTable'; import Auth from './pages/Auth'; import History from './pages/History'; +import Leaderboard from './pages/Leaderboard'; import Donate from './pages/Donate'; // Admin pulls in recharts — lazy-load it so players never download that chunk. @@ -108,6 +109,7 @@ function AppInner() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/HeaderMenu.tsx b/frontend/src/components/HeaderMenu.tsx index 45f2093..1773d62 100644 --- a/frontend/src/components/HeaderMenu.tsx +++ b/frontend/src/components/HeaderMenu.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; interface Props { onlineCount?: number; availableCount?: number; + onLeaderboard: () => void; onHistory: () => void; onDonate: () => void; onLogout: () => void; @@ -12,6 +13,7 @@ interface Props { export default function HeaderMenu({ onlineCount, availableCount, + onLeaderboard, onHistory, onDonate, onLogout, @@ -50,7 +52,10 @@ export default function HeaderMenu({ )} -
+
+ @@ -80,6 +85,13 @@ export default function HeaderMenu({ 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)]" > + +
+ +
+ {TABS.map(([key, label]) => ( + + ))} +
+ + {tab === 'points' && ( +
+ {KINDS.map(([key, label]) => ( + + ))} +
+ )} + +

+ {tab === 'hours' + ? 'Celkový čas strávený v dohraných hrách, vrátane hier s botmi.' + : kind === 'humans' + ? 'Najvyšší počet bodov v jednej hre. Počítajú sa len hry, ktoré hrali samí ľudia.' + : 'Najvyšší počet bodov v jednej hre, v ktorej hral aspoň jeden bot.'} +

+ + {rows ? ( + + ) : ( +

Načítava sa…

+ )} + + {rows && rows.top.length > 0 && !rows.me && ( +

V tomto rebríčku zatiaľ nie si.

+ )} +

+ {tab === 'points' + ? 'Rebríček počíta len dohrané hry. Pri bodoch je dátum hry, v ktorej sa výsledok dosiahol.' + : 'Počíta sa čas od začiatku po koniec hry. Hry dlhšie ako 3 hodiny (napr. obnovené na druhý deň) sa nezapočítavajú.'} +

+
+ ); +} diff --git a/frontend/src/store/gameStore.ts b/frontend/src/store/gameStore.ts index 0b29c34..3b0ed53 100644 --- a/frontend/src/store/gameStore.ts +++ b/frontend/src/store/gameStore.ts @@ -6,6 +6,7 @@ import type { GameStatusPayload, Hand, HistoryGame, + Leaderboard, MyPlayer, Registration, } from '../types'; @@ -18,6 +19,7 @@ interface GameStore { registration: Registration | null; history: HistoryGame[]; gameDetail: GameDetail | null; + leaderboard: Leaderboard | null; myPlayer: MyPlayer | null; gameStatus: GameStatusPayload | null; hand: Hand; @@ -30,6 +32,7 @@ interface GameStore { setRegistration: (registration: Registration | null) => void; setHistory: (history: HistoryGame[]) => void; setGameDetail: (detail: GameDetail | null) => void; + setLeaderboard: (leaderboard: Leaderboard | null) => void; setMyPlayer: (player: MyPlayer | null) => void; setGameStatus: (status: GameStatusPayload) => void; setHand: (hand: Hand) => void; @@ -48,6 +51,7 @@ export const useGameStore = create((set) => ({ registration: null, history: [], gameDetail: null, + leaderboard: null, myPlayer: null, gameStatus: null, hand: {}, @@ -60,6 +64,7 @@ export const useGameStore = create((set) => ({ setRegistration: (registration) => set({ registration }), setHistory: (history) => set({ history }), setGameDetail: (gameDetail) => set({ gameDetail }), + setLeaderboard: (leaderboard) => set({ leaderboard }), setMyPlayer: (myPlayer) => set({ myPlayer }), setGameStatus: (gameStatus) => set({ gameStatus }), setHand: (hand) => set({ hand }), @@ -83,6 +88,7 @@ export const useGameStore = create((set) => ({ registration: null, history: [], gameDetail: null, + leaderboard: null, myPlayer: null, gameStatus: null, hand: {}, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 349b3e3..6f9d515 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -75,6 +75,36 @@ export interface Registration { otpauth_uri: string; } +// --- leaderboard --- + +export interface PointsEntry { + rank: number; + player_id: number; + username: string; + points: number; + /** Koniec hry, v ktorej sa výsledok dosiahol (ISO). */ + ended_at: string; +} + +export interface HoursEntry { + rank: number; + player_id: number; + username: string; + seconds: number; + games: number; +} + +/** `top` = prvých 10, `me` = riadok prihláseného hráča (null, ak v rebríčku nie je). */ +export interface Board { + top: T[]; + me: T | null; +} + +export interface Leaderboard { + points: { humans: Board; bots: Board }; + hours: Board; +} + // --- history --- export interface HistoryGame { diff --git a/tests/test_history.py b/tests/test_history.py index b7caa3c..8224fc2 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -10,6 +10,7 @@ import os import tempfile import unittest import uuid +from datetime import datetime, timedelta from types import SimpleNamespace # Nastav DB/ENCRYPTION_KEY PRED importom db/api modulov -- engine sa vytvara pri importe. @@ -23,10 +24,12 @@ 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 api import auth, history, leaderboard # noqa: E402 +from api import bots as bots_module # noqa: E402 +from bridzik import ROUNDS_PER_SERIES # 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 +from db.models import Game, Guess, Player # noqa: E402 def run(coro): @@ -47,19 +50,24 @@ def make_core(completed=True): return SimpleNamespace(series=[series], is_completed=lambda: completed) +def make_players(n=4): + """Zalozi `n` potvrdenych uctov, vrati ich player_id.""" + 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 + + 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 + return make_players(n) @staticmethod def _next_step_code(secret): @@ -279,6 +287,163 @@ class HistoryCase(unittest.TestCase): self.assertEqual(stored, crypto.hash_token(ident["token"])) +class LeaderboardCase(unittest.TestCase): + """Pravidla rebrickov (api/leaderboard.py): radenie nad syntetickymi datami, + plus jeden integracny test cez DB.""" + + UN = {1: "ann", 2: "bob", 3: "cyd", 4: "dee", 5: "bot:heuristic-1", 6: "bot:heuristic-2"} + + @staticmethod + def game(created_h, ended_h, has_bot=False, day=1): + base = datetime(2026, 10, day) + return { + "created_at": base + timedelta(hours=created_h), + "ended_at": base + timedelta(hours=ended_h), + "has_bot": has_bot, + } + + def board(self, results, games, me=1): + return leaderboard.build_leaderboard(results, games, self.UN, me) + + def test_points_split_humans_and_bots_and_hide_bots(self): + games = {"h": self.game(10, 11), "b": self.game(12, 13, has_bot=True)} + results = [ + ("h", 1, 200), ("h", 2, 150), ("h", 3, 100), ("h", 4, 50), + ("b", 1, 300), ("b", 2, 120), ("b", 3, 90), ("b", 5, 400), + ] + lb = self.board(results, games) + humans = lb["points"]["humans"]["top"] + self.assertEqual([(e["username"], e["points"]) for e in humans], + [("ann", 200), ("bob", 150), ("cyd", 100), ("dee", 50)]) + bots = lb["points"]["bots"]["top"] + # bot (400) sa v rebricku nezobrazi, hrac ann ma v botej hre svojich 300 + self.assertEqual([(e["username"], e["points"]) for e in bots], + [("ann", 300), ("bob", 120), ("cyd", 90)]) + self.assertEqual([e["rank"] for e in bots], [1, 2, 3]) + + def test_points_one_row_per_player_best_game(self): + games = {"a": self.game(10, 11), "b": self.game(12, 13, day=2)} + results = [("a", 1, 100), ("b", 1, 180), ("a", 2, 150)] + top = self.board(results, games)["points"]["humans"]["top"] + self.assertEqual([(e["username"], e["points"]) for e in top], + [("ann", 180), ("bob", 150)]) + + def test_points_tie_goes_to_earlier_game(self): + games = {"early": self.game(10, 11, day=1), "late": self.game(10, 11, day=2)} + results = [("late", 1, 150), ("early", 2, 150)] + top = self.board(results, games)["points"]["humans"]["top"] + self.assertEqual([e["username"] for e in top], ["bob", "ann"]) + + def test_me_outside_top_n_gets_real_rank(self): + games = {"g": self.game(10, 11)} + usernames = {i: f"p{i}" for i in range(1, 16)} + results = [("g", i, 1000 - i) for i in range(1, 16)] + lb = leaderboard.build_leaderboard(results, games, usernames, 12) + board = lb["points"]["humans"] + self.assertEqual(len(board["top"]), leaderboard.TOP_N) + self.assertEqual(board["me"]["rank"], 12) + self.assertEqual(board["me"]["points"], 988) + self.assertNotIn(12, [e["player_id"] for e in board["top"]]) + + def test_me_is_none_without_a_game(self): + games = {"g": self.game(10, 11)} + lb = self.board([("g", 2, 100)], games, me=1) + self.assertIsNone(lb["points"]["humans"]["me"]) + self.assertIsNone(lb["points"]["bots"]["me"]) + self.assertIsNone(lb["hours"]["me"]) + + def test_hours_sum_includes_bot_games_and_counts_games(self): + games = { + "a": self.game(10, 11), # 1 h + "b": self.game(12, 12.5, has_bot=True), # 30 min, s botom sa pocita + } + results = [("a", 1, 10), ("b", 1, 10), ("b", 5, 10), ("a", 2, 10)] + hours = self.board(results, games)["hours"] + self.assertEqual([e["username"] for e in hours["top"]], ["ann", "bob"]) + self.assertEqual(hours["top"][0]["seconds"], 5400) + self.assertEqual(hours["top"][0]["games"], 2) + self.assertEqual(hours["top"][1]["seconds"], 3600) + + def test_hours_game_across_midnight_counts_but_long_game_does_not(self): + games = { + "night": { # 23:30 -> 00:15 nasledujuceho dna = 45 min + "created_at": datetime(2026, 10, 1, 23, 30), + "ended_at": datetime(2026, 10, 2, 0, 15), + "has_bot": False, + }, + "resumed": { # obnovena o den neskor -> pauza by skreslila cas + "created_at": datetime(2026, 10, 1, 20, 0), + "ended_at": datetime(2026, 10, 2, 20, 30), + "has_bot": False, + }, + } + results = [("night", 1, 10), ("resumed", 1, 10), ("resumed", 2, 10)] + hours = self.board(results, games)["hours"] + self.assertEqual([(e["username"], e["seconds"]) for e in hours["top"]], + [("ann", 2700)]) + + def test_hours_game_at_the_limit_counts(self): + games = {"g": self.game(10, 13)} + hours = self.board([("g", 1, 10)], games)["hours"] + self.assertEqual(hours["top"][0]["seconds"], 3 * 3600) + + def test_bots_never_in_hours(self): + games = {"g": self.game(10, 11, has_bot=True)} + hours = self.board([("g", 1, 10), ("g", 5, 10), ("g", 6, 10)], games)["hours"] + self.assertEqual([e["username"] for e in hours["top"]], ["ann"]) + + # --- integracia s DB ----------------------------------------------------- + + @classmethod + def setUpClass(cls): + run(init_db()) + + @staticmethod + async def _add_game(gid, ids, totals, created, ended, rounds=history.FULL_GAME_ROUNDS): + """Hra so `rounds` dohratymi kolami; cely sucet bodov hraca je v kole 0.""" + async with async_session() as session: + session.add(Game( + id=gid, name="LB", player0_id=ids[0], player1_id=ids[1], + player2_id=ids[2], player3_id=ids[3], created_at=created, ended_at=ended, + )) + for n in range(rounds): + for seat in range(4): + session.add(Guess( + game_id=gid, player_id=ids[seat], series_number=n // ROUNDS_PER_SERIES, + round_number=n % ROUNDS_PER_SERIES, guess=0, points=totals[seat] if n == 0 else 0, + )) + await session.commit() + + def test_get_leaderboard_from_db(self): + humans = make_players(4) + bot = run(bots_module.ensure_bot_account("heuristic", set())) + mixed = humans[:3] + [bot["player_id"]] + start = datetime(2026, 11, 1, 10, 0) + # hry s obrovskymi bodmi, nech su urcite na vrchu aj popri inych testoch + run(self._add_game(str(uuid.uuid4()), humans, [9001, 9002, 9003, 9004], + start, start + timedelta(hours=1))) + run(self._add_game(str(uuid.uuid4()), mixed, [9101, 9102, 9103, 99999], + start, start + timedelta(hours=2))) + # nedohrana hra (len 1 kolo) sa nepocita + run(self._add_game(str(uuid.uuid4()), humans, [99990, 0, 0, 0], + start, start + timedelta(hours=1), rounds=1)) + + lb = run(leaderboard.get_leaderboard(humans[0])) + hum = lb["points"]["humans"]["top"] + self.assertEqual([e["player_id"] for e in hum[:4]], + [humans[3], humans[2], humans[1], humans[0]]) + self.assertEqual(hum[0]["points"], 9004) + bts = lb["points"]["bots"]["top"] + self.assertEqual([e["player_id"] for e in bts[:3]], + [humans[2], humans[1], humans[0]]) + self.assertNotIn(bot["player_id"], [e["player_id"] for e in bts]) + self.assertEqual(lb["points"]["humans"]["me"]["points"], 9001) + # hodiny: humans[0] odohral 1 h + 2 h, humans[3] len 1 h + me_hours = lb["hours"]["me"] + self.assertEqual(me_hours["seconds"], 3 * 3600) + self.assertEqual(me_hours["games"], 2) + + class CryptoCase(unittest.TestCase): def test_encrypt_decrypt_roundtrip(self): secret = "JBSWY3DPEHPK3PXP"