Compare commits
1
Commits
04185a0d97
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18ae869ee8 |
+101
-7
@@ -253,8 +253,52 @@ def public_games() -> list:
|
|||||||
|
|
||||||
# --- emit helpers ---------------------------------------------------------
|
# --- 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():
|
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):
|
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)
|
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 -------------------------------------------------
|
# --- connection lifecycle -------------------------------------------------
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
@@ -465,8 +516,9 @@ async def connect(sid, environ, auth=None):
|
|||||||
identity = await auth_module.player_by_token(token)
|
identity = await auth_module.player_by_token(token)
|
||||||
if identity is not None:
|
if identity is not None:
|
||||||
accounts[sid] = identity
|
accounts[sid] = identity
|
||||||
await sio.emit("login", {"player": identity}, to=sid)
|
await sio.emit("login", {"player": _public_identity(identity)}, to=sid)
|
||||||
await sio.emit("get_games", {"games": public_games()}, to=sid)
|
# Broadcast (not just emit to sid) so everyone's online-players count stays live.
|
||||||
|
await broadcast_lobby()
|
||||||
|
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
@@ -478,6 +530,7 @@ async def disconnect(sid):
|
|||||||
player = game.player_by_sid(sid)
|
player = game.player_by_sid(sid)
|
||||||
if player is not None:
|
if player is not None:
|
||||||
await _mark_player_offline(game, player)
|
await _mark_player_offline(game, player)
|
||||||
|
# accounts/sessions already updated above, so the lobby counters are correct.
|
||||||
await broadcast_lobby()
|
await broadcast_lobby()
|
||||||
|
|
||||||
|
|
||||||
@@ -513,7 +566,11 @@ async def confirm_account(sid, username, code):
|
|||||||
return await send_error(sid, str(exc))
|
return await send_error(sid, str(exc))
|
||||||
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
|
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
|
||||||
await _record_login_event(sid, identity["player_id"])
|
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")
|
@sio.on("login")
|
||||||
@@ -533,7 +590,28 @@ async def login(sid, username, code):
|
|||||||
return await send_error(sid, str(exc))
|
return await send_error(sid, str(exc))
|
||||||
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
|
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
|
||||||
await _record_login_event(sid, identity["player_id"])
|
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 ----------------------------------------------------------------
|
# --- lobby ----------------------------------------------------------------
|
||||||
@@ -550,7 +628,15 @@ async def create_game(sid, name):
|
|||||||
|
|
||||||
@sio.on("get_games")
|
@sio.on("get_games")
|
||||||
async def get_games(sid, *args):
|
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")
|
@sio.on("register_player")
|
||||||
@@ -800,7 +886,15 @@ async def restore_game(sid, gid):
|
|||||||
if gid in games:
|
if gid in games:
|
||||||
# Uz je v pamati (lobby) -- staci obnovit zoznam hier u klienta.
|
# Uz je v pamati (lobby) -- staci obnovit zoznam hier u klienta.
|
||||||
await sio.emit("game_restored", {"gid": gid}, to=sid)
|
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"])
|
info = await history.reopen_game(gid, account["player_id"])
|
||||||
if info is None:
|
if info is None:
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
username?: string;
|
onlineCount?: number;
|
||||||
|
availableCount?: number;
|
||||||
onHistory: () => void;
|
onHistory: () => void;
|
||||||
onDonate: () => void;
|
onDonate: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Header navigation: a row of links from `sm:` up, a hamburger dropdown below it. */
|
/** 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 [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -34,9 +41,16 @@ export default function HeaderMenu({ username, onHistory, onDonate, onLogout }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<div className="hidden sm:flex items-center gap-3 text-sm">
|
{typeof onlineCount === 'number' && (
|
||||||
<span className="text-green-dim">{username}</span>
|
<span className="flex items-center gap-1.5 text-green-dim" title="Online / dostupní hráči">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-score" />
|
||||||
|
{onlineCount}
|
||||||
|
{typeof availableCount === 'number' && `/${availableCount} free`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="hidden sm:flex items-center gap-3">
|
||||||
<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>
|
||||||
@@ -66,11 +80,6 @@ export default function HeaderMenu({ username, onHistory, onDonate, onLogout }:
|
|||||||
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)]"
|
||||||
>
|
>
|
||||||
{username && (
|
|
||||||
<p className="px-4 py-2 text-xs text-green-dim border-b border-[#142018] truncate">
|
|
||||||
{username}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={pick(onHistory)}
|
onClick={pick(onHistory)}
|
||||||
@@ -95,6 +104,6 @@ export default function HeaderMenu({ username, onHistory, onDonate, onLogout }:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { useGameStore } from '../store/gameStore';
|
|||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
GameDetail,
|
GameDetail,
|
||||||
GameInfo,
|
|
||||||
GameStatusPayload,
|
GameStatusPayload,
|
||||||
Hand,
|
Hand,
|
||||||
HistoryGame,
|
HistoryGame,
|
||||||
MyPlayer,
|
MyPlayer,
|
||||||
|
OnlineCountPayload,
|
||||||
Registration,
|
Registration,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
@@ -29,6 +29,9 @@ export const emit = {
|
|||||||
confirmAccount: (username: string, code: string) =>
|
confirmAccount: (username: string, code: string) =>
|
||||||
socket.emit('confirm_account', username, code),
|
socket.emit('confirm_account', username, code),
|
||||||
login: (username: string, code: string) => socket.emit('login', 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
|
// 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),
|
||||||
@@ -64,8 +67,11 @@ export const emit = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function setupSocketListeners() {
|
export function setupSocketListeners() {
|
||||||
socket.on('get_games', ({ games }: { games: GameInfo[] }) => {
|
socket.on('get_games', ({ games, online_count, available_count }: OnlineCountPayload) => {
|
||||||
useGameStore.getState().setGames(games);
|
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.
|
// Registration step 1: server returns the otpauth URI to render as a QR code.
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function Auth() {
|
|||||||
<div className="max-w-sm mx-auto p-4 pt-12 min-h-screen">
|
<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>
|
<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">
|
<p className="text-center text-green-dim text-sm mb-7">
|
||||||
Prihlás sa kódom z aplikácie (napr. Google Authenticator).
|
Prihlás sa kódom z aplikácie (<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" className="text-gold underline" target="_blank" rel="noopener noreferrer">napr. Google Authenticator </a>).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex mb-6 rounded-xl overflow-hidden border border-gold/20">
|
<div className="flex mb-6 rounded-xl overflow-hidden border border-gold/20">
|
||||||
@@ -141,7 +141,7 @@ export default function Auth() {
|
|||||||
{mode === 'register' && registration && (
|
{mode === 'register' && registration && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-sm text-green-score">
|
<p className="text-sm text-green-score">
|
||||||
Naskenuj QR kód do autentifikačnej aplikácie a opíš aktuálny kód.
|
Naskenuj QR kód do <a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" className="text-gold underline" target="_blank" rel="noopener noreferrer">autentifikačnej aplikácie</a> a opíš aktuálny kód.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-white rounded-xl p-4 flex justify-center">
|
<div className="bg-white rounded-xl p-4 flex justify-center">
|
||||||
<QRCodeSVG value={registration.otpauth_uri} size={176} />
|
<QRCodeSVG value={registration.otpauth_uri} size={176} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useGameStore } from '../store/gameStore';
|
import { useGameStore } from '../store/gameStore';
|
||||||
import { emit, socket, setAuthToken } from '../lib/socket';
|
import { emit, setAuthToken } from '../lib/socket';
|
||||||
import { trackEvent } from '../lib/track';
|
import { trackEvent } from '../lib/track';
|
||||||
import HeaderMenu from '../components/HeaderMenu';
|
import HeaderMenu from '../components/HeaderMenu';
|
||||||
import NameModal from '../components/NameModal';
|
import NameModal from '../components/NameModal';
|
||||||
@@ -10,6 +10,8 @@ import RulesModal from '../components/RulesModal';
|
|||||||
export default function GameList() {
|
export default function GameList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const games = useGameStore((s) => s.games);
|
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 account = useGameStore((s) => s.account);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [showRules, setShowRules] = useState(false);
|
const [showRules, setShowRules] = useState(false);
|
||||||
@@ -20,13 +22,13 @@ export default function GameList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
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_token');
|
||||||
localStorage.removeItem('bridzik_player');
|
localStorage.removeItem('bridzik_player');
|
||||||
setAuthToken(null);
|
setAuthToken(null);
|
||||||
useGameStore.getState().logout();
|
useGameStore.getState().logout();
|
||||||
// Drop the authenticated server-side session for this connection.
|
|
||||||
socket.disconnect();
|
|
||||||
socket.connect();
|
|
||||||
navigate('/auth', { replace: true });
|
navigate('/auth', { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,7 +37,8 @@ export default function GameList() {
|
|||||||
<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
|
||||||
username={account?.username}
|
onlineCount={onlineCount}
|
||||||
|
availableCount={availableCount}
|
||||||
onHistory={() => navigate('/history')}
|
onHistory={() => navigate('/history')}
|
||||||
onDonate={() => navigate('/donate')}
|
onDonate={() => navigate('/donate')}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type {
|
|||||||
|
|
||||||
interface GameStore {
|
interface GameStore {
|
||||||
games: GameInfo[];
|
games: GameInfo[];
|
||||||
|
onlineCount: number;
|
||||||
|
availableCount: number;
|
||||||
account: Account | null;
|
account: Account | null;
|
||||||
registration: Registration | null;
|
registration: Registration | null;
|
||||||
history: HistoryGame[];
|
history: HistoryGame[];
|
||||||
@@ -22,6 +24,8 @@ interface GameStore {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
setGames: (games: GameInfo[]) => void;
|
setGames: (games: GameInfo[]) => void;
|
||||||
|
setOnlineCount: (count: number) => void;
|
||||||
|
setAvailableCount: (count: number) => void;
|
||||||
setAccount: (account: Account | null) => void;
|
setAccount: (account: Account | null) => void;
|
||||||
setRegistration: (registration: Registration | null) => void;
|
setRegistration: (registration: Registration | null) => void;
|
||||||
setHistory: (history: HistoryGame[]) => void;
|
setHistory: (history: HistoryGame[]) => void;
|
||||||
@@ -38,6 +42,8 @@ interface GameStore {
|
|||||||
|
|
||||||
export const useGameStore = create<GameStore>((set) => ({
|
export const useGameStore = create<GameStore>((set) => ({
|
||||||
games: [],
|
games: [],
|
||||||
|
onlineCount: 0,
|
||||||
|
availableCount: 0,
|
||||||
account: null,
|
account: null,
|
||||||
registration: null,
|
registration: null,
|
||||||
history: [],
|
history: [],
|
||||||
@@ -48,6 +54,8 @@ export const useGameStore = create<GameStore>((set) => ({
|
|||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
setGames: (games) => set({ games }),
|
setGames: (games) => set({ games }),
|
||||||
|
setOnlineCount: (onlineCount) => set({ onlineCount }),
|
||||||
|
setAvailableCount: (availableCount) => set({ availableCount }),
|
||||||
setAccount: (account) => set({ account }),
|
setAccount: (account) => set({ account }),
|
||||||
setRegistration: (registration) => set({ registration }),
|
setRegistration: (registration) => set({ registration }),
|
||||||
setHistory: (history) => set({ history }),
|
setHistory: (history) => set({ history }),
|
||||||
|
|||||||
@@ -55,13 +55,18 @@ export interface GameInfo {
|
|||||||
players: PlayerInfo[];
|
players: PlayerInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OnlineCountPayload {
|
||||||
|
games: GameInfo[];
|
||||||
|
online_count: number;
|
||||||
|
available_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type Hand = Record<string, Card>;
|
export type Hand = Record<string, Card>;
|
||||||
|
|
||||||
// --- authentication (TOTP) ---
|
// --- authentication (TOTP) ---
|
||||||
|
|
||||||
export interface Account {
|
export interface Account {
|
||||||
player_id: number;
|
player_id: number;
|
||||||
username: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Registration {
|
export interface Registration {
|
||||||
|
|||||||
Reference in New Issue
Block a user