Compare commits
2
Commits
3655e06587
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04846aee26 | ||
|
|
4000da0454 |
No files matched your search
@@ -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]`.
|
- 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/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/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/`
|
### Frontend — `frontend/`
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -12,7 +12,7 @@ import socketio
|
|||||||
|
|
||||||
from bridzik import Bridzik, BridzikException, Card
|
from bridzik import Bridzik, BridzikException, Card
|
||||||
from db.db import init_db
|
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 bots as bots_module
|
||||||
from api import stats as stats_module
|
from api import stats as stats_module
|
||||||
from api.auth import AuthError, RegistrationIncomplete
|
from api.auth import AuthError, RegistrationIncomplete
|
||||||
@@ -1016,3 +1016,14 @@ async def get_game_detail(sid, gid):
|
|||||||
if detail is None:
|
if detail is None:
|
||||||
return await send_error(sid, "Hra neexistuje.")
|
return await send_error(sid, "Hra neexistuje.")
|
||||||
await sio.emit("get_game_detail", detail, to=sid)
|
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)
|
||||||
@@ -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)
|
||||||
@@ -240,14 +240,6 @@ async def get_daily_stats(logged_in_only: bool = False) -> dict:
|
|||||||
)
|
)
|
||||||
).one()
|
).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
|
# Nedokoncena registracia = kod nebol nikdy potvrdeny (SQL obdoba
|
||||||
# api/auth._is_unconfirmed). "Hraci celkom" pocita len potvrdene ucty,
|
# api/auth._is_unconfirmed). "Hraci celkom" pocita len potvrdene ucty,
|
||||||
# nedokoncene sa vykazuju osobitne.
|
# nedokoncene sa vykazuju osobitne.
|
||||||
@@ -338,7 +330,6 @@ async def get_daily_stats(logged_in_only: bool = False) -> dict:
|
|||||||
"players_per_day": {str(r.day): r.n for r in player_rows},
|
"players_per_day": {str(r.day): r.n for r in player_rows},
|
||||||
"finished_bot_games": finished_bot_games,
|
"finished_bot_games": finished_bot_games,
|
||||||
"finished_human_games": finished_human_games,
|
"finished_human_games": finished_human_games,
|
||||||
"avg_game_duration_minutes": (float(avg_duration) / 60) if avg_duration else None,
|
|
||||||
"total_players": total_players,
|
"total_players": total_players,
|
||||||
"unconfirmed_players": unconfirmed_players,
|
"unconfirmed_players": unconfirmed_players,
|
||||||
"peak_hours": {int(r.h): r.n for r in peak_hours},
|
"peak_hours": {int(r.h): r.n for r in peak_hours},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Lobby from './pages/Lobby';
|
|||||||
import GameTable from './pages/GameTable';
|
import GameTable from './pages/GameTable';
|
||||||
import Auth from './pages/Auth';
|
import Auth from './pages/Auth';
|
||||||
import History from './pages/History';
|
import History from './pages/History';
|
||||||
|
import Leaderboard from './pages/Leaderboard';
|
||||||
import Donate from './pages/Donate';
|
import Donate from './pages/Donate';
|
||||||
|
|
||||||
// Admin pulls in recharts — lazy-load it so players never download that chunk.
|
// Admin pulls in recharts — lazy-load it so players never download that chunk.
|
||||||
@@ -108,6 +109,7 @@ function AppInner() {
|
|||||||
<Route path="/auth" element={<Auth />} />
|
<Route path="/auth" element={<Auth />} />
|
||||||
<Route path="/" element={<GameList />} />
|
<Route path="/" element={<GameList />} />
|
||||||
<Route path="/history" element={<History />} />
|
<Route path="/history" element={<History />} />
|
||||||
|
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||||
<Route path="/donate" element={<Donate />} />
|
<Route path="/donate" element={<Donate />} />
|
||||||
<Route path="/lobby/:gid" element={<Lobby />} />
|
<Route path="/lobby/:gid" element={<Lobby />} />
|
||||||
<Route path="/game/:gid" element={<GameTable />} />
|
<Route path="/game/:gid" element={<GameTable />} />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
interface Props {
|
interface Props {
|
||||||
onlineCount?: number;
|
onlineCount?: number;
|
||||||
availableCount?: number;
|
availableCount?: number;
|
||||||
|
onLeaderboard: () => void;
|
||||||
onHistory: () => void;
|
onHistory: () => void;
|
||||||
onDonate: () => void;
|
onDonate: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
@@ -12,6 +13,7 @@ interface Props {
|
|||||||
export default function HeaderMenu({
|
export default function HeaderMenu({
|
||||||
onlineCount,
|
onlineCount,
|
||||||
availableCount,
|
availableCount,
|
||||||
|
onLeaderboard,
|
||||||
onHistory,
|
onHistory,
|
||||||
onDonate,
|
onDonate,
|
||||||
onLogout,
|
onLogout,
|
||||||
@@ -50,7 +52,10 @@ export default function HeaderMenu({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="hidden sm:flex items-center gap-3">
|
<div className="hidden sm:flex items-center gap-3 whitespace-nowrap">
|
||||||
|
<button onClick={onLeaderboard} className="text-gold hover:text-gold-bright">
|
||||||
|
Rebríček
|
||||||
|
</button>
|
||||||
<button onClick={onHistory} className="text-gold hover:text-gold-bright">
|
<button onClick={onHistory} className="text-gold hover:text-gold-bright">
|
||||||
História
|
História
|
||||||
</button>
|
</button>
|
||||||
@@ -80,6 +85,13 @@ export default function HeaderMenu({
|
|||||||
role="menu"
|
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)]"
|
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)]"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
role="menuitem"
|
||||||
|
onClick={pick(onLeaderboard)}
|
||||||
|
className="block w-full text-left px-4 py-2.5 text-sm text-gold hover:bg-gold/10"
|
||||||
|
>
|
||||||
|
Rebríček
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={pick(onHistory)}
|
onClick={pick(onHistory)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
GameStatusPayload,
|
GameStatusPayload,
|
||||||
Hand,
|
Hand,
|
||||||
HistoryGame,
|
HistoryGame,
|
||||||
|
Leaderboard,
|
||||||
MyPlayer,
|
MyPlayer,
|
||||||
OnlineCountPayload,
|
OnlineCountPayload,
|
||||||
Registration,
|
Registration,
|
||||||
@@ -35,6 +36,8 @@ export const emit = {
|
|||||||
// history
|
// history
|
||||||
getPlayerHistory: () => socket.emit('get_player_history'),
|
getPlayerHistory: () => socket.emit('get_player_history'),
|
||||||
getGameDetail: (gid: string) => socket.emit('get_game_detail', gid),
|
getGameDetail: (gid: string) => socket.emit('get_game_detail', gid),
|
||||||
|
// leaderboard
|
||||||
|
getLeaderboard: () => socket.emit('get_leaderboard'),
|
||||||
// lobby / game
|
// lobby / game
|
||||||
createGame: (name: string) => {
|
createGame: (name: string) => {
|
||||||
_createName = name;
|
_createName = name;
|
||||||
@@ -98,6 +101,10 @@ export function setupSocketListeners() {
|
|||||||
useGameStore.getState().setGameDetail(detail);
|
useGameStore.getState().setGameDetail(detail);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('get_leaderboard', (data: Leaderboard) => {
|
||||||
|
useGameStore.getState().setLeaderboard(data);
|
||||||
|
});
|
||||||
|
|
||||||
socket.on('create_game', ({ gid }: { gid: string }) => {
|
socket.on('create_game', ({ gid }: { gid: string }) => {
|
||||||
// Auto-chain: register into the just-created game (identity comes from the session).
|
// Auto-chain: register into the just-created game (identity comes from the session).
|
||||||
if (_createName !== null) {
|
if (_createName !== null) {
|
||||||
|
|||||||
@@ -33,12 +33,13 @@ export default function GameList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
|
<div className="max-w-lg mx-auto p-4 pt-8 min-h-screen">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="font-serif text-2xl text-gold tracking-wide">Bridžik</h1>
|
<h1 className="font-serif text-2xl text-gold tracking-wide">Bridžik</h1>
|
||||||
<HeaderMenu
|
<HeaderMenu
|
||||||
onlineCount={onlineCount}
|
onlineCount={onlineCount}
|
||||||
availableCount={availableCount}
|
availableCount={availableCount}
|
||||||
|
onLeaderboard={() => navigate('/leaderboard')}
|
||||||
onHistory={() => navigate('/history')}
|
onHistory={() => navigate('/history')}
|
||||||
onDonate={() => navigate('/donate')}
|
onDonate={() => navigate('/donate')}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useGameStore } from '../store/gameStore';
|
||||||
|
import { emit } from '../lib/socket';
|
||||||
|
import { displayName } from '../lib/names';
|
||||||
|
import type { Board, HoursEntry, PointsEntry } from '../types';
|
||||||
|
|
||||||
|
type Tab = 'points' | 'hours';
|
||||||
|
type Kind = 'humans' | 'bots';
|
||||||
|
|
||||||
|
/** `ended_at` is naive UTC from the server (no "Z") -- parse it as UTC so the
|
||||||
|
* date is the player's local day, not shifted near midnight. */
|
||||||
|
function fmtDate(iso: string): string {
|
||||||
|
const d = new Date(/(Z|[+-]\d\d:?\d\d)$/i.test(iso) ? iso : `${iso}Z`);
|
||||||
|
return isNaN(d.getTime()) ? '' : d.toLocaleDateString('sk-SK');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(seconds: number): string {
|
||||||
|
const totalMin = Math.floor(seconds / 60);
|
||||||
|
const h = Math.floor(totalMin / 60);
|
||||||
|
const m = totalMin % 60;
|
||||||
|
return h > 0 ? `${h} h ${String(m).padStart(2, '0')} min` : `${m} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowProps {
|
||||||
|
rank: number;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
unit?: string;
|
||||||
|
sub: string;
|
||||||
|
isMe: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ rank, name, value, unit, sub, isMe }: RowProps) {
|
||||||
|
const top = rank <= 3;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`grid grid-cols-[34px_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3.5 py-2 border-b border-gold/5 last:border-b-0 ${
|
||||||
|
isMe
|
||||||
|
? 'bg-gold/10 border-y border-gold/30'
|
||||||
|
: rank === 1
|
||||||
|
? 'bg-gradient-to-r from-gold/[.12] to-transparent'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`font-serif text-center tabular-nums ${
|
||||||
|
isMe || top ? 'text-gold-bright' : 'text-[#7a7058]'
|
||||||
|
} ${top ? 'font-semibold' : ''}`}
|
||||||
|
>
|
||||||
|
{rank}.
|
||||||
|
</span>
|
||||||
|
<span className={`min-w-0 truncate font-medium ${isMe ? 'text-gold-bright' : 'text-green-score'}`}>
|
||||||
|
{name}
|
||||||
|
{isMe && (
|
||||||
|
<span className="ml-1.5 align-[2px] rounded bg-gold px-1 py-px text-[10px] uppercase tracking-wider text-table">
|
||||||
|
Ty
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-right font-serif text-gold leading-tight whitespace-nowrap tabular-nums">
|
||||||
|
{value}
|
||||||
|
{unit && <small className="ml-0.5 font-sans text-[11px] text-green-dim">{unit}</small>}
|
||||||
|
<span className="block font-sans text-[11px] text-[#7a7058]">{sub}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ranked row without the "is this me" flag, which BoardView decides. */
|
||||||
|
type RowData = Omit<RowProps, 'isMe'> & { playerId: number };
|
||||||
|
|
||||||
|
/** Top 10 + (if the viewer is outside it) a gap and the viewer's own row. */
|
||||||
|
function BoardView({ top, me, myId }: { top: RowData[]; me: RowData | null; myId: number | undefined }) {
|
||||||
|
if (top.length === 0) {
|
||||||
|
return <p className="text-center text-green-dim py-6">Zatiaľ žiadne dohrané hry.</p>;
|
||||||
|
}
|
||||||
|
const meOutside = me && !top.some((r) => r.playerId === me.playerId);
|
||||||
|
return (
|
||||||
|
<div className="bg-header border border-[#142018] rounded-xl overflow-hidden">
|
||||||
|
{top.map((r) => (
|
||||||
|
<Row key={r.playerId} {...r} isMe={r.playerId === myId} />
|
||||||
|
))}
|
||||||
|
{meOutside && (
|
||||||
|
<>
|
||||||
|
<div aria-hidden className="text-center text-[13px] tracking-[.4em] text-[#7a7058] py-0.5 bg-gold/[.03]">
|
||||||
|
···
|
||||||
|
</div>
|
||||||
|
<Row {...me} isMe />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRows<T>(board: Board<T>, toRow: (e: T) => RowData) {
|
||||||
|
return { top: board.top.map(toRow), me: board.me && toRow(board.me) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointsRow = (e: PointsEntry): RowData => ({
|
||||||
|
playerId: e.player_id,
|
||||||
|
rank: e.rank,
|
||||||
|
name: displayName(e.username),
|
||||||
|
value: String(e.points),
|
||||||
|
unit: 'b.',
|
||||||
|
sub: fmtDate(e.ended_at),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hoursRow = (e: HoursEntry): RowData => ({
|
||||||
|
playerId: e.player_id,
|
||||||
|
rank: e.rank,
|
||||||
|
name: displayName(e.username),
|
||||||
|
value: fmtDuration(e.seconds),
|
||||||
|
sub: `${e.games} ${e.games === 1 ? 'hra' : e.games >= 2 && e.games <= 4 ? 'hry' : 'hier'}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const TABS: [Tab, string][] = [
|
||||||
|
['points', 'Najviac bodov'],
|
||||||
|
['hours', 'Hodiny v hre'],
|
||||||
|
];
|
||||||
|
const KINDS: [Kind, string][] = [
|
||||||
|
['humans', 'Len ľudia'],
|
||||||
|
['bots', 'S botmi'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Leaderboard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const leaderboard = useGameStore((s) => s.leaderboard);
|
||||||
|
const myId = useGameStore((s) => s.account?.player_id);
|
||||||
|
const setLeaderboard = useGameStore((s) => s.setLeaderboard);
|
||||||
|
const [tab, setTab] = useState<Tab>('points');
|
||||||
|
const [kind, setKind] = useState<Kind>('humans');
|
||||||
|
|
||||||
|
// Drop the snapshot on leave, so a revisit shows loading instead of stale ranks.
|
||||||
|
useEffect(() => {
|
||||||
|
emit.getLeaderboard();
|
||||||
|
return () => setLeaderboard(null);
|
||||||
|
}, [setLeaderboard]);
|
||||||
|
|
||||||
|
const rows = !leaderboard
|
||||||
|
? null
|
||||||
|
: tab === 'points'
|
||||||
|
? toRows(leaderboard.points[kind], pointsRow)
|
||||||
|
: toRows(leaderboard.hours, hoursRow);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<h1 className="font-serif text-2xl text-gold">Rebríček</h1>
|
||||||
|
<button onClick={() => navigate('/')} className="text-sm text-green-dim hover:text-gold">
|
||||||
|
Späť
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="tablist" className="grid grid-cols-2 gap-[3px] p-[3px] mb-4 rounded-xl border border-gold/20">
|
||||||
|
{TABS.map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === key}
|
||||||
|
onClick={() => setTab(key)}
|
||||||
|
className={`py-1.5 rounded-lg text-sm transition-colors ${
|
||||||
|
tab === key ? 'bg-gold text-table font-serif font-semibold' : 'text-green-dim hover:text-gold'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'points' && (
|
||||||
|
<div role="group" aria-label="Typ hier" className="flex gap-2 mb-4">
|
||||||
|
{KINDS.map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
aria-pressed={kind === key}
|
||||||
|
onClick={() => setKind(key)}
|
||||||
|
className={`px-3.5 py-1 rounded-full border text-sm transition-colors ${
|
||||||
|
kind === key
|
||||||
|
? 'border-gold text-gold-bright bg-gold/10'
|
||||||
|
: 'border-gold/25 text-green-dim hover:border-gold/50 hover:text-gold'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-green-dim mb-3">
|
||||||
|
{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.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{rows ? (
|
||||||
|
<BoardView top={rows.top} me={rows.me} myId={myId} />
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-green-dim pt-10 font-serif italic">Načítava sa…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows && rows.top.length > 0 && !rows.me && (
|
||||||
|
<p className="text-xs text-[#7a7058] mt-3 px-0.5">V tomto rebríčku zatiaľ nie si.</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-[#7a7058] mt-3 px-0.5">
|
||||||
|
{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ú.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@ interface DailyStats {
|
|||||||
players_per_day: Record<string, number>;
|
players_per_day: Record<string, number>;
|
||||||
finished_bot_games: number;
|
finished_bot_games: number;
|
||||||
finished_human_games: number;
|
finished_human_games: number;
|
||||||
avg_game_duration_minutes: number | null;
|
|
||||||
total_players: number;
|
total_players: number;
|
||||||
unconfirmed_players: number;
|
unconfirmed_players: number;
|
||||||
peak_hours: Record<string, number>;
|
peak_hours: Record<string, number>;
|
||||||
@@ -159,10 +158,6 @@ export default function AdminStats() {
|
|||||||
<SummaryCard label="Nedokončené registrácie" value={String(data.unconfirmed_players)} />
|
<SummaryCard label="Nedokončené registrácie" value={String(data.unconfirmed_players)} />
|
||||||
<SummaryCard label="Dokončené hry s ľuďmi" value={String(data.finished_human_games)} />
|
<SummaryCard label="Dokončené hry s ľuďmi" value={String(data.finished_human_games)} />
|
||||||
<SummaryCard label="Dokončené hry s botmi" value={String(data.finished_bot_games)} />
|
<SummaryCard label="Dokončené hry s botmi" value={String(data.finished_bot_games)} />
|
||||||
<SummaryCard
|
|
||||||
label="Priem. dĺžka hry"
|
|
||||||
value={data.avg_game_duration_minutes != null ? `${Math.round(data.avg_game_duration_minutes)} min` : '—'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`grid gap-4 ${desktop ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
<div className={`grid gap-4 ${desktop ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
GameStatusPayload,
|
GameStatusPayload,
|
||||||
Hand,
|
Hand,
|
||||||
HistoryGame,
|
HistoryGame,
|
||||||
|
Leaderboard,
|
||||||
MyPlayer,
|
MyPlayer,
|
||||||
Registration,
|
Registration,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
@@ -18,6 +19,7 @@ interface GameStore {
|
|||||||
registration: Registration | null;
|
registration: Registration | null;
|
||||||
history: HistoryGame[];
|
history: HistoryGame[];
|
||||||
gameDetail: GameDetail | null;
|
gameDetail: GameDetail | null;
|
||||||
|
leaderboard: Leaderboard | null;
|
||||||
myPlayer: MyPlayer | null;
|
myPlayer: MyPlayer | null;
|
||||||
gameStatus: GameStatusPayload | null;
|
gameStatus: GameStatusPayload | null;
|
||||||
hand: Hand;
|
hand: Hand;
|
||||||
@@ -30,6 +32,7 @@ interface GameStore {
|
|||||||
setRegistration: (registration: Registration | null) => void;
|
setRegistration: (registration: Registration | null) => void;
|
||||||
setHistory: (history: HistoryGame[]) => void;
|
setHistory: (history: HistoryGame[]) => void;
|
||||||
setGameDetail: (detail: GameDetail | null) => void;
|
setGameDetail: (detail: GameDetail | null) => void;
|
||||||
|
setLeaderboard: (leaderboard: Leaderboard | null) => void;
|
||||||
setMyPlayer: (player: MyPlayer | null) => void;
|
setMyPlayer: (player: MyPlayer | null) => void;
|
||||||
setGameStatus: (status: GameStatusPayload) => void;
|
setGameStatus: (status: GameStatusPayload) => void;
|
||||||
setHand: (hand: Hand) => void;
|
setHand: (hand: Hand) => void;
|
||||||
@@ -48,6 +51,7 @@ export const useGameStore = create<GameStore>((set) => ({
|
|||||||
registration: null,
|
registration: null,
|
||||||
history: [],
|
history: [],
|
||||||
gameDetail: null,
|
gameDetail: null,
|
||||||
|
leaderboard: null,
|
||||||
myPlayer: null,
|
myPlayer: null,
|
||||||
gameStatus: null,
|
gameStatus: null,
|
||||||
hand: {},
|
hand: {},
|
||||||
@@ -60,6 +64,7 @@ export const useGameStore = create<GameStore>((set) => ({
|
|||||||
setRegistration: (registration) => set({ registration }),
|
setRegistration: (registration) => set({ registration }),
|
||||||
setHistory: (history) => set({ history }),
|
setHistory: (history) => set({ history }),
|
||||||
setGameDetail: (gameDetail) => set({ gameDetail }),
|
setGameDetail: (gameDetail) => set({ gameDetail }),
|
||||||
|
setLeaderboard: (leaderboard) => set({ leaderboard }),
|
||||||
setMyPlayer: (myPlayer) => set({ myPlayer }),
|
setMyPlayer: (myPlayer) => set({ myPlayer }),
|
||||||
setGameStatus: (gameStatus) => set({ gameStatus }),
|
setGameStatus: (gameStatus) => set({ gameStatus }),
|
||||||
setHand: (hand) => set({ hand }),
|
setHand: (hand) => set({ hand }),
|
||||||
@@ -83,6 +88,7 @@ export const useGameStore = create<GameStore>((set) => ({
|
|||||||
registration: null,
|
registration: null,
|
||||||
history: [],
|
history: [],
|
||||||
gameDetail: null,
|
gameDetail: null,
|
||||||
|
leaderboard: null,
|
||||||
myPlayer: null,
|
myPlayer: null,
|
||||||
gameStatus: null,
|
gameStatus: null,
|
||||||
hand: {},
|
hand: {},
|
||||||
|
|||||||
@@ -75,6 +75,36 @@ export interface Registration {
|
|||||||
otpauth_uri: string;
|
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<T> {
|
||||||
|
top: T[];
|
||||||
|
me: T | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Leaderboard {
|
||||||
|
points: { humans: Board<PointsEntry>; bots: Board<PointsEntry> };
|
||||||
|
hours: Board<HoursEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
// --- history ---
|
// --- history ---
|
||||||
|
|
||||||
export interface HistoryGame {
|
export interface HistoryGame {
|
||||||
|
|||||||
+174
-9
@@ -10,6 +10,7 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
# Nastav DB/ENCRYPTION_KEY PRED importom db/api modulov -- engine sa vytvara pri importe.
|
# 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
|
import pyotp # noqa: E402
|
||||||
from sqlalchemy import select # 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 import crypto # noqa: E402
|
||||||
from db.db import async_session, init_db # 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):
|
def run(coro):
|
||||||
@@ -47,19 +50,24 @@ def make_core(completed=True):
|
|||||||
return SimpleNamespace(series=[series], is_completed=lambda: completed)
|
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):
|
class HistoryCase(unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
run(init_db())
|
run(init_db())
|
||||||
|
|
||||||
def _make_players(self, n=4):
|
def _make_players(self, n=4):
|
||||||
ids = []
|
return make_players(n)
|
||||||
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
|
@staticmethod
|
||||||
def _next_step_code(secret):
|
def _next_step_code(secret):
|
||||||
@@ -279,6 +287,163 @@ class HistoryCase(unittest.TestCase):
|
|||||||
self.assertEqual(stored, crypto.hash_token(ident["token"]))
|
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):
|
class CryptoCase(unittest.TestCase):
|
||||||
def test_encrypt_decrypt_roundtrip(self):
|
def test_encrypt_decrypt_roundtrip(self):
|
||||||
secret = "JBSWY3DPEHPK3PXP"
|
secret = "JBSWY3DPEHPK3PXP"
|
||||||
|
|||||||
Reference in new issue
Block a user