With DataBaseREdis
This commit is contained in:
501
server.js
501
server.js
@ -6,13 +6,215 @@ const express = require('express');
|
||||
const http = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcrypt');
|
||||
const session = require('express-session');
|
||||
const redisClient = require('./redis-client');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = new Server(server);
|
||||
|
||||
// Парсинг JSON для API
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Настройка сессий (будет настроена после инициализации Redis)
|
||||
// Временная конфигурация сессий, будет обновлена после инициализации Redis
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'star-wars-hearthstone-secret-key-change-in-production',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
httpOnly: true,
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 дней
|
||||
}
|
||||
}));
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// API для авторизации и регистрации
|
||||
app.post('/api/register', async (req, res) => {
|
||||
try {
|
||||
const { username, password, email } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Имя пользователя и пароль обязательны' });
|
||||
}
|
||||
|
||||
if (username.length < 3 || username.length > 20) {
|
||||
return res.status(400).json({ error: 'Имя пользователя должно быть от 3 до 20 символов' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Пароль должен быть не менее 6 символов' });
|
||||
}
|
||||
|
||||
// Проверяем, существует ли пользователь
|
||||
if (redisClient.isRedisAvailable()) {
|
||||
const existingUser = await redisClient.get(`user:${username}`);
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ error: 'Пользователь с таким именем уже существует' });
|
||||
}
|
||||
|
||||
// Хешируем пароль
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Сохраняем пользователя
|
||||
const userData = {
|
||||
username,
|
||||
password: hashedPassword,
|
||||
email: email || '',
|
||||
createdAt: Date.now(),
|
||||
gamesPlayed: 0,
|
||||
gamesWon: 0,
|
||||
totalDamage: 0,
|
||||
totalHealing: 0
|
||||
};
|
||||
|
||||
await redisClient.set(`user:${username}`, JSON.stringify(userData));
|
||||
if (email) {
|
||||
await redisClient.set(`user:email:${email}`, username);
|
||||
}
|
||||
|
||||
// Создаем сессию
|
||||
req.session.userId = username;
|
||||
req.session.save();
|
||||
|
||||
res.json({ success: true, user: { username, email: userData.email } });
|
||||
} else {
|
||||
// Без Redis - простая проверка (только для тестов)
|
||||
req.session.userId = username;
|
||||
req.session.save();
|
||||
res.json({ success: true, user: { username, email: email || '' }, warning: 'Работаем без БД' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка регистрации:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера при регистрации' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Имя пользователя и пароль обязательны' });
|
||||
}
|
||||
|
||||
if (redisClient.isRedisAvailable()) {
|
||||
const userDataStr = await redisClient.get(`user:${username}`);
|
||||
if (!userDataStr) {
|
||||
return res.status(401).json({ error: 'Неверное имя пользователя или пароль' });
|
||||
}
|
||||
|
||||
const userData = JSON.parse(userDataStr);
|
||||
const passwordMatch = await bcrypt.compare(password, userData.password);
|
||||
|
||||
if (!passwordMatch) {
|
||||
return res.status(401).json({ error: 'Неверное имя пользователя или пароль' });
|
||||
}
|
||||
|
||||
// Создаем сессию
|
||||
req.session.userId = username;
|
||||
req.session.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
gamesPlayed: userData.gamesPlayed || 0,
|
||||
gamesWon: userData.gamesWon || 0
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Без Redis - простая авторизация (только для тестов)
|
||||
req.session.userId = username;
|
||||
req.session.save();
|
||||
res.json({ success: true, user: { username }, warning: 'Работаем без БД' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка входа:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера при входе' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/logout', (req, res) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Ошибка при выходе' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/user', async (req, res) => {
|
||||
if (!req.session.userId) {
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
|
||||
if (redisClient.isRedisAvailable()) {
|
||||
const userDataStr = await redisClient.get(`user:${req.session.userId}`);
|
||||
if (userDataStr) {
|
||||
const userData = JSON.parse(userDataStr);
|
||||
res.json({
|
||||
user: {
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
gamesPlayed: userData.gamesPlayed || 0,
|
||||
gamesWon: userData.gamesWon || 0,
|
||||
totalDamage: userData.totalDamage || 0,
|
||||
totalHealing: userData.totalHealing || 0
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({ error: 'Пользователь не найден' });
|
||||
}
|
||||
} else {
|
||||
res.json({ user: { username: req.session.userId }, warning: 'Работаем без БД' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/leaderboard', async (req, res) => {
|
||||
try {
|
||||
if (!redisClient.isRedisAvailable()) {
|
||||
return res.json({ leaderboard: [], warning: 'Работаем без БД' });
|
||||
}
|
||||
|
||||
// Получаем всех пользователей
|
||||
const userKeys = await redisClient.keys('user:*');
|
||||
const leaderboard = [];
|
||||
|
||||
for (const key of userKeys) {
|
||||
if (key.startsWith('user:email:')) continue; // Пропускаем email индексы
|
||||
const userDataStr = await redisClient.get(key);
|
||||
if (userDataStr) {
|
||||
const userData = JSON.parse(userDataStr);
|
||||
leaderboard.push({
|
||||
username: userData.username,
|
||||
gamesPlayed: userData.gamesPlayed || 0,
|
||||
gamesWon: userData.gamesWon || 0,
|
||||
winRate: userData.gamesPlayed > 0 ? ((userData.gamesWon || 0) / userData.gamesPlayed * 100).toFixed(1) : 0,
|
||||
totalDamage: userData.totalDamage || 0,
|
||||
totalHealing: userData.totalHealing || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Сортируем по победам, затем по количеству игр
|
||||
leaderboard.sort((a, b) => {
|
||||
if (b.gamesWon !== a.gamesWon) return b.gamesWon - a.gamesWon;
|
||||
return b.gamesPlayed - a.gamesPlayed;
|
||||
});
|
||||
|
||||
res.json({ leaderboard: leaderboard.slice(0, 100) }); // Топ 100
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения leaderboard:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/cards', (req, res) => {
|
||||
res.json(cardDb);
|
||||
});
|
||||
@ -28,6 +230,7 @@ function getCard(room, cardId) {
|
||||
}
|
||||
|
||||
const rooms = new Map(); // code -> { lobby, gameState, gameStarted, faction, turnTimerInterval, turnTimeLeft, turnTimerWarnFired, spectators: [] }
|
||||
const socketToUsername = new Map(); // socket.id -> username (для обновления статистики)
|
||||
const TURN_SECONDS = 90;
|
||||
const TURN_WARN_AT = 15;
|
||||
|
||||
@ -1021,6 +1224,32 @@ function handleTransformOnDeath(room, card, ownerIndex) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePlayerStats(playerName, isWinner, stats = {}) {
|
||||
if (!redisClient.isRedisAvailable() || !playerName) return;
|
||||
|
||||
try {
|
||||
// playerName может быть именем пользователя или socket.id
|
||||
// Пытаемся найти по имени пользователя
|
||||
const userDataStr = await redisClient.get(`user:${playerName}`);
|
||||
if (userDataStr) {
|
||||
const userData = JSON.parse(userDataStr);
|
||||
userData.gamesPlayed = (userData.gamesPlayed || 0) + 1;
|
||||
if (isWinner) {
|
||||
userData.gamesWon = (userData.gamesWon || 0) + 1;
|
||||
}
|
||||
if (stats.totalDamage) {
|
||||
userData.totalDamage = (userData.totalDamage || 0) + stats.totalDamage;
|
||||
}
|
||||
if (stats.totalHealing) {
|
||||
userData.totalHealing = (userData.totalHealing || 0) + stats.totalHealing;
|
||||
}
|
||||
await redisClient.set(`user:${playerName}`, JSON.stringify(userData));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка обновления статистики для', playerName, ':', error);
|
||||
}
|
||||
}
|
||||
|
||||
function checkGameOver(room) {
|
||||
const gameState = room.gameState;
|
||||
const alive = gameState.players.filter((pp) => pp.health > 0);
|
||||
@ -1038,6 +1267,27 @@ function checkGameOver(room) {
|
||||
if (alive.length <= 1) {
|
||||
gameState.phase = 'ended';
|
||||
gameState.winner = alive.length === 1 ? alive[0] : null;
|
||||
|
||||
// Обновляем статистику игроков
|
||||
if (gameState.winner) {
|
||||
gameState.players.forEach((player) => {
|
||||
const isWinner = player.id === gameState.winner.id;
|
||||
// Подсчитываем статистику из лога
|
||||
const totalDamage = gameState.log
|
||||
.filter(log => {
|
||||
const playerIdx = gameState.players.indexOf(player);
|
||||
return (log.fromPlayer === playerIdx || log.fromPlayerIndex === playerIdx) && log.damage;
|
||||
})
|
||||
.reduce((sum, log) => sum + (log.damage || 0), 0);
|
||||
|
||||
// Обновляем асинхронно, не блокируя основной поток
|
||||
// Используем имя пользователя из маппинга или имя игрока
|
||||
const username = socketToUsername.get(player.id) || player.name;
|
||||
updatePlayerStats(username, isWinner, { totalDamage }).catch(err => {
|
||||
console.error('Ошибка обновления статистики для', username, err);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Если текущий игрок мёртв, переключаем ход
|
||||
@ -2037,13 +2287,22 @@ function attack(room, socketId, attackerBoardIndex, targetPlayerIndex, targetBoa
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
// Получаем имя пользователя от клиента
|
||||
socket.on('setUsername', (username) => {
|
||||
if (username) {
|
||||
socketToUsername.set(socket.id, username);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('createRoom', (data) => {
|
||||
const name = typeof data === 'string' ? data : (data?.name || 'Host');
|
||||
// Используем имя из маппинга или переданное имя
|
||||
const username = socketToUsername.get(socket.id);
|
||||
const name = username || (typeof data === 'string' ? data : (data?.name || 'Host'));
|
||||
const aiMode = typeof data === 'object' && data?.aiMode === true;
|
||||
const code = generateRoomCode();
|
||||
const room = {
|
||||
code,
|
||||
lobby: [{ id: socket.id, name: name || 'Host' }],
|
||||
lobby: [{ id: socket.id, name: name }],
|
||||
gameState: null,
|
||||
gameStarted: false,
|
||||
faction: null,
|
||||
@ -2107,10 +2366,14 @@ io.on('connection', (socket) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Используем имя из маппинга или переданное имя
|
||||
const username = socketToUsername.get(socket.id);
|
||||
const playerName = username || name || `Player ${room.lobby.length + 1}`;
|
||||
|
||||
if (room.gameStarted) {
|
||||
// Игра уже началась - подключаемся как наблюдатель
|
||||
if (!room.spectators) room.spectators = [];
|
||||
room.spectators.push({ id: socket.id, name: name || 'Наблюдатель' });
|
||||
room.spectators.push({ id: socket.id, name: playerName });
|
||||
socket.join(code);
|
||||
socket.emit('joinedAsSpectator', { code, gameState: room.gameState });
|
||||
broadcastGameState(room); // Отправляем текущее состояние игры
|
||||
@ -2122,7 +2385,7 @@ io.on('connection', (socket) => {
|
||||
socket.emit('error', 'Комната заполнена');
|
||||
return;
|
||||
}
|
||||
room.lobby.push({ id: socket.id, name: name || `Player ${room.lobby.length + 1}` });
|
||||
room.lobby.push({ id: socket.id, name: playerName });
|
||||
socket.join(code);
|
||||
io.to(code).emit('lobbyUpdate', room.lobby);
|
||||
socket.emit('roomJoined', { code, you: room.lobby[room.lobby.length - 1], lobby: room.lobby });
|
||||
@ -2339,6 +2602,8 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
// Удаляем маппинг socket.id -> username
|
||||
socketToUsername.delete(socket.id);
|
||||
const room = getRoomBySocket(socket.id);
|
||||
if (!room) return;
|
||||
|
||||
@ -2382,25 +2647,227 @@ io.on('connection', (socket) => {
|
||||
let serverIP = 'localhost';
|
||||
let serverPort = PORT;
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
const os = require('os');
|
||||
const nets = os.networkInterfaces();
|
||||
let lan = 'localhost';
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const n of nets[name]) {
|
||||
if (n.family === 'IPv4' && !n.internal) {
|
||||
lan = n.address;
|
||||
break;
|
||||
// API для авторизации и регистрации
|
||||
app.post('/api/register', async (req, res) => {
|
||||
try {
|
||||
const { username, password, email } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Имя пользователя и пароль обязательны' });
|
||||
}
|
||||
|
||||
if (username.length < 3 || username.length > 20) {
|
||||
return res.status(400).json({ error: 'Имя пользователя должно быть от 3 до 20 символов' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Пароль должен быть не менее 6 символов' });
|
||||
}
|
||||
|
||||
// Проверяем, существует ли пользователь
|
||||
if (redisClient.isRedisAvailable()) {
|
||||
const existingUser = await redisClient.get(`user:${username}`);
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ error: 'Пользователь с таким именем уже существует' });
|
||||
}
|
||||
|
||||
// Хешируем пароль
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Сохраняем пользователя
|
||||
const userData = {
|
||||
username,
|
||||
password: hashedPassword,
|
||||
email: email || '',
|
||||
createdAt: Date.now(),
|
||||
gamesPlayed: 0,
|
||||
gamesWon: 0,
|
||||
totalDamage: 0,
|
||||
totalHealing: 0
|
||||
};
|
||||
|
||||
await redisClient.set(`user:${username}`, JSON.stringify(userData));
|
||||
await redisClient.set(`user:email:${email || ''}`, username); // Для поиска по email
|
||||
|
||||
// Создаем сессию
|
||||
req.session.userId = username;
|
||||
req.session.save();
|
||||
|
||||
res.json({ success: true, user: { username, email: userData.email } });
|
||||
} else {
|
||||
// Без Redis - простая проверка (только для тестов)
|
||||
req.session.userId = username;
|
||||
req.session.save();
|
||||
res.json({ success: true, user: { username, email: email || '' }, warning: 'Работаем без БД' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка регистрации:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера при регистрации' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Имя пользователя и пароль обязательны' });
|
||||
}
|
||||
|
||||
if (redisClient.isRedisAvailable()) {
|
||||
const userDataStr = await redisClient.get(`user:${username}`);
|
||||
if (!userDataStr) {
|
||||
return res.status(401).json({ error: 'Неверное имя пользователя или пароль' });
|
||||
}
|
||||
|
||||
const userData = JSON.parse(userDataStr);
|
||||
const passwordMatch = await bcrypt.compare(password, userData.password);
|
||||
|
||||
if (!passwordMatch) {
|
||||
return res.status(401).json({ error: 'Неверное имя пользователя или пароль' });
|
||||
}
|
||||
|
||||
// Создаем сессию
|
||||
req.session.userId = username;
|
||||
req.session.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
gamesPlayed: userData.gamesPlayed || 0,
|
||||
gamesWon: userData.gamesWon || 0
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Без Redis - простая авторизация (только для тестов)
|
||||
req.session.userId = username;
|
||||
req.session.save();
|
||||
res.json({ success: true, user: { username }, warning: 'Работаем без БД' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка входа:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера при входе' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/logout', (req, res) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Ошибка при выходе' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/user', async (req, res) => {
|
||||
if (!req.session.userId) {
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
|
||||
if (redisClient.isRedisAvailable()) {
|
||||
const userDataStr = await redisClient.get(`user:${req.session.userId}`);
|
||||
if (userDataStr) {
|
||||
const userData = JSON.parse(userDataStr);
|
||||
res.json({
|
||||
user: {
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
gamesPlayed: userData.gamesPlayed || 0,
|
||||
gamesWon: userData.gamesWon || 0,
|
||||
totalDamage: userData.totalDamage || 0,
|
||||
totalHealing: userData.totalHealing || 0
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({ error: 'Пользователь не найден' });
|
||||
}
|
||||
} else {
|
||||
res.json({ user: { username: req.session.userId }, warning: 'Работаем без БД' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/leaderboard', async (req, res) => {
|
||||
try {
|
||||
if (!redisClient.isRedisAvailable()) {
|
||||
return res.json({ leaderboard: [], warning: 'Работаем без БД' });
|
||||
}
|
||||
|
||||
// Получаем всех пользователей
|
||||
const userKeys = await redisClient.keys('user:*');
|
||||
const leaderboard = [];
|
||||
|
||||
for (const key of userKeys) {
|
||||
if (key.startsWith('user:email:')) continue; // Пропускаем email индексы
|
||||
const userDataStr = await redisClient.get(key);
|
||||
if (userDataStr) {
|
||||
const userData = JSON.parse(userDataStr);
|
||||
leaderboard.push({
|
||||
username: userData.username,
|
||||
gamesPlayed: userData.gamesPlayed || 0,
|
||||
gamesWon: userData.gamesWon || 0,
|
||||
winRate: userData.gamesPlayed > 0 ? ((userData.gamesWon || 0) / userData.gamesPlayed * 100).toFixed(1) : 0,
|
||||
totalDamage: userData.totalDamage || 0,
|
||||
totalHealing: userData.totalHealing || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Сортируем по победам, затем по количеству игр
|
||||
leaderboard.sort((a, b) => {
|
||||
if (b.gamesWon !== a.gamesWon) return b.gamesWon - a.gamesWon;
|
||||
return b.gamesPlayed - a.gamesPlayed;
|
||||
});
|
||||
|
||||
res.json({ leaderboard: leaderboard.slice(0, 100) }); // Топ 100
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения leaderboard:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
serverIP = lan;
|
||||
serverPort = PORT;
|
||||
console.log(`
|
||||
});
|
||||
|
||||
// Middleware для проверки авторизации
|
||||
function requireAuth(req, res, next) {
|
||||
if (!req.session.userId) {
|
||||
return res.status(401).json({ error: 'Требуется авторизация' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// Инициализация Redis перед запуском сервера
|
||||
(async () => {
|
||||
await redisClient.initRedis();
|
||||
|
||||
// Настраиваем session store после инициализации Redis
|
||||
// Сессии уже настроены выше, Redis используется только для данных пользователей
|
||||
if (redisClient.isRedisAvailable()) {
|
||||
console.log('💾 Redis доступен для хранения данных пользователей');
|
||||
} else {
|
||||
console.log('💾 Работаем без БД (Redis недоступен)');
|
||||
}
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
const os = require('os');
|
||||
const nets = os.networkInterfaces();
|
||||
let lan = 'localhost';
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const n of nets[name]) {
|
||||
if (n.family === 'IPv4' && !n.internal) {
|
||||
lan = n.address;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
serverIP = lan;
|
||||
serverPort = PORT;
|
||||
console.log(`
|
||||
╔══════════════════════════════════════════════════════════╗
|
||||
║ STAR WARS HEARTHSTONE - Server running ║
|
||||
║ Local: http://localhost:${PORT} ║
|
||||
║ LAN: http://${lan}:${PORT} (use for Radmin VPN) ║
|
||||
║ Redis: ${redisClient.isRedisAvailable() ? '✅ подключен' : '❌ отключен (работаем без БД)'} ║
|
||||
╚══════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user