With DataBaseREdis
This commit is contained in:
19
.env.example
Normal file
19
.env.example
Normal 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
106
AUTH_README.md
Normal 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** в правом верхнем углу лобби
|
||||
- **Информация о пользователе** отображается в левом верхнем углу (если авторизован)
|
||||
15
deploy.sh
15
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
277
public/game.js
277
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 = '<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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
224
redis-client.js
Normal 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
|
||||
};
|
||||
479
server.js
479
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,7 +2647,207 @@ io.on('connection', (socket) => {
|
||||
let serverIP = 'localhost';
|
||||
let serverPort = PORT;
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
// 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();
|
||||
let lan = 'localhost';
|
||||
@ -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() ? '✅ подключен' : '❌ отключен (работаем без БД)'} ║
|
||||
╚══════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user