From 18ae869ee830bca8f7c384ebca7d331339eaf3c7 Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 27 Jul 2026 20:09:24 +0200 Subject: [PATCH] pridanie pocty online/available hracov --- api/__init__.py | 108 +++++++++++++++++++++++-- frontend/src/components/HeaderMenu.tsx | 31 ++++--- frontend/src/lib/socket.ts | 10 ++- frontend/src/pages/Auth.tsx | 4 +- frontend/src/pages/GameList.tsx | 13 +-- frontend/src/store/gameStore.ts | 8 ++ frontend/src/types.ts | 7 +- 7 files changed, 153 insertions(+), 28 deletions(-) diff --git a/api/__init__.py b/api/__init__.py index 048b0a5..baf5a96 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -253,8 +253,52 @@ def public_games() -> list: # --- emit helpers --------------------------------------------------------- +def _online_player_ids() -> set[int]: + """Distinct logged-in players currently connected. + + Counting *players* (by account id), not raw sockets, is what makes the + lobby counters stable and meaningful: + * one person with several tabs/devices counts once; + * a not-yet-logged-in visitor (still on the auth screen) is not a player + and is not counted; + * reconnect churn and phantom sockets lingering until the engine.io ping + timeout no longer inflate the number, because the identity is counted, + not the transient connection. + """ + return {acc["player_id"] for acc in accounts.values()} + + +def _in_game_player_ids() -> set[int]: + """Players who currently occupy a seat in a game (any of their sockets is + seated) -- 'busy', i.e. not available to start/join another game.""" + ids: set[int] = set() + for sid in sessions: + acc = accounts.get(sid) + if acc is not None: + ids.add(acc["player_id"]) + return ids + + +def online_count() -> int: + """Number of distinct logged-in players online (in the lobby or in a game).""" + return len(_online_player_ids()) + + +def available_count() -> int: + """Online players not currently seated in any game (free to join/create).""" + return len(_online_player_ids() - _in_game_player_ids()) + + async def broadcast_lobby(): - await sio.emit("get_games", {"games": public_games()}, room=LOBBY) + await sio.emit( + "get_games", + { + "games": public_games(), + "online_count": online_count(), + "available_count": available_count(), + }, + room=LOBBY, + ) async def send_game_status(gid: str): @@ -455,6 +499,13 @@ async def send_error_room(gid: str, message: str): await sio.emit("error", {"error": message}, room=gid) +def _public_identity(account: dict) -> dict: + """The client only needs `player_id` to compare against game rosters (see + `isMember` in frontend/src/pages/GameList.tsx) -- `username` stays purely + server-side (used to name the seat on register_player/rejoin_game etc.).""" + return {"player_id": account["player_id"]} + + # --- connection lifecycle ------------------------------------------------- @sio.event @@ -465,8 +516,9 @@ async def connect(sid, environ, auth=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) + await sio.emit("login", {"player": _public_identity(identity)}, to=sid) + # Broadcast (not just emit to sid) so everyone's online-players count stays live. + await broadcast_lobby() @sio.event @@ -478,6 +530,7 @@ async def disconnect(sid): player = game.player_by_sid(sid) if player is not None: await _mark_player_offline(game, player) + # accounts/sessions already updated above, so the lobby counters are correct. await broadcast_lobby() @@ -513,7 +566,11 @@ async def confirm_account(sid, username, code): 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) + await sio.emit( + "login", {"player": _public_identity(accounts[sid]), "token": identity["token"]}, to=sid + ) + # A new player just came online -> refresh everyone's lobby counters. + await broadcast_lobby() @sio.on("login") @@ -533,7 +590,28 @@ async def login(sid, username, code): 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) + await sio.emit( + "login", {"player": _public_identity(accounts[sid]), "token": identity["token"]}, to=sid + ) + # A new player just came online -> refresh everyone's lobby counters. + await broadcast_lobby() + + +@sio.on("logout") +async def logout(sid): + """De-authenticate this connection without touching the transport. + + The previous approach (client calling `socket.disconnect()` then + immediately `socket.connect()`) was racy: python-socketio's disconnect + isn't instantaneous (the old sid can briefly linger past its ping-timeout + while a brand new sid is already connecting), so for that window BOTH + sids were counted in the lobby online/available totals -- exactly the + transient over-count reported. Simply dropping the account association + server-side avoids any connect/disconnect churn entirely. + """ + accounts.pop(sid, None) + # A player just went offline -> refresh everyone's lobby counters. + await broadcast_lobby() # --- lobby ---------------------------------------------------------------- @@ -550,7 +628,15 @@ async def create_game(sid, name): @sio.on("get_games") async def get_games(sid, *args): - await sio.emit("get_games", {"games": public_games()}, to=sid) + await sio.emit( + "get_games", + { + "games": public_games(), + "online_count": online_count(), + "available_count": available_count(), + }, + to=sid, + ) @sio.on("register_player") @@ -800,7 +886,15 @@ async def restore_game(sid, gid): 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) + return await sio.emit( + "get_games", + { + "games": public_games(), + "online_count": online_count(), + "available_count": available_count(), + }, + to=sid, + ) info = await history.reopen_game(gid, account["player_id"]) if info is None: diff --git a/frontend/src/components/HeaderMenu.tsx b/frontend/src/components/HeaderMenu.tsx index 5f06eff..45f2093 100644 --- a/frontend/src/components/HeaderMenu.tsx +++ b/frontend/src/components/HeaderMenu.tsx @@ -1,14 +1,21 @@ import { useEffect, useRef, useState } from 'react'; interface Props { - username?: string; + onlineCount?: number; + availableCount?: number; 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) { +export default function HeaderMenu({ + onlineCount, + availableCount, + onHistory, + onDonate, + onLogout, +}: Props) { const [open, setOpen] = useState(false); const ref = useRef(null); @@ -34,9 +41,16 @@ export default function HeaderMenu({ username, onHistory, onDonate, onLogout }: }; return ( - <> -
- {username} +
+ {typeof onlineCount === 'number' && ( + + + {onlineCount} + {typeof availableCount === 'number' && `/${availableCount} free`} + + )} + +
@@ -66,11 +80,6 @@ export default function HeaderMenu({ username, onHistory, onDonate, onLogout }: 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 && ( -

- {username} -

- )}
- +
); } diff --git a/frontend/src/lib/socket.ts b/frontend/src/lib/socket.ts index 598835c..20e2ccc 100644 --- a/frontend/src/lib/socket.ts +++ b/frontend/src/lib/socket.ts @@ -3,11 +3,11 @@ import { useGameStore } from '../store/gameStore'; import type { Account, GameDetail, - GameInfo, GameStatusPayload, Hand, HistoryGame, MyPlayer, + OnlineCountPayload, Registration, } from '../types'; @@ -29,6 +29,9 @@ export const emit = { confirmAccount: (username: string, code: string) => socket.emit('confirm_account', username, code), login: (username: string, code: string) => socket.emit('login', username, code), + // De-authenticates this connection server-side (no disconnect/reconnect -- + // see the "logout" handler comment in api/__init__.py for why). + logout: () => socket.emit('logout'), // history getPlayerHistory: () => socket.emit('get_player_history'), getGameDetail: (gid: string) => socket.emit('get_game_detail', gid), @@ -64,8 +67,11 @@ export const emit = { }; export function setupSocketListeners() { - socket.on('get_games', ({ games }: { games: GameInfo[] }) => { + socket.on('get_games', ({ games, online_count, available_count }: OnlineCountPayload) => { useGameStore.getState().setGames(games); + if (typeof online_count === 'number') useGameStore.getState().setOnlineCount(online_count); + if (typeof available_count === 'number') + useGameStore.getState().setAvailableCount(available_count); }); // Registration step 1: server returns the otpauth URI to render as a QR code. diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx index e87601f..daf83fb 100644 --- a/frontend/src/pages/Auth.tsx +++ b/frontend/src/pages/Auth.tsx @@ -65,7 +65,7 @@ export default function Auth() {

Bridžik

- Prihlás sa kódom z aplikácie (napr. Google Authenticator). + Prihlás sa kódom z aplikácie (napr. Google Authenticator ).

@@ -141,7 +141,7 @@ export default function Auth() { {mode === 'register' && registration && (

- Naskenuj QR kód do autentifikačnej aplikácie a opíš aktuálny kód. + Naskenuj QR kód do autentifikačnej aplikácie a opíš aktuálny kód.

diff --git a/frontend/src/pages/GameList.tsx b/frontend/src/pages/GameList.tsx index 25fba3d..1ced127 100644 --- a/frontend/src/pages/GameList.tsx +++ b/frontend/src/pages/GameList.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useGameStore } from '../store/gameStore'; -import { emit, socket, setAuthToken } from '../lib/socket'; +import { emit, setAuthToken } from '../lib/socket'; import { trackEvent } from '../lib/track'; import HeaderMenu from '../components/HeaderMenu'; import NameModal from '../components/NameModal'; @@ -10,6 +10,8 @@ import RulesModal from '../components/RulesModal'; export default function GameList() { const navigate = useNavigate(); const games = useGameStore((s) => s.games); + const onlineCount = useGameStore((s) => s.onlineCount); + const availableCount = useGameStore((s) => Math.max(0, s.availableCount - 1)); const account = useGameStore((s) => s.account); const [showCreate, setShowCreate] = useState(false); const [showRules, setShowRules] = useState(false); @@ -20,13 +22,13 @@ export default function GameList() { }; const handleLogout = () => { + // Tell the server first (while the socket auth is still attached), then + // clear local state -- no disconnect/reconnect needed (see emit.logout). + emit.logout(); 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 }); }; @@ -35,7 +37,8 @@ export default function GameList() {

Bridžik

navigate('/history')} onDonate={() => navigate('/donate')} onLogout={handleLogout} diff --git a/frontend/src/store/gameStore.ts b/frontend/src/store/gameStore.ts index ba0a2ba..0b29c34 100644 --- a/frontend/src/store/gameStore.ts +++ b/frontend/src/store/gameStore.ts @@ -12,6 +12,8 @@ import type { interface GameStore { games: GameInfo[]; + onlineCount: number; + availableCount: number; account: Account | null; registration: Registration | null; history: HistoryGame[]; @@ -22,6 +24,8 @@ interface GameStore { error: string | null; setGames: (games: GameInfo[]) => void; + setOnlineCount: (count: number) => void; + setAvailableCount: (count: number) => void; setAccount: (account: Account | null) => void; setRegistration: (registration: Registration | null) => void; setHistory: (history: HistoryGame[]) => void; @@ -38,6 +42,8 @@ interface GameStore { export const useGameStore = create((set) => ({ games: [], + onlineCount: 0, + availableCount: 0, account: null, registration: null, history: [], @@ -48,6 +54,8 @@ export const useGameStore = create((set) => ({ error: null, setGames: (games) => set({ games }), + setOnlineCount: (onlineCount) => set({ onlineCount }), + setAvailableCount: (availableCount) => set({ availableCount }), setAccount: (account) => set({ account }), setRegistration: (registration) => set({ registration }), setHistory: (history) => set({ history }), diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 30415c2..349b3e3 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -55,13 +55,18 @@ export interface GameInfo { players: PlayerInfo[]; } +export interface OnlineCountPayload { + games: GameInfo[]; + online_count: number; + available_count: number; +} + export type Hand = Record; // --- authentication (TOTP) --- export interface Account { player_id: number; - username: string; } export interface Registration {