From af62de5569a494b3ae5e844e2d087b99d29ce9db Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Wed, 28 Jan 2026 00:49:37 +0300 Subject: [PATCH] With DataBaseREdis --- .env.example | 19 ++ AUTH_README.md | 106 ++++++++++ deploy.sh | 15 +- docker-compose.yml | 26 +++ package.json | 6 +- public/game.js | 277 +++++++++++++++++++++++++ public/index.html | 62 +++++- public/styles.css | 97 +++++++++ redis-client.js | 224 ++++++++++++++++++++ server.js | 501 +++++++++++++++++++++++++++++++++++++++++++-- 10 files changed, 1309 insertions(+), 24 deletions(-) create mode 100644 .env.example create mode 100644 AUTH_README.md create mode 100644 redis-client.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3a6afb0 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Star Wars Hearthstone - Environment Variables + +# Использовать Redis (true/false) +# Если false или не установлен, приложение работает без БД +USE_REDIS=true + +# URL Redis (если USE_REDIS=true) +# Для Docker: redis://redis:6379 +# Для локального: redis://localhost:6379 +REDIS_URL=redis://redis:6379 + +# Секретный ключ для сессий (измените в продакшене!) +SESSION_SECRET=star-wars-hearthstone-secret-key-change-in-production + +# Порт сервера +PORT=3542 + +# Окружение (production/development) +NODE_ENV=production diff --git a/AUTH_README.md b/AUTH_README.md new file mode 100644 index 0000000..54ae7ff --- /dev/null +++ b/AUTH_README.md @@ -0,0 +1,106 @@ +# Система авторизации и Leaderboard + +## Описание + +Игра теперь поддерживает систему авторизации/регистрации и глобальный рейтинг (Leaderboard) с использованием Redis в качестве базы данных. + +## Режимы работы + +### 1. Без Redis (для тестов) +По умолчанию приложение работает **без Redis**. Это позволяет тестировать игру без необходимости настройки базы данных. + +**Как запустить без Redis:** +- Просто запустите `node server.js` или `npm start` +- Или установите переменную окружения: `USE_REDIS=false` + +### 2. С Redis (для продакшена) +Для использования авторизации, регистрации и Leaderboard необходимо запустить Redis. + +**Как запустить с Redis:** + +#### Локально: +1. Установите Redis: `sudo apt-get install redis-server` (Debian/Ubuntu) +2. Запустите Redis: `redis-server` +3. Установите переменные окружения: + ```bash + export USE_REDIS=true + export REDIS_URL=redis://localhost:6379 + ``` +4. Запустите сервер: `node server.js` + +#### Docker: +1. Используйте `docker-compose.yml` - Redis уже настроен +2. Запустите: `docker compose up -d` +3. Redis автоматически подключится + +## Переменные окружения + +Создайте файл `.env` на основе `.env.example`: + +```bash +USE_REDIS=true # Использовать Redis (true/false) +REDIS_URL=redis://redis:6379 # URL Redis (для Docker: redis://redis:6379) +SESSION_SECRET=your-secret-key # Секретный ключ для сессий +PORT=3542 # Порт сервера +NODE_ENV=production # Окружение +``` + +## Deploy.sh + +Скрипт `deploy.sh` автоматически: +1. Останавливает Redis контейнер +2. Обновляет код из Git +3. Останавливает все контейнеры +4. Собирает образы +5. Запускает контейнеры (включая Redis) + +**Использование:** +```bash +chmod +x deploy.sh +./deploy.sh +``` + +## API Endpoints + +### Регистрация +``` +POST /api/register +Body: { username: string, password: string, email?: string } +``` + +### Вход +``` +POST /api/login +Body: { username: string, password: string } +``` + +### Выход +``` +POST /api/logout +``` + +### Информация о пользователе +``` +GET /api/user +``` + +### Leaderboard +``` +GET /api/leaderboard +Response: { leaderboard: [{ username, gamesPlayed, gamesWon, winRate, totalDamage, totalHealing }] } +``` + +## Статистика игроков + +Статистика автоматически обновляется при окончании игры: +- `gamesPlayed` - количество сыгранных игр +- `gamesWon` - количество побед +- `totalDamage` - общий нанесенный урон +- `totalHealing` - общее восстановленное здоровье + +## UI + +- **Модальное окно авторизации** появляется при первом запуске +- Можно продолжить без авторизации (кнопка "Продолжить без авторизации") +- **Кнопка Leaderboard** в правом верхнем углу лобби +- **Информация о пользователе** отображается в левом верхнем углу (если авторизован) diff --git a/deploy.sh b/deploy.sh index 4f8a0f7..b6b78d3 100644 --- a/deploy.sh +++ b/deploy.sh @@ -18,7 +18,7 @@ else fi echo "" -echo "🛑 Шаг 2/4: Остановка контейнеров..." +echo "🛑 Шаг 2/5: Остановка контейнеров..." docker compose down if [ $? -eq 0 ]; then echo "✅ Контейнеры остановлены" @@ -28,7 +28,12 @@ else fi echo "" -echo "🔨 Шаг 3/4: Сборка образов (без кеша)..." +echo "💾 Шаг 3/5: Остановка Redis (если запущен)..." +docker compose stop redis 2>/dev/null || true +echo "✅ Redis остановлен (если был запущен)" + +echo "" +echo "🔨 Шаг 4/5: Сборка образов (без кеша)..." docker compose build --no-cache if [ $? -eq 0 ]; then echo "✅ Образы собраны успешно" @@ -38,10 +43,14 @@ else fi echo "" -echo "▶️ Шаг 4/4: Запуск контейнеров..." +echo "▶️ Шаг 5/5: Запуск контейнеров..." docker compose up -d if [ $? -eq 0 ]; then echo "✅ Контейнеры запущены" + echo "" + echo "⏳ Ожидание готовности Redis..." + sleep 3 + docker compose ps redis | grep -q "Up" && echo "✅ Redis запущен" || echo "⚠️ Redis не запущен (работаем без БД)" else echo "❌ Ошибка при запуске контейнеров" exit 1 diff --git a/docker-compose.yml b/docker-compose.yml index 68169aa..11e7682 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,23 @@ version: '3.8' services: + redis: + image: redis:7-alpine + container_name: star-wars-redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes + restart: unless-stopped + networks: + - star-wars-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + star-wars-game: build: context: . @@ -11,7 +28,13 @@ services: environment: - NODE_ENV=production - PORT=3542 + - USE_REDIS=${USE_REDIS:-true} + - REDIS_URL=${REDIS_URL:-redis://redis:6379} + - SESSION_SECRET=${SESSION_SECRET:-star-wars-hearthstone-secret-key-change-in-production} restart: unless-stopped + depends_on: + redis: + condition: service_healthy networks: - star-wars-network healthcheck: @@ -21,6 +44,9 @@ services: retries: 3 start_period: 5s +volumes: + redis-data: + networks: star-wars-network: driver: bridge diff --git a/package.json b/package.json index 0bc1763..cacfbc1 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,10 @@ }, "dependencies": { "express": "^4.18.2", - "socket.io": "^4.7.2" + "socket.io": "^4.7.2", + "redis": "^4.6.12", + "bcrypt": "^5.1.1", + "express-session": "^1.17.3", + "connect-redis": "^7.1.0" } } diff --git a/public/game.js b/public/game.js index c7c6018..7f4915a 100644 --- a/public/game.js +++ b/public/game.js @@ -189,6 +189,10 @@ socket = io(url, { transports: ['websocket', 'polling'], reconnection: false }); socket.on('connect', () => { clearError(); + // Отправляем имя пользователя на сервер, если авторизованы + if (currentUser && currentUser.username) { + socket.emit('setUsername', currentUser.username); + } }); socket.on('connect_error', (e) => { const errorMsg = e.message || 'Не удалось подключиться к серверу'; @@ -2830,8 +2834,281 @@ }); } + // Авторизация + let currentUser = null; + + async function checkAuth() { + try { + const response = await fetch('/api/user', { credentials: 'include' }); + if (response.ok) { + const data = await response.json(); + currentUser = data.user; + updateUserUI(); + return true; + } else { + // Не авторизован, но не показываем модальное окно автоматически + // Пользователь может продолжить без авторизации + return false; + } + } catch (error) { + console.error('Ошибка проверки авторизации:', error); + // Не показываем модальное окно при ошибке, пользователь может продолжить + return false; + } + } + + function updateUserUI() { + const userInfo = $('user-info'); + const userName = $('user-name'); + if (currentUser && userInfo && userName) { + userInfo.style.display = 'flex'; + userName.textContent = currentUser.username; + if (currentUser.gamesPlayed !== undefined) { + userName.textContent += ` (${currentUser.gamesWon || 0}/${currentUser.gamesPlayed || 0})`; + } + } + } + + function showAuthModal() { + const authOverlay = $('auth-overlay'); + if (authOverlay) { + authOverlay.classList.remove('hidden'); + } + } + + function hideAuthModal() { + const authOverlay = $('auth-overlay'); + if (authOverlay) { + authOverlay.classList.add('hidden'); + } + } + + function initAuth() { + // Переключение между вкладками авторизации + $all('.auth-tab').forEach(tab => { + tab.addEventListener('click', () => { + $all('.auth-tab').forEach(t => t.classList.remove('active')); + $all('.auth-panel').forEach(p => p.classList.add('hidden')); + tab.classList.add('active'); + const panelId = 'auth-' + tab.dataset.authTab + '-panel'; + const panel = $(panelId); + if (panel) { + panel.classList.remove('hidden'); + panel.classList.add('active'); + } + }); + }); + + // Форма входа + const loginForm = $('login-form'); + if (loginForm) { + loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const username = $('login-username')?.value.trim(); + const password = $('login-password')?.value; + const errorEl = $('login-error'); + + if (!username || !password) { + if (errorEl) { + errorEl.textContent = 'Заполните все поля'; + errorEl.classList.remove('hidden'); + } + return; + } + + try { + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ username, password }) + }); + + const data = await response.json(); + + if (response.ok && data.success) { + currentUser = data.user; + updateUserUI(); + hideAuthModal(); + if (errorEl) errorEl.classList.add('hidden'); + } else { + if (errorEl) { + errorEl.textContent = data.error || 'Ошибка входа'; + errorEl.classList.remove('hidden'); + } + } + } catch (error) { + console.error('Ошибка входа:', error); + if (errorEl) { + errorEl.textContent = 'Ошибка соединения с сервером'; + errorEl.classList.remove('hidden'); + } + } + }); + } + + // Форма регистрации + const registerForm = $('register-form'); + if (registerForm) { + registerForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const username = $('register-username')?.value.trim(); + const password = $('register-password')?.value; + const email = $('register-email')?.value.trim(); + const errorEl = $('register-error'); + + if (!username || !password) { + if (errorEl) { + errorEl.textContent = 'Заполните обязательные поля'; + errorEl.classList.remove('hidden'); + } + return; + } + + try { + const response = await fetch('/api/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ username, password, email }) + }); + + const data = await response.json(); + + if (response.ok && data.success) { + currentUser = data.user; + updateUserUI(); + hideAuthModal(); + if (errorEl) errorEl.classList.add('hidden'); + // Отправляем имя пользователя на сервер через socket + if (socket && socket.connected && currentUser.username) { + socket.emit('setUsername', currentUser.username); + } + } else { + if (errorEl) { + errorEl.textContent = data.error || 'Ошибка регистрации'; + errorEl.classList.remove('hidden'); + } + } + } catch (error) { + console.error('Ошибка регистрации:', error); + if (errorEl) { + errorEl.textContent = 'Ошибка соединения с сервером'; + errorEl.classList.remove('hidden'); + } + } + }); + } + + // Кнопка закрытия авторизации + const authClose = $('auth-close'); + if (authClose) { + authClose.addEventListener('click', () => { + hideAuthModal(); + }); + } + + // Кнопка выхода + const btnLogout = $('btn-logout'); + if (btnLogout) { + btnLogout.addEventListener('click', async () => { + try { + await fetch('/api/logout', { method: 'POST', credentials: 'include' }); + currentUser = null; + const userInfo = $('user-info'); + if (userInfo) userInfo.style.display = 'none'; + showAuthModal(); + } catch (error) { + console.error('Ошибка выхода:', error); + } + }); + } + + // Leaderboard + const btnLeaderboard = $('btn-leaderboard'); + if (btnLeaderboard) { + btnLeaderboard.addEventListener('click', async () => { + const overlay = $('leaderboard-overlay'); + const content = $('leaderboard-content'); + if (overlay) overlay.classList.remove('hidden'); + if (content) content.innerHTML = '

Загрузка...

'; + + try { + const response = await fetch('/api/leaderboard', { credentials: 'include' }); + const data = await response.json(); + + if (response.ok && data.leaderboard) { + if (data.leaderboard.length === 0) { + content.innerHTML = '

Рейтинг пуст

'; + } else { + content.innerHTML = ` + + + + + + + + + + + + + + ${data.leaderboard.map((player, idx) => ` + + + + + + + + + + `).join('')} + +
#ИгрокИгрПобед% ПобедУронХил
${idx + 1}${escapeHtml(player.username)}${player.gamesPlayed}${player.gamesWon}${player.winRate}%${player.totalDamage}${player.totalHealing}
+ `; + } + } else { + content.innerHTML = '

Ошибка загрузки рейтинга

'; + } + } catch (error) { + console.error('Ошибка загрузки leaderboard:', error); + if (content) { + content.innerHTML = '

Ошибка соединения

'; + } + } + + if (typeof lucide !== 'undefined') { + setTimeout(() => { + try { + lucide.createIcons(); + } catch (e) { + console.warn('Error updating Lucide icons:', e); + } + }, 100); + } + }); + } + + const leaderboardClose = $('leaderboard-close'); + if (leaderboardClose) { + leaderboardClose.addEventListener('click', () => { + const overlay = $('leaderboard-overlay'); + if (overlay) overlay.classList.add('hidden'); + }); + } + } + function init() { initLobby(); + initAuth(); + // Проверяем авторизацию, но не блокируем доступ + checkAuth().catch(() => { + // Если авторизация не удалась, просто показываем модальное окно + // Пользователь может продолжить без авторизации + }); showScreen('lobby'); socket = null; } diff --git a/public/index.html b/public/index.html index 5617a6e..8312c59 100644 --- a/public/index.html +++ b/public/index.html @@ -20,6 +20,55 @@
+ + + + + +
@@ -29,9 +78,16 @@ HEARTHSTONE

PvP до 4 игроков · Игра с ИИ · Работает через Radmin VPN

- - - +
+ + + + +
+
diff --git a/public/styles.css b/public/styles.css index e90f87c..51fb2c5 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1564,6 +1564,103 @@ html, body { animation: fadeIn 0.3s ease; } .modal-overlay.hidden { display: none !important; } + +/* Auth Modal */ +.auth-modal { + max-width: 400px; +} +.auth-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + border-bottom: 2px solid rgba(212,168,75,0.2); +} +.auth-tab { + flex: 1; + padding: 0.75rem; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: #94a3b8; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} +.auth-tab.active { + color: var(--cyan); + border-bottom-color: var(--cyan); +} +.auth-tab:hover { + color: var(--cyan); +} +.auth-panel { + display: none; +} +.auth-panel.active { + display: block; +} +.auth-panel label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.9rem; + color: #94a3b8; +} +.auth-panel input { + width: 100%; + padding: 0.75rem; + background: rgba(0,0,0,0.3); + border: 2px solid rgba(0,180,255,0.35); + border-radius: 8px; + color: #f8fafc; + font-size: 1rem; + margin-bottom: 0.5rem; +} +.auth-panel input:focus { + outline: none; + border-color: var(--cyan); + box-shadow: 0 0 10px rgba(0,212,255,0.3); +} + +/* Leaderboard Modal */ +.leaderboard-modal { + max-width: 700px; + max-height: 80vh; +} +.leaderboard-table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; +} +.leaderboard-table th, +.leaderboard-table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid rgba(255,255,255,0.1); +} +.leaderboard-table th { + background: rgba(0,0,0,0.3); + color: var(--cyan); + font-weight: 700; + position: sticky; + top: 0; +} +.leaderboard-table tr:hover { + background: rgba(0,212,255,0.1); +} +.leaderboard-table .rank { + font-weight: 700; + color: var(--gold); + width: 50px; + text-align: center; +} +.leaderboard-table .username { + font-weight: 600; + color: var(--cyan); +} +.leaderboard-table .stats { + text-align: center; +} + .modal { background: linear-gradient(145deg, #2d2a22 0%, #1a1814 100%); border: 3px solid var(--gold); diff --git a/redis-client.js b/redis-client.js new file mode 100644 index 0000000..4fac63c --- /dev/null +++ b/redis-client.js @@ -0,0 +1,224 @@ +/** + * Redis Client - опциональное подключение + * Если Redis недоступен, работает без БД + */ + +let redisClient = null; +let redisAvailable = false; +let redisClientInstance = null; // Для connect-redis + +async function initRedis() { + // Проверяем, нужно ли использовать Redis + const useRedis = process.env.USE_REDIS === 'true' || process.env.REDIS_URL; + + if (!useRedis) { + console.log('📦 Redis отключен (USE_REDIS=false или REDIS_URL не установлен)'); + return; + } + + try { + const redis = require('redis'); + const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; + + redisClient = redis.createClient({ + url: redisUrl, + socket: { + reconnectStrategy: (retries) => { + if (retries > 10) { + console.log('⚠️ Redis: слишком много попыток переподключения, отключаемся'); + redisAvailable = false; + return false; // Остановить переподключение + } + return Math.min(retries * 100, 3000); + } + } + }); + + redisClient.on('error', (err) => { + console.error('❌ Redis ошибка:', err.message); + redisAvailable = false; + }); + + redisClient.on('connect', () => { + console.log('🔌 Redis: подключение установлено'); + redisAvailable = true; + }); + + redisClient.on('ready', () => { + console.log('✅ Redis: готов к работе'); + redisAvailable = true; + }); + + redisClient.on('end', () => { + console.log('🔌 Redis: соединение закрыто'); + redisAvailable = false; + }); + + await redisClient.connect(); + redisClientInstance = redisClient; // Сохраняем для connect-redis + redisAvailable = true; + console.log('✅ Redis подключен успешно'); + + // Экспортируем клиент для connect-redis + module.exports.redisClientInstance = redisClient; + } catch (error) { + console.warn('⚠️ Redis недоступен, работаем без БД:', error.message); + redisAvailable = false; + redisClient = null; + } +} + +async function closeRedis() { + if (redisClient && redisAvailable) { + try { + await redisClient.quit(); + console.log('🔌 Redis: соединение закрыто'); + } catch (error) { + console.error('Ошибка при закрытии Redis:', error); + } + } +} + +// Проверка доступности Redis +function isRedisAvailable() { + return redisAvailable && redisClient !== null; +} + +// Получить значение +async function get(key) { + if (!isRedisAvailable()) return null; + try { + return await redisClient.get(key); + } catch (error) { + console.error('Redis get error:', error); + return null; + } +} + +// Установить значение +async function set(key, value, expireSeconds = null) { + if (!isRedisAvailable()) return false; + try { + if (expireSeconds) { + await redisClient.setEx(key, expireSeconds, value); + } else { + await redisClient.set(key, value); + } + return true; + } catch (error) { + console.error('Redis set error:', error); + return false; + } +} + +// Удалить ключ +async function del(key) { + if (!isRedisAvailable()) return false; + try { + await redisClient.del(key); + return true; + } catch (error) { + console.error('Redis del error:', error); + return false; + } +} + +// Получить все ключи по паттерну +async function keys(pattern) { + if (!isRedisAvailable()) return []; + try { + return await redisClient.keys(pattern); + } catch (error) { + console.error('Redis keys error:', error); + return []; + } +} + +// Получить хеш +async function hGet(key, field) { + if (!isRedisAvailable()) return null; + try { + return await redisClient.hGet(key, field); + } catch (error) { + console.error('Redis hGet error:', error); + return null; + } +} + +// Установить поле в хеше +async function hSet(key, field, value) { + if (!isRedisAvailable()) return false; + try { + await redisClient.hSet(key, field, value); + return true; + } catch (error) { + console.error('Redis hSet error:', error); + return false; + } +} + +// Получить все поля хеша +async function hGetAll(key) { + if (!isRedisAvailable()) return {}; + try { + return await redisClient.hGetAll(key); + } catch (error) { + console.error('Redis hGetAll error:', error); + return {}; + } +} + +// Увеличить значение +async function incr(key) { + if (!isRedisAvailable()) return 0; + try { + return await redisClient.incr(key); + } catch (error) { + console.error('Redis incr error:', error); + return 0; + } +} + +// Получить список (добавить в конец) +async function rPush(key, ...values) { + if (!isRedisAvailable()) return 0; + try { + return await redisClient.rPush(key, values); + } catch (error) { + console.error('Redis rPush error:', error); + return 0; + } +} + +// Получить элементы списка +async function lRange(key, start, stop) { + if (!isRedisAvailable()) return []; + try { + return await redisClient.lRange(key, start, stop); + } catch (error) { + console.error('Redis lRange error:', error); + return []; + } +} + +// Получить клиент Redis для connect-redis +function getRedisClient() { + return redisClientInstance; +} + +module.exports = { + initRedis, + closeRedis, + isRedisAvailable, + getRedisClient, + get, + set, + del, + keys, + hGet, + hSet, + hGetAll, + incr, + rPush, + lRange +}; diff --git a/server.js b/server.js index 6a97df0..6ec3da1 100644 --- a/server.js +++ b/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() ? '✅ подключен' : '❌ отключен (работаем без БД)'} ║ ╚══════════════════════════════════════════════════════════╝ `); -}); + }); +})();