With DataBaseREdis

This commit is contained in:
2026-01-28 00:49:37 +03:00
parent 4b01c2d4de
commit af62de5569
10 changed files with 1309 additions and 24 deletions

19
.env.example Normal file
View File

@ -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

106
AUTH_README.md Normal file
View File

@ -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** в правом верхнем углу лобби
- **Информация о пользователе** отображается в левом верхнем углу (если авторизован)

View File

@ -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

View File

@ -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

View File

@ -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"
}
}

View File

@ -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 = '<p class="hint" style="text-align: center; padding: 2rem;">Загрузка...</p>';
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 = '<p class="hint" style="text-align: center; padding: 2rem;">Рейтинг пуст</p>';
} else {
content.innerHTML = `
<table class="leaderboard-table">
<thead>
<tr>
<th class="rank">#</th>
<th class="username">Игрок</th>
<th class="stats">Игр</th>
<th class="stats">Побед</th>
<th class="stats">% Побед</th>
<th class="stats">Урон</th>
<th class="stats">Хил</th>
</tr>
</thead>
<tbody>
${data.leaderboard.map((player, idx) => `
<tr>
<td class="rank">${idx + 1}</td>
<td class="username">${escapeHtml(player.username)}</td>
<td class="stats">${player.gamesPlayed}</td>
<td class="stats">${player.gamesWon}</td>
<td class="stats">${player.winRate}%</td>
<td class="stats">${player.totalDamage}</td>
<td class="stats">${player.totalHealing}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
} else {
content.innerHTML = '<p class="hint" style="text-align: center; padding: 2rem; color: var(--red);">Ошибка загрузки рейтинга</p>';
}
} catch (error) {
console.error('Ошибка загрузки leaderboard:', error);
if (content) {
content.innerHTML = '<p class="hint" style="text-align: center; padding: 2rem; color: var(--red);">Ошибка соединения</p>';
}
}
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;
}

View File

@ -20,6 +20,55 @@
<div id="stars2"></div>
<div id="stars3"></div>
<!-- Auth Modal -->
<div id="auth-overlay" class="modal-overlay hidden">
<div class="modal auth-modal">
<div class="auth-tabs">
<button type="button" class="auth-tab active" data-auth-tab="login">Вход</button>
<button type="button" class="auth-tab" data-auth-tab="register">Регистрация</button>
</div>
<div id="auth-login-panel" class="auth-panel active">
<h2>Вход</h2>
<form id="login-form">
<label>Имя пользователя</label>
<input type="text" id="login-username" placeholder="Введите имя" required maxlength="20" />
<label style="margin-top: 1rem;">Пароль</label>
<input type="password" id="login-password" placeholder="Введите пароль" required />
<button type="submit" class="btn btn-primary" style="margin-top: 1.5rem; width: 100%;">Войти</button>
</form>
<p id="login-error" class="error hidden" style="margin-top: 1rem;"></p>
</div>
<div id="auth-register-panel" class="auth-panel hidden">
<h2>Регистрация</h2>
<form id="register-form">
<label>Имя пользователя</label>
<input type="text" id="register-username" placeholder="От 3 до 20 символов" required minlength="3" maxlength="20" />
<label style="margin-top: 1rem;">Пароль</label>
<input type="password" id="register-password" placeholder="Не менее 6 символов" required minlength="6" />
<label style="margin-top: 1rem;">Email (необязательно)</label>
<input type="email" id="register-email" placeholder="your@email.com" />
<button type="submit" class="btn btn-primary" style="margin-top: 1.5rem; width: 100%;">Зарегистрироваться</button>
</form>
<p id="register-error" class="error hidden" style="margin-top: 1rem;"></p>
</div>
<button type="button" id="auth-close" class="btn btn-ghost" style="margin-top: 1rem; width: 100%;">Продолжить без авторизации</button>
</div>
</div>
<!-- Leaderboard Modal -->
<div id="leaderboard-overlay" class="modal-overlay hidden">
<div class="modal leaderboard-modal">
<h2><i data-lucide="trophy" style="width: 24px; height: 24px; vertical-align: middle; margin-right: 0.5rem;"></i>Глобальный рейтинг</h2>
<div id="leaderboard-content" style="max-height: 500px; overflow-y: auto; margin-top: 1rem;">
<p class="hint" style="text-align: center; padding: 2rem;">Загрузка...</p>
</div>
<button type="button" id="leaderboard-close" class="btn btn-primary" style="margin-top: 1rem; width: 100%;">Закрыть</button>
</div>
</div>
<!-- Lobby -->
<section id="lobby" class="screen">
<div class="lobby-card">
@ -29,9 +78,16 @@
<span class="logo-hs">HEARTHSTONE</span>
</h1>
<p class="subtitle">PvP до 4 игроков · Игра с ИИ · Работает через Radmin VPN</p>
<div style="position: absolute; top: 1rem; right: 1rem; display: flex; gap: 0.5rem; align-items: center;">
<button type="button" id="btn-leaderboard" class="btn-instructions-lobby" title="Рейтинг"><i data-lucide="trophy"></i></button>
<button type="button" id="btn-instructions-lobby" class="btn-instructions-lobby" title="Как играть">?</button>
<button type="button" id="btn-cards-gallery" class="btn-instructions-lobby" title="Галерея карт" style="right: 3.5rem;"><i data-lucide="book-open"></i></button>
<button type="button" id="btn-cards-gallery" class="btn-instructions-lobby" title="Галерея карт"><i data-lucide="book-open"></i></button>
<button type="button" id="btn-settings-lobby" class="btn-settings-lobby" title="Настройки"><i data-lucide="settings"></i></button>
</div>
<div id="user-info" style="position: absolute; top: 1rem; left: 1rem; display: none; align-items: center; gap: 0.5rem;">
<span id="user-name" style="color: var(--cyan); font-weight: 600;"></span>
<button type="button" id="btn-logout" class="btn btn-ghost" style="padding: 0.3rem 0.6rem; font-size: 0.85rem;">Выйти</button>
</div>
<div class="lobby-tabs">
<button type="button" class="tab active" data-tab="host">Создать игру</button>

View File

@ -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);

224
redis-client.js Normal file
View File

@ -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
};

475
server.js
View File

@ -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,6 +2647,206 @@ io.on('connection', (socket) => {
let serverIP = 'localhost';
let serverPort = PORT;
// 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: 'Ошибка сервера' });
}
});
// 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();
@ -2401,6 +2866,8 @@ server.listen(PORT, '0.0.0.0', () => {
║ STAR WARS HEARTHSTONE - Server running ║
║ Local: http://localhost:${PORT}
║ LAN: http://${lan}:${PORT} (use for Radmin VPN) ║
║ Redis: ${redisClient.isRedisAvailable() ? '✅ подключен' : '❌ отключен (работаем без БД)'}
╚══════════════════════════════════════════════════════════╝
`);
});
})();