Compare commits

..

No commits in common. "6afcd4656300e62103fdb65339d2c5c2826b2080" and "395e5fa3fbe59ed76d427500e9f1d53edfca7bab" have entirely different histories.

32 changed files with 1 additions and 16696 deletions

View File

@ -1,37 +0,0 @@
# Node modules
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Базы данных
*.db
*.db-journal
# Логи
logs/
*.log
# Временные файлы
.env.local
.env.development.local
.env.test.local
.env.production.local
# OS файлы
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Docker volumes
data/
# Временные файлы разработки
.tmp/
temp/

View File

@ -1,15 +0,0 @@
# Environment Configuration
# Для production используйте docker-compose.yml
# Порт сервера
PORT=3336
# Режим работы
NODE_ENV=production
# База данных (путь относительно корня проекта)
# DB_PATH=./data/poker.db
# Настройки JWT (опционально, генерируются автоматически)
# JWT_SECRET=your-secret-key-here
# JWT_EXPIRES_IN=7d

56
.gitignore vendored
View File

@ -1,56 +0,0 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
# Database
*.db
*.db-journal
data/
# Logs
logs/
*.log
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini
# IDE
.vscode/
.idea/
*.swp
*.swo
*.swn
*~
.project
.classpath
.settings/
# Docker volumes
data/
logs/
# Temporary files
.tmp/
temp/
tmp/
# Build files
dist/
build/

View File

@ -1,411 +0,0 @@
# Управление пользователями (Админ-панель)
## Обзор
Добавлена полноценная система управления пользователями в админ-панели с возможностью просмотра статистики, фильтрации, редактирования, блокировки и удаления пользователей.
## Новые возможности
### 📊 Статистика пользователей
- **Всего пользователей** — общее количество зарегистрированных
- **Активных (сегодня)** — пользователи с входом сегодня
- **Заблокировано** — количество заблокированных аккаунтов
- **Админов** — количество администраторов
### 🔍 Фильтры
- **По роли**: Все / Админы / Пользователи
- **По статусу**: Все / Активные / Заблокированные
- **Поиск по логину**: Динамический поиск
### 📋 Таблица пользователей
Отображаемые данные:
- **Логин** — имя пользователя (с пометкой "Вы" для текущего)
- **Роль** — 👑 Админ или 👤 Пользователь
- **IP адрес** — последний известный IP
- **Последний вход** — относительное время ("2 ч назад", "Вчера")
- **Статус** — ✅ Активен или 🚫 Заблокирован
- **Действия** — кнопки управления
### ⚙️ Действия с пользователями
1. **✏️ Редактировать**
- Изменить роль (Админ/Пользователь)
- Изменить статус (Активен/Заблокирован)
- Сменить пароль (опционально)
2. **🚫/✅ Заблокировать/Разблокировать**
- Быстрая блокировка/разблокировка
- Заблокированные пользователи не могут войти
3. **🗑️ Удалить**
- Полное удаление пользователя
- Требует двойного подтверждения
- Нельзя удалить себя
## Использование
### Открыть вкладку "Пользователи"
1. Войдите как администратор (`admin` / `admin`)
2. Откройте **Админ-панель** (меню → ⚙️ Админ)
3. Выберите вкладку **👥 Пользователи**
### Просмотр и фильтрация
```
┌─────────────────────────────────────────────┐
│ [Все роли ▼] [Все статусы ▼] [🔍 Поиск...] │
└─────────────────────────────────────────────┘
```
- Выберите фильтры в выпадающих списках
- Введите текст в поле поиска для фильтрации по логину
- Результаты обновляются автоматически
### Редактирование пользователя
1. Нажмите **✏️** напротив пользователя
2. Откроется модальное окно:
```
┌─────────────────────────────────┐
│ ✏️ Редактирование пользователя │
├─────────────────────────────────┤
│ Логин: [username] (readonly) │
│ Роль: [Пользователь ▼] │
│ Статус: [Активен ▼] │
│ Новый пароль: [________] │
│ │
│ [💾 Сохранить] [❌ Отмена] │
└─────────────────────────────────┘
```
3. Внесите изменения
4. Нажмите **💾 Сохранить**
### Блокировка пользователя
**Быстрая блокировка:**
- Нажмите **🚫** напротив пользователя
- Подтвердите действие
- Пользователь заблокирован
**Эффект:**
- Пользователь не сможет войти в систему
- При попытке входа: "Ваш аккаунт заблокирован"
- Статус в таблице: 🚫 Заблокирован
**Разблокировка:**
- Нажмите **✅** напротив заблокированного пользователя
- Подтвердите действие
### Удаление пользователя
⚠️ **ВНИМАНИЕ: Действие необратимо!**
1. Нажмите **🗑️** напротив пользователя
2. Подтвердите удаление (1-е окно)
3. Подтвердите ещё раз (2-е окно для безопасности)
4. Пользователь удалён полностью
**Что удаляется:**
- Запись пользователя из БД
- Настройки пользователя
- История игр сохраняется для статистики
**Ограничения:**
- Нельзя удалить себя
- Нельзя удалить единственного админа (рекомендуется)
## API Endpoints
### POST `/api/admin/users`
Получить список пользователей с фильтрами.
**Headers:**
```
Authorization: Bearer <token>
Content-Type: application/json
```
**Body:**
```json
{
"role": "admin|user|",
"status": "active|banned|",
"search": "логин для поиска"
}
```
**Response:**
```json
{
"users": [
{
"id": "uuid",
"username": "admin",
"role": "admin",
"banned": 0,
"ipAddress": "127.0.0.1",
"lastLogin": "2026-02-01T12:00:00Z",
"createdAt": "2026-01-01T00:00:00Z",
"totalGames": 42,
"totalWins": 18,
"totalChipsWon": 15000
}
],
"stats": {
"total": 10,
"active": 3,
"banned": 1,
"admins": 2
}
}
```
### POST `/api/admin/user/update`
Обновить данные пользователя.
**Body:**
```json
{
"username": "user123",
"role": "admin",
"banned": false,
"password": "новый_пароль" // опционально
}
```
**Response:**
```json
{
"success": true
}
```
### POST `/api/admin/user/ban`
Заблокировать/разблокировать пользователя.
**Body:**
```json
{
"username": "user123",
"banned": true
}
```
**Response:**
```json
{
"success": true
}
```
### POST `/api/admin/user/delete`
Удалить пользователя.
**Body:**
```json
{
"username": "user123"
}
```
**Response:**
```json
{
"success": true
}
```
**Errors:**
- `403` — Нельзя удалить себя
- `404` — Пользователь не найден
- `500` — Ошибка сервера
## База данных
### Новые поля в таблице `users`
```sql
ALTER TABLE users ADD COLUMN banned INTEGER DEFAULT 0;
ALTER TABLE users ADD COLUMN ip_address TEXT;
```
**Описание:**
- `banned` — флаг блокировки (0 = активен, 1 = заблокирован)
- `ip_address` — последний известный IP адрес пользователя
### Миграция
Поля добавляются автоматически при запуске сервера, если их нет:
```
✅ Добавлено поле banned в таблицу users
✅ Добавлено поле ip_address в таблицу users
```
## Безопасность
### Проверки на сервере
1. **Авторизация**: Только админы имеют доступ к API
2. **Валидация**: Проверка наличия обязательных полей
3. **Защита от самоблокировки**: Нельзя заблокировать/удалить себя
4. **Проверка при входе**: Блокированные пользователи не могут войти
### Логирование
Все административные действия логируются:
```javascript
database.logAction(adminId, adminUsername, 'update_user', {
target: 'user123',
changes: { role: 'admin', banned: true }
});
```
**Типы действий:**
- `view_users` — просмотр списка пользователей
- `update_user` — изменение данных пользователя
- `ban_user` — блокировка пользователя
- `unban_user` — разблокировка пользователя
- `delete_user` — удаление пользователя
## Интерфейс
### Цветовая индикация
**Роли:**
- 👑 **Админ** — фиолетовый градиент
- 👤 **Пользователь** — серый фон
**Статусы:**
- ✅ **Активен** — зелёный фон
- 🚫 **Заблокирован** — красный фон
### Адаптивность
- **Desktop (>1024px)**: Полная таблица
- **Tablet (768-1024px)**: Компактные столбцы
- **Mobile (<768px)**: Горизонтальная прокрутка таблицы
### Анимации
- Плавное появление вкладки (fadeInUp 0.3s)
- Hover эффекты на строках таблицы
- Увеличение кнопок при наведении (scale 1.1)
## Примеры использования
### Создать нового админа
1. Зарегистрируйте пользователя обычным способом
2. Войдите как админ
3. Откройте вкладку **👥 Пользователи**
4. Найдите нужного пользователя
5. Нажмите **✏️ Редактировать**
6. Измените роль на **👑 Администратор**
7. Сохраните
### Заблокировать спамера
1. Откройте вкладку **👥 Пользователи**
2. Найдите спамера в списке или через поиск
3. Нажмите **🚫 Заблокировать**
4. Подтвердите действие
5. Пользователь заблокирован
### Посмотреть активных сегодня
1. Откройте вкладку **👥 Пользователи**
2. В верхней панели статистики смотрите "Активных (сегодня)"
3. Или используйте фильтр и отсортируйте по "Последний вход"
### Сбросить пароль пользователю
1. Откройте вкладку **👥 Пользователи**
2. Найдите пользователя
3. Нажмите **✏️ Редактировать**
4. Введите новый пароль в поле "Новый пароль"
5. Нажмите **💾 Сохранить**
6. Сообщите пользователю новый пароль
## Ограничения и рекомендации
### Ограничения
- ❌ Нельзя редактировать/удалять себя
- ❌ Нельзя заблокировать себя
- ❌ Удаление необратимо (нет корзины)
### Рекомендации
- ✅ Всегда имейте минимум 2 админов
- ✅ Используйте блокировку вместо удаления (можно отменить)
- ✅ Регулярно проверяйте список активных пользователей
- ✅ Следите за IP адресами для выявления дубликатов
- ✅ Логируйте важные действия через вкладку "📋 Логи"
## Отладка
### Проверка прав доступа
```javascript
// В консоли браузера
console.log(currentUser?.role); // Должно быть "admin"
```
### Проверка токена
```javascript
// В консоли браузера
console.log(localStorage.getItem('token'));
```
### Проверка API
```bash
# Получить список пользователей
curl -X POST http://localhost:3000/api/admin/users \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"role":"","status":"","search":""}'
```
### Логи действий
Все административные действия записываются в таблицу `action_logs`:
```sql
SELECT * FROM action_logs
WHERE action_type IN ('update_user', 'ban_user', 'delete_user')
ORDER BY timestamp DESC
LIMIT 20;
```
## Обновления
**Версия 1.0** (2026-02-01)
- ✅ Базовое управление пользователями
- ✅ Фильтры и поиск
- ✅ Редактирование, блокировка, удаление
- ✅ Статистика пользователей
- ✅ Отслеживание IP адресов
- ✅ Проверка блокировки при входе
**Планируется:**
- 📋 Экспорт списка пользователей в CSV
- 📊 Расширенная статистика по пользователям
- 🔍 Фильтр по дате регистрации
- 📧 Отправка уведомлений пользователям
- 🔐 Двухфакторная аутентификация для админов
---
**Документация актуальна для версии**: 1.0
**Дата**: 2026-02-01
**Автор**: GitHub Copilot

View File

@ -1,326 +0,0 @@
# 🏗️ Архитектура развертывания
## 📊 Схема развертывания
```
┌─────────────────────────────────────────────────────────────────┐
│ ИНТЕРНЕТ │
└────────────────────────────┬────────────────────────────────────┘
│ HTTP/HTTPS (80/443)
┌─────────────────────────────────────────────────────────────────┐
│ APACHE 2.4 │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ • Reverse Proxy │ │
│ │ • WebSocket Support (proxy_wstunnel) │ │
│ │ • SSL Termination │ │
│ │ • Security Headers │ │
│ └───────────────────────────────────────────────────────────┘ │
└────────────────────────────┬────────────────────────────────────┘
│ Proxy Pass (3336)
┌─────────────────────────────────────────────────────────────────┐
│ DOCKER CONTAINER │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Texas Hold'em Poker │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ Node.js 18 (Alpine Linux) │ │ │
│ │ │ │ │ │
│ │ │ ┌────────────────┐ ┌────────────────┐ │ │ │
│ │ │ │ Express.js │◄───┤ WebSocket │ │ │ │
│ │ │ │ REST API │ │ Server (ws) │ │ │ │
│ │ │ └───────┬────────┘ └───────┬────────┘ │ │ │
│ │ │ │ │ │ │ │
│ │ │ └─────────┬───────────┘ │ │ │
│ │ │ ▼ │ │ │
│ │ │ ┌──────────────────┐ │ │ │
│ │ │ │ Database.js │ │ │ │
│ │ │ └─────────┬────────┘ │ │ │
│ │ │ │ │ │ │
│ │ │ ▼ │ │ │
│ │ │ ┌──────────────────┐ │ │ │
│ │ │ │ SQLite (sql.js) │ │ │ │
│ │ │ └──────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ Port: 3336 │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
└────────────────────────────┬────────────────────────────────────┘
│ Volumes
┌─────────────────────────────────────────────────────────────────┐
│ HOST FILESYSTEM │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ ./data/poker.db ◄─── База данных SQLite │ │
│ │ ./logs/ ◄─── Логи приложения │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 🔄 Поток данных
### HTTP Request Flow
```
1. Клиент → Apache (80/443)
2. Apache → Docker (3336)
3. Express.js обрабатывает запрос
4. Database.js → SQLite
5. Ответ возвращается клиенту
```
### WebSocket Flow
```
1. Клиент инициирует WS соединение
2. Apache proxy_wstunnel → Docker (3336)
3. WebSocket Server (ws) принимает подключение
4. Двусторонняя связь в реальном времени
```
---
## 📦 Компоненты
### Frontend (public/)
```
┌──────────────────────────┐
│ Static Files │
├──────────────────────────┤
│ • index.html │
│ • main.js │
│ • game.js │
│ • ai.js │
│ • auth.js │
│ • styles.css │
└──────────────────────────┘
```
### Backend (Node.js)
```
┌──────────────────────────┐
│ server.js │
├──────────────────────────┤
│ • Express App │
│ • HTTP Server │
│ • WebSocket Server │
│ • REST API Endpoints │
│ • Middleware │
└──────────────────────────┘
```
### Database Layer
```
┌──────────────────────────┐
│ database.js │
├──────────────────────────┤
│ • User Management │
│ • Settings Storage │
│ • Logs Storage │
│ • JWT Tokens │
│ • bcrypt Hashing │
└──────────────────────────┘
```
---
## 🔐 Безопасность
```
┌─────────────────────────────────────────────────────────────┐
│ Security Layers │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Apache Layer │
│ ├─ SSL/TLS (HTTPS) │
│ ├─ Security Headers │
│ └─ Rate Limiting (опционально) │
│ │
│ 2. Application Layer │
│ ├─ JWT Authentication │
│ ├─ bcrypt Password Hashing │
│ ├─ authMiddleware │
│ ├─ adminMiddleware │
│ └─ Input Validation │
│ │
│ 3. Database Layer │
│ ├─ Prepared Statements │
│ ├─ User Ban System │
│ └─ Action Logging │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## 🌐 Network Ports
```
╔══════════════════════════════════════════════════════════════╗
║ PORT MAPPING ║
╠══════════════════════════════════════════════════════════════╣
║ ║
║ External (Internet) ║
║ │ ║
║ ├── :80 (HTTP) ──────┐ ║
║ └── :443 (HTTPS) ──┐ │ ║
║ │ │ ║
║ Apache │ │ ║
║ ├── :80 <─────────┘ │ ║
║ └── :443 <────────────┘ ║
║ │ ║
║ └── Proxy to localhost:3336 ║
║ │ ║
║ Docker │ ║
║ └── :3336 <────────┘ ║
║ ║
╚══════════════════════════════════════════════════════════════╝
```
---
## 💾 Data Persistence
```
┌─────────────────────────────────────────────────────────────┐
│ Data Flow │
├─────────────────────────────────────────────────────────────┤
│ │
│ Application (Container) │
│ │ │
│ │ Write │
│ ▼ │
│ /app/data/poker.db ◄─────────► Volume Mount │
│ │ │ │
│ │ │ │
│ ▼ ▼ │
│ Host: ./data/poker.db │
│ │
│ ✅ Сохраняется при перезапуске контейнера │
│ ✅ Доступно для backup │
│ ✅ Можно редактировать напрямую │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## 🔄 Deployment Flow
```
┌─────────────────────────────────────────────────────────────┐
│ Deployment Process │
└─────────────────────────────────────────────────────────────┘
┌──────────────────┐
│ Clone Repo │
└────────┬─────────┘
┌──────────────────┐
│ ./deploy.sh │
│ ./deploy.ps1 │
└────────┬─────────┘
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Check │ │ Create │ │ Build │
│ Docker │ │ Dirs │ │ Image │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└────────────┼────────────┘
┌─────────────────┐
│ Start Container│
└────────┬────────┘
┌─────────────────┐
│ Verify Status │
└────────┬────────┘
┌──────────────┐
│ ✅ Ready │
└──────────────┘
```
---
## 🎯 Production Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Production Setup │
└─────────────────────────────────────────────────────────────┘
Internet
┌──────────────┐
│ Firewall │
└──────┬───────┘
┌──────────────┐
│ Apache │ ◄── SSL Certificates (Let's Encrypt)
│ (80/443) │
└──────┬───────┘
┌──────────────┐
│ Docker │
│ (3336) │ ◄── Auto-restart: unless-stopped
└──────┬───────┘
┌──────┴───────┬──────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌─────────┐ ┌──────┐
│ SQLite │ │ Logs │ │Backup│
│ Volume │ │ Volume │ │ Cron │
└────────┘ └─────────┘ └──────┘
```
---
## 📊 Monitoring Points
```
┌─────────────────────────────────────────────────────────────┐
│ Monitoring Setup │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Docker Logs │
│ docker-compose logs -f │
│ │
│ 2. Apache Logs │
│ /var/log/apache2/poker_error.log │
│ /var/log/apache2/poker_access.log │
│ │
│ 3. Application Logs │
│ ./logs/*.log (if configured) │
│ │
│ 4. Database Size │
│ du -h data/poker.db │
│ │
│ 5. Container Status │
│ docker-compose ps │
│ docker stats texas-holdem-poker │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
**Дата:** 2026-02-01
**Версия:** 1.0.0
**Статус:** ✅ Production Architecture Ready

View File

@ -1,305 +0,0 @@
# Настройка персональностей ботов
## Обзор
В игре реализована система настройки персональностей ботов через админ-панель. Каждый бот имеет уникальную личность с собственным системным промптом, который определяет его характер, стиль общения и поведение.
## Доступные персональности
1. **🦈 Виктор "Акула"** (aggressive)
- 20 лет опыта, несколько браслетов WSOP
- Агрессивный, уверенный, слегка надменный
- Любит подкалывать соперников и давить психологически
2. **👩‍💼 Анна "Блефер"** (tricky)
- Загадочная, проницательная
- Любит психологические игры и блеф
- Говорит намёками и двусмысленностями
3. **👴 Дед Михалыч** (oldschool)
- Добродушный, мудрый
- Часто вспоминает старые времена
- Говорит просто, с народными выражениями
4. **🤓 Макс "ГТО"** (mathematical)
- Молодой задрот-математик
- Помешан на GTO (Game Theory Optimal)
- Постоянно считает odds и EV
5. **🍀 Катя "Удача"** (lucky)
- Эмоциональная, позитивная, суеверная
- Верит в приметы и талисманы
- Радуется победам, расстраивается от проигрышей
6. **🎭 Борис "Молчун"** (silent)
- Молчаливый, загадочный
- Говорит ОЧЕНЬ мало (максимум 1-3 слова)
- Каждое слово на вес золота
7. **😤 Олег "Тильтер"** (tilted)
- Эмоциональный, легко тильтует
- При проигрыше злится и жалуется
- При выигрыше хвастается
8. **💎 Ирина "Профи"** (professional)
- Профессиональный онлайн-гриндер
- Спокойная, собранная, рациональная
- Покер — это работа, эмоции в сторону
## Как настроить персональность
### Через админ-панель
1. **Откройте админ-панель**
- Войдите как администратор (логин: `admin`, пароль: `admin`)
- Перейдите в раздел "Админ" (иконка ⚙️)
2. **Перейдите во вкладку "🤖 Боты"**
- В верхнем меню админ-панели выберите вкладку "Боты"
3. **Выберите персональность**
- В выпадающем списке выберите бота, которого хотите настроить
- Например: "🦈 Виктор "Акула""
4. **Редактируйте системный промпт**
- В большом текстовом поле отобразится текущий системный промпт
- Отредактируйте промпт по своему усмотрению
- Сохраните изменения кнопкой "💾 Сохранить промпт"
5. **Протестируйте промпт** (опционально)
- Нажмите кнопку "🧪 Тестировать промпт"
- Система отправит тестовое сообщение к LLM с вашим промптом
- Вы увидите ответ бота в тестовой ситуации
6. **Сбросьте к оригиналу** (если нужно)
- Кнопка "🔄 Сбросить к оригиналу" вернёт стандартный промпт
## Структура системного промпта
Системный промпт определяет всё поведение бота:
```
Ты — [имя и прозвище]. Прямо сейчас ты сидишь за покерным столом и играешь в Техасский Холдем.
ВАЖНО: Ты НЕ ИИ и НЕ ассистент. Ты — реальный игрок за столом.
Твой характер:
- [черта 1]
- [черта 2]
- [черта 3]
Правила ответов:
- ТОЛЬКО 1-2 коротких предложения
- Реагируй на игровую ситуацию
- Комментируй конкретную ситуацию (карты, ставки)
- [специфичные правила для персональности]
```
## Хранение кастомных промптов
- Кастомные промпты сохраняются в **localStorage** браузера
- Ключ: `customPersonalityPrompts`
- Формат: JSON объект `{ "Имя персональности": "Промпт" }`
- Промпты применяются при каждой загрузке страницы
### Пример localStorage:
```json
{
"Виктор \"Акула\"": "Ты — Виктор, агрессивный игрок...",
"Дед Михалыч": "Ты — Дед Михалыч, мудрый старик..."
}
```
## Программное использование
### Загрузка кастомных промптов при инициализации
```javascript
// В main.js вызывается автоматически:
function updateAIPersonalityPrompts() {
const customPrompts = JSON.parse(localStorage.getItem('customPersonalityPrompts') || '{}');
pokerAI.personalities.forEach(personality => {
const custom = customPrompts[personality.name];
if (custom) {
personality.systemPrompt = custom;
}
});
}
```
### Получение персональности для бота
```javascript
// В main.js при создании бота:
const botPersonality = pokerAI.getRandomPersonality();
// botPersonality содержит:
// {
// name: 'Виктор "Акула"',
// avatar: '🦈',
// style: 'aggressive',
// systemPrompt: '...' // Может быть кастомным!
// }
```
## Интеграция с LLM
Системный промпт используется во всех взаимодействиях бота с LLM:
1. **Обычный чат** (`llmChat.chat()`)
- Промпт передаётся как системное сообщение
2. **Эмоциональные реакции** (`generateEmotionalReaction()`)
- Промпт + контекст победы/поражения
3. **Поздравления игрока** (`generatePlayerCongratulation()`)
- Промпт + информация о сильной руке игрока
## Рекомендации по созданию промптов
### ✅ Хорошие практики
- **Короткие ответы**: Указывайте "ТОЛЬКО 1-2 предложения"
- **Контекст игры**: Упоминайте, что бот за покерным столом
- **Избегайте AI-ассистента**: "Ты НЕ ИИ и НЕ ассистент"
- **Конкретные примеры**: Давайте примеры фраз и реакций
- **Покерный сленг**: Указывайте, если бот должен использовать термины
### ❌ Частые ошибки
- Слишком длинные промпты (LLM будет давать длинные ответы)
- Отсутствие ограничений на длину ответа
- Промпт как для ассистента ("Помоги игроку", "Дай совет")
- Нет указания на игровой контекст
- Противоречивые инструкции
### Пример хорошего промпта
```
Ты — Макс "ГТО", молодой математик-покерист.
Ты сидишь за покерным столом в живой игре.
ВАЖНО: Ты НЕ ИИ. Ты — реальный игрок.
Характер:
- Помешан на математике и GTO стратегии
- Считаешь odds и EV в уме
- Немного занудный, но уважаешь хорошую игру
Ответы:
- Максимум 1-2 предложения, как реплика за столом
- Комментируй с точки зрения математики
- Можешь упомянуть: +EV, odds, equity, диапазоны, GTO
- Реагируй на конкретную ситуацию в игре
Примеры: "Матожидание сработало!", "По GTO это фолд", "Equity на моей стороне"
```
## Отладка
### Проверка промптов в консоли браузера
```javascript
// Посмотреть все персональности
console.log(pokerAI.personalities);
// Посмотреть кастомные промпты
console.log(localStorage.getItem('customPersonalityPrompts'));
// Обновить промпты из localStorage
updateAIPersonalityPrompts();
```
### Тестирование через админ-панель
1. Откройте вкладку "🤖 Боты"
2. Выберите персональность
3. Нажмите "🧪 Тестировать промпт"
4. Проверьте ответ бота в тестовой ситуации
Тестовый сценарий:
- Фаза: Флоп
- Карты на столе: K♠ Q♥ 7♦
- Карты бота: A♠ A♥
- Банк: 150
- Ставка: 50
- Вопрос игрока: "Как думаешь, какие у меня шансы?"
## Сброс к оригиналам
### Через админ-панель
- Выберите персональность
- Нажмите "🔄 Сбросить к оригиналу"
- Подтвердите действие
- Страница перезагрузится
### Программно
```javascript
// Удалить все кастомные промпты
localStorage.removeItem('customPersonalityPrompts');
location.reload();
// Удалить промпт конкретной персональности
const customPrompts = JSON.parse(localStorage.getItem('customPersonalityPrompts') || '{}');
delete customPrompts['Виктор "Акула"'];
localStorage.setItem('customPersonalityPrompts', JSON.stringify(customPrompts));
location.reload();
```
## FAQ
**Q: Промпт сохранился, но бот не изменился?**
A: Перезагрузите страницу. Промпты применяются при загрузке.
**Q: Можно ли добавить новую персональность?**
A: Да, но это требует изменения кода в `ai.js` (массив `personalities`).
**Q: Что если LLM выключен?**
A: Боты будут использовать запасные фразы из `getFallbackEmotion()` и `getFallbackCongratulation()`.
**Q: Промпты синхронизируются между браузерами?**
A: Нет, localStorage локален для каждого браузера.
**Q: Как экспортировать/импортировать промпты?**
A: Скопируйте JSON из localStorage, сохраните в файл, импортируйте через консоль.
## Примеры использования
### Создать бота с сильным акцентом на блеф
Выберите "Анна Блефер" и усильте промпт:
```
Ты — Анна "Блефер", королева психологических игр.
НИКОГДА не показывай свои истинные эмоции.
ВСЕГДА говори загадками и намёками.
Блефуй даже в обычном разговоре.
Ответы: 1-2 предложения, загадочно, с ноткой иронии.
```
### Создать бота-новичка
Выберите любую персональность и измените на:
```
Ты — начинающий игрок в покер, учишься у профи.
Часто спрашиваешь совета.
Иногда путаешь термины.
Радуешься даже мелким победам.
Ответы: 1-2 предложения, неуверенно, с вопросами.
Примеры: "Это хорошо?", "Надеюсь не ошибся", "Стрит бьёт флеш?"
```
---
**Версия документа**: 1.0
**Дата**: 2026-02-01
**Автор**: GitHub Copilot

View File

@ -1,288 +0,0 @@
# ✅ Чеклист готовности к Docker развертыванию
## 📦 Созданные файлы (12 новых)
### Docker конфигурация
- [x] **Dockerfile** (733 bytes)
- Node.js 18 Alpine
- Production режим
- Порт 3336
- [x] **docker-compose.yml** (586 bytes)
- Сервис poker-app
- Volumes для данных
- Переменные окружения
- [x] **.dockerignore** (450 bytes)
- Исключение ненужных файлов
- Оптимизация сборки
### Веб-сервер
- [x] **apache-config.conf**
- HTTP/HTTPS конфигурация
- WebSocket поддержка
- Reverse proxy на 3336
### Автоматизация
- [x] **deploy.sh** (2,727 bytes)
- Bash скрипт для Linux/Mac
- Автоматическая проверка зависимостей
- Пошаговое развертывание
- [x] **deploy.ps1**
- PowerShell скрипт для Windows
- Цветной вывод
- Проверка Docker
### Конфигурация
- [x] **.env.example** (445 bytes)
- Пример переменных окружения
- PORT=3336
- NODE_ENV=production
- [x] **.gitignore** (обновлен)
- Исключение data/ и logs/
- Исключение .env файлов
### Документация
- [x] **README.md** (11,385 bytes)
- Главная документация
- Docker инструкции
- Полное описание проекта
- [x] **DEPLOYMENT.md** (11,785 bytes)
- Полное руководство
- Apache настройка
- Backup инструкции
- Troubleshooting
- [x] **DOCKER_README.md** (3,294 bytes)
- Краткая инструкция
- Основные команды
- Quick start
- [x] **DOCKER_SETUP_SUMMARY.md** (7,613 bytes)
- Сводка изменений
- Контрольный список
- Следующие шаги
- [x] **TESTING.md** (5,425 bytes)
- Тестовые сценарии
- Проверка работы
- Troubleshooting
### Код
- [x] **package.json** (обновлен)
- Добавлены npm скрипты для Docker
- docker:build, docker:up, docker:down, etc.
- [x] **public/main.js** (исправлен)
- Исправлена работа с токенами
- Синхронизировано с auth.js
---
## 🔧 Технические характеристики
### Порты
- **Внутренний:** 3336
- **Внешний:** 3336 (можно изменить в docker-compose.yml)
- **Apache HTTP:** 80 → 3336
- **Apache HTTPS:** 443 → 3336
### Технологии
- **Docker:** Alpine Linux + Node.js 18
- **База данных:** SQLite (в volume)
- **WebSocket:** ws module
- **Reverse Proxy:** Apache 2.4+
### Volumes
- `./data:/app/data` - База данных SQLite
- `./logs:/app/logs` - Логи приложения
### Переменные окружения
- `PORT=3336`
- `NODE_ENV=production`
---
## 🚀 Способы запуска
### 1. Автоматический (рекомендуется)
```bash
# Windows
.\deploy.ps1
# Linux/Mac
chmod +x deploy.sh && ./deploy.sh
```
### 2. Docker Compose
```bash
docker-compose up -d
```
### 3. NPM скрипты
```bash
npm run docker:build
npm run docker:up
```
---
## ✅ Что готово
### Основное
- [x] Docker образ настроен
- [x] Docker Compose конфигурация
- [x] Порт изменен на 3336
- [x] Volumes для данных
- [x] Переменные окружения
- [x] Production режим
### Автоматизация
- [x] Скрипт развертывания (Linux/Mac)
- [x] Скрипт развертывания (Windows)
- [x] NPM скрипты
- [x] .dockerignore
- [x] .gitignore обновлен
### Apache
- [x] HTTP конфигурация
- [x] HTTPS конфигурация
- [x] WebSocket поддержка
- [x] Reverse proxy
- [x] Заголовки безопасности
### Документация
- [x] README.md обновлен
- [x] DEPLOYMENT.md создан
- [x] DOCKER_README.md создан
- [x] TESTING.md создан
- [x] DOCKER_SETUP_SUMMARY.md создан
### Исправления
- [x] Токены синхронизированы (authToken)
- [x] Функции управления пользователями исправлены
- [x] showScreen() ошибки устранены
---
## 📋 Следующие шаги
### 1. Локальное тестирование
```bash
# Запуск
docker-compose up -d
# Проверка
curl http://localhost:3336
```
### 2. Тестирование в браузере
- Открыть http://localhost:3336
- Зарегистрироваться
- Создать игру
- Проверить WebSocket
### 3. Создание администратора
```sql
UPDATE users SET role = 'admin' WHERE username = 'ваш_логин';
```
### 4. Настройка Apache (опционально)
```bash
sudo cp apache-config.conf /etc/apache2/sites-available/poker.conf
sudo a2ensite poker.conf
sudo systemctl reload apache2
```
### 5. Настройка SSL (опционально)
```bash
sudo certbot --apache -d poker.yourdomain.com
```
### 6. Настройка backup
- Следуйте инструкциям в DEPLOYMENT.md
- Настройте cron задачу
---
## 🎯 Итоговая структура
```
onlinepocker/
├── 🐳 Docker
│ ├── Dockerfile
│ ├── docker-compose.yml
│ ├── .dockerignore
│ ├── deploy.sh
│ └── deploy.ps1
├── 🌐 Apache
│ └── apache-config.conf
├── ⚙️ Конфигурация
│ ├── .env.example
│ ├── .gitignore
│ └── package.json (обновлен)
├── 📚 Документация
│ ├── README.md (обновлен)
│ ├── DEPLOYMENT.md
│ ├── DOCKER_README.md
│ ├── DOCKER_SETUP_SUMMARY.md
│ ├── TESTING.md
│ ├── BOT_PERSONALITIES_CONFIG.md
│ └── ADMIN_USER_MANAGEMENT.md
├── 💻 Приложение
│ ├── server.js
│ ├── database.js
│ └── public/
│ ├── index.html
│ ├── main.js (исправлен)
│ ├── game.js
│ ├── ai.js
│ ├── auth.js
│ └── styles.css
└── 💾 Данные (создаются автоматически)
├── data/poker.db
└── logs/
```
---
## 📊 Статистика
- **Всего файлов создано:** 12
- **Файлов обновлено:** 3
- **Строк документации:** ~3,500
- **Строк кода:** ~200
- **Размер Docker образа:** ~150 MB (Alpine + Node.js)
- **Порт:** 3336
- **Статус:** ✅ Production Ready
---
## 🎉 Резюме
Репозиторий **полностью подготовлен** к развертыванию в Docker:
**Docker конфигурация** - готова
**Apache настройка** - готова
**Автоматизация** - готова
**Документация** - готова
**Тестирование** - инструкции готовы
**Безопасность** - настроена
**Backup** - инструкции готовы
**Можно деплоить в production!** 🚀
---
**Дата:** 2026-02-01
**Версия:** 1.0.0
**Порт:** 3336
**Статус:** ✅ ГОТОВО

View File

@ -1,197 +0,0 @@
# Система поздравлений игрока от ботов
## 🎯 Описание
Боты теперь **поздравляют игрока**, когда он выигрывает с сильной покерной рукой! Вероятность поздравления зависит от редкости и силы руки.
---
## 📊 Вероятности поздравлений по силе руки
| Рука | Ранг | Вероятность | Пример |
|------|------|-------------|--------|
| **Роял-флеш** | 10 | 90% | "A♠ K♠ Q♠ J♠ 10♠ - Легенда!" |
| **Стрит-флеш** | 9 | 80% | "9♥ 8♥ 7♥ 6♥ 5♥ - Невероятно!" |
| **Каре** | 8 | 60% | "K♠ K♥ K♦ K♣ - Впечатляет!" |
| **Фулл-хаус** | 7 | 40% | "Q♠ Q♥ Q♦ 5♠ 5♥ - Красиво!" |
| **Флеш** | 6 | 25% | "A♠ J♠ 9♠ 7♠ 3♠ - Неплохо!" |
| **Стрит** | 5 | 15% | "10♦ 9♠ 8♥ 7♣ 6♦ - Хорошо!" |
| Сет и ниже | ≤4 | 0% | Не поздравляют |
---
## 🤖 Стили поздравлений от разных ботов
### 1. **Виктор "Акула"** (агрессивный)
- **Обычная рука:** "Неплохо сыграно!", "Уважаю!"
- **Сильная:** "Вау, фулл-хаус! 💪", "Монстр-рука!"
- **Очень сильная:** "Роял-флеш?! Респект! 🔥", "НЕВЕРОЯТНО!"
### 2. **Анна "Блефер"** (загадочная)
- **Обычная:** "Интересно сыграно... 😏"
- **Сильная:** "Фулл-хаус? Впечатляет! 👏"
- **Очень сильная:** "Стрит-флеш... Я в восторге! 😮"
### 3. **Дед Михалыч** (старая школа)
- **Обычная:** "Молодец, голубчик!"
- **Сильная:** "Фулл-хаус! Красота! 👴"
- **Очень сильная:** "Роял-флеш! За 50 лет такое редко видел! 😲"
### 4. **Макс "ГТО"** (математик)
- **Обычная:** "Хороший EV!"
- **Сильная:** "Фулл-хаус! Rare! 📊", "Топ 0.1% рук!"
- **Очень сильная:** "Роял-флеш... 0.00154%! 🤯"
### 5. **Катя "Удача"** (суеверная)
- **Обычная:** "Ура! Молодец! 😊"
- **Сильная:** "Фулл-хаус! Удача! 🍀✨"
- **Очень сильная:** "Роял-флеш! ЭТО МАГИЯ! 🎉🍀"
### 6. **Борис "Молчун"** (молчаливый)
- **Обычная:** "Хм. Неплохо."
- **Сильная:** "Фулл-хаус... Ого."
- **Очень сильная:** "Роял-флеш?! ..."
### 7. **Олег "Тильтер"** (эмоциональный)
- **Обычная:** "Ну везёт же... 😒"
- **Сильная:** "Фулл-хаус?! Ладно, красиво 😤"
- **Очень сильная:** "Роял-флеш... Я не верю! 😱"
### 8. **Ирина "Профи"** (профессионал)
- **Обычная:** "GG WP!"
- **Сильная:** "Фулл-хаус. Респект! 💎"
- **Очень сильная:** "Роял-флеш! Incredible! 🎯"
---
## 🎬 Примеры игровых ситуаций
### Ситуация 1: Флеш (25% вероятность)
```
Игрок выигрывает с: A♠ K♠ 9♠ 7♠ 3♠ (Флеш)
Банк: 250 фишек
🎲 Случайный бот (25% шанс):
Макс "ГТО": "Флеш! Статистически сильно! 📊"
```
### Ситуация 2: Фулл-хаус (40% вероятность)
```
Игрок выигрывает с: K♠ K♥ K♦ 8♠ 8♥ (Фулл-хаус)
Банк: 500 фишек
🎲 Случайный бот (40% шанс):
Дед Михалыч: "Фулл-хаус! Красота! Как в старые времена! 👴"
```
### Ситуация 3: Каре (60% вероятность)
```
Игрок выигрывает с: A♠ A♥ A♦ A♣ Q♠ (Каре тузов)
Банк: 800 фишек
🎲 Случайный бот (60% шанс):
Виктор "Акула": "Каре тузов! МОНСТР-РУКА! 💪🔥"
```
### Ситуация 4: Стрит-флеш (80% вероятность)
```
Игрок выигрывает с: 9♥ 8♥ 7♥ 6♥ 5♥ (Стрит-флеш)
Банк: 1200 фишек
🎲 Случайный бот (80% шанс):
Анна "Блефер": "Стрит-флеш до девятки... Я в восторге! 😮✨"
```
### Ситуация 5: Роял-флеш (90% вероятность)
```
Игрок выигрывает с: A♠ K♠ Q♠ J♠ 10♠ (Роял-флеш!)
Банк: 2000 фишек
🎲 Случайный бот (90% шанс):
Макс "ГТО": "РОЯЛ-ФЛЕШ! Математическое чудо! Вероятность 0.00154%! 🤯🎯"
```
---
## ⚙️ Технические детали
### Алгоритм работы:
1. **Проверка победителя**
- Игрок победил? (не бот)
- Есть информация о руке?
2. **Определение вероятности**
- Ранг руки ≥ 5 (стрит или выше)
- Вероятность от 15% до 90%
3. **Выбор бота**
- Случайный бот из активных (не спасовавших)
- Учитывается его личность
4. **Генерация поздравления**
- С помощью LLM (если включён)
- Или запасные фразы по стилю бота
5. **Отправка в чат**
- Задержка 1-2.5 секунды
- Не конфликтует с эмоциональными реакциями
### Интеграция с LLM:
При включенном LLM бот получает контекст:
```
Игрок [Имя] только что ВЫИГРАЛ с рукой "Фулл-хаус"!
Банк: 500 фишек
Это сильная рука!
Поздравь игрока КРАТКО (максимум 5-7 слов), в своём стиле.
```
---
## 🎮 Примеры с LLM (если включён)
**Виктор "Акула":**
- Флеш: "С флешем на терне? Красиво прочитал борд! 🔥"
- Каре: "Каре на ривере?! Вот это хладнокровие! 💪"
**Дед Михалыч:**
- Фулл-хаус: "Эх, молодость! С таким фулл-хаусом я бы в 82-м весь турнир взял!"
- Стрит-флеш: "Святые угодники! Такое в жизни раз видел!"
**Макс "ГТО":**
- Флеш: "Флеш на этой текстуре борда - +EV колл!"
- Каре: "Четыре аута превратились в каре. Probability magic! 📊"
---
## 📝 Файлы изменены
1. **`ai.js`**
- Добавлена `generatePlayerCongratulation()` - генерация поздравления через LLM
- Добавлена `getFallbackCongratulation()` - запасные фразы для каждого стиля
2. **`main.js`**
- Добавлена `generatePlayerCongratulations()` - логика выбора бота и отправки
- Вызывается в `onHandEnd()` после завершения раздачи
---
## 🎯 Результат
Теперь игра стала **более живой и реалистичной**:
- Боты замечают и ценят сильные руки игрока
- Редкие комбинации вызывают восхищение
- Каждый бот поздравляет в своём уникальном стиле
- LLM делает поздравления ещё более контекстуальными
**Пример полного взаимодействия:**
```
[Игрок собрал каре королей и выиграл банк 800 фишек]
Макс "ГТО": "Каре королей! Топ 0.024% рук! 📊" (через 1.2 сек)
```
🎰🃏 **Играйте и получайте заслуженные поздравления!**

View File

@ -1,439 +0,0 @@
<VirtualHost *:80>
ServerName poker.yourdomain.com
ServerAdmin admin@yourdomain.com
# Логи
ErrorLog ${APACHE_LOG_DIR}/poker_error.log
CustomLog ${APACHE_LOG_DIR}/poker_access.log combined
# Проксирование на Docker контейнер
ProxyPreserveHost On
ProxyPass / http://localhost:3336/
ProxyPassReverse / http://localhost:3336/
# WebSocket поддержка
RewriteEngine On
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule /(.*) ws://localhost:3336/$1 [P,L]
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
RewriteRule /(.*) http://localhost:3336/$1 [P,L]
# Заголовки для WebSocket
<Location />
ProxyPass http://localhost:3336/
ProxyPassReverse http://localhost:3336/
ProxyPreserveHost On
# WebSocket headers
RequestHeader set X-Forwarded-Proto "http"
RequestHeader set X-Forwarded-Port "80"
</Location>
</VirtualHost>
# SSL версия (если используете HTTPS)
<IfModule mod_ssl.c>
<VirtualHost *:443>
ServerName poker.yourdomain.com
ServerAdmin admin@yourdomain.com
# SSL сертификаты
SSLEngine on
SSLCertificateFile /etc/ssl/certs/poker.crt
SSLCertificateKeyFile /etc/ssl/private/poker.key
# SSLCertificateChainFile /etc/ssl/certs/chain.pem
# Логи
ErrorLog ${APACHE_LOG_DIR}/poker_ssl_error.log
CustomLog ${APACHE_LOG_DIR}/poker_ssl_access.log combined
# Проксирование на Docker контейнер
ProxyPreserveHost On
ProxyPass / http://localhost:3336/
ProxyPassReverse / http://localhost:3336/
# WebSocket поддержка
RewriteEngine On
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule /(.*) ws://localhost:3336/$1 [P,L]
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
RewriteRule /(.*) http://localhost:3336/$1 [P,L]
# Заголовки для WebSocket
<Location />
ProxyPass http://localhost:3336/
ProxyPassReverse http://localhost:3336/
ProxyPreserveHost On
# WebSocket headers
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Port "443"
</Location>
# Безопасность
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
</VirtualHost>
</IfModule>
# 🃏 Texas Hold'em Poker - Развертывание в Docker
## 📋 Содержание
- [Быстрый старт](#быстрый-старт)
- [Развертывание с Docker](#развертывание-с-docker)
- [Настройка Apache](#настройка-apache)
- [Переменные окружения](#переменные-окружения)
- [Backup и восстановление](#backup-и-восстановление)
---
## 🚀 Быстрый старт
### Запуск через Docker Compose (рекомендуется)
```bash
# Клонируйте репозиторий
git clone <repository-url>
cd onlinepocker
# Запустите контейнер
docker-compose up -d
# Проверьте статус
docker-compose ps
# Просмотр логов
docker-compose logs -f
```
Приложение будет доступно по адресу: `http://localhost:3336`
---
## 🐳 Развертывание с Docker
### Сборка Docker образа вручную
```bash
# Сборка образа
docker build -t texas-holdem-poker:latest .
# Запуск контейнера
docker run -d \
--name poker-app \
-p 3336:3336 \
-v $(pwd)/data:/app/data \
-e NODE_ENV=production \
-e PORT=3336 \
texas-holdem-poker:latest
# Проверка работы
docker ps
docker logs poker-app
```
### Управление контейнером
```bash
# Остановка
docker stop poker-app
# Запуск
docker start poker-app
# Перезапуск
docker restart poker-app
# Удаление
docker stop poker-app
docker rm poker-app
# Просмотр логов
docker logs -f poker-app
```
### Docker Compose команды
```bash
# Запуск в фоне
docker-compose up -d
# Остановка
docker-compose down
# Перезапуск
docker-compose restart
# Пересборка образа
docker-compose build
# Пересборка и запуск
docker-compose up -d --build
# Просмотр логов
docker-compose logs -f
# Остановка с удалением volumes
docker-compose down -v
```
---
## 🌐 Настройка Apache
### Включение необходимых модулей Apache
```bash
# Включаем модули для проксирования и WebSocket
sudo a2enmod proxy
sudo a2enmod proxy_http
sudo a2enmod proxy_wstunnel
sudo a2enmod rewrite
sudo a2enmod headers
sudo a2enmod ssl # Если используете HTTPS
# Перезапуск Apache
sudo systemctl restart apache2
```
### Копирование конфигурации
```bash
# Копируем конфиг в Apache
sudo cp apache-config.conf /etc/apache2/sites-available/poker.conf
# Редактируем домен (замените poker.yourdomain.com на ваш домен)
sudo nano /etc/apache2/sites-available/poker.conf
# Включаем сайт
sudo a2ensite poker.conf
# Проверяем конфигурацию
sudo apache2ctl configtest
# Перезагружаем Apache
sudo systemctl reload apache2
```
### Проверка статуса Apache
```bash
# Проверка статуса
sudo systemctl status apache2
# Просмотр логов ошибок
sudo tail -f /var/log/apache2/poker_error.log
# Просмотр логов доступа
sudo tail -f /var/log/apache2/poker_access.log
```
---
## ⚙️ Переменные окружения
### Основные переменные
| Переменная | Значение по умолчанию | Описание |
|------------|----------------------|----------|
| `PORT` | 3336 | Порт для запуска сервера |
| `NODE_ENV` | production | Режим работы (production/development) |
### Настройка в docker-compose.yml
```yaml
environment:
- NODE_ENV=production
- PORT=3336
```
### Настройка в Dockerfile
```dockerfile
ENV NODE_ENV=production
ENV PORT=3336
```
---
## 📦 Структура проекта после развертывания
```
onlinepocker/
├── data/ # База данных (создается автоматически)
│ └── poker.db
├── logs/ # Логи приложения (опционально)
├── public/ # Статические файлы
├── node_modules/ # Зависимости
├── server.js # Основной сервер
├── database.js # Работа с БД
├── package.json
├── Dockerfile # Docker образ
├── docker-compose.yml # Docker Compose конфигурация
├── .dockerignore # Исключения для Docker
└── apache-config.conf # Конфигурация Apache
```
---
## 💾 Backup и восстановление
### Создание резервной копии базы данных
```bash
# Остановка контейнера
docker-compose stop
# Копирование БД
cp data/poker.db data/poker.db.backup.$(date +%Y%m%d_%H%M%S)
# Запуск контейнера
docker-compose start
```
### Восстановление из резервной копии
```bash
# Остановка контейнера
docker-compose stop
# Восстановление БД
cp data/poker.db.backup.20260201_120000 data/poker.db
# Запуск контейнера
docker-compose start
```
### Автоматический backup (cron)
```bash
# Создаем скрипт backup
nano /opt/poker-backup.sh
```
```bash
#!/bin/bash
BACKUP_DIR="/opt/poker-backups"
DATE=$(date +%Y%m%d_%H%M%S)
# Создаем директорию для backup
mkdir -p $BACKUP_DIR
# Копируем БД
docker cp texas-holdem-poker:/app/data/poker.db $BACKUP_DIR/poker.db.$DATE
# Удаляем старые backup (старше 30 дней)
find $BACKUP_DIR -name "poker.db.*" -mtime +30 -delete
echo "Backup создан: poker.db.$DATE"
```
```bash
# Делаем скрипт исполняемым
chmod +x /opt/poker-backup.sh
# Добавляем в cron (каждый день в 3:00)
crontab -e
# Добавьте строку:
0 3 * * * /opt/poker-backup.sh >> /var/log/poker-backup.log 2>&1
```
---
## 🔧 Troubleshooting
### Проблемы с портами
```bash
# Проверка, не занят ли порт 3336
sudo netstat -tlnp | grep 3336
# Если порт занят, найти процесс
sudo lsof -i :3336
# Убить процесс
sudo kill -9 <PID>
```
### Проблемы с WebSocket
```bash
# Проверка модулей Apache
apache2ctl -M | grep proxy
# Должны быть включены:
# - proxy_module
# - proxy_http_module
# - proxy_wstunnel_module
```
### Проблемы с правами доступа
```bash
# Установка правильных прав на директорию data
sudo chown -R www-data:www-data data/
sudo chmod 755 data/
# Для Docker
sudo chown -R 1000:1000 data/
```
### Просмотр логов контейнера
```bash
# Все логи
docker logs texas-holdem-poker
# Последние 100 строк
docker logs --tail 100 texas-holdem-poker
# В реальном времени
docker logs -f texas-holdem-poker
```
---
## 🔐 Безопасность
### Рекомендации
1. **Используйте HTTPS** - настройте SSL сертификаты (Let's Encrypt)
2. **Firewall** - ограничьте доступ к порту 3336 только с localhost
3. **Регулярные обновления** - обновляйте зависимости и Docker образы
4. **Backup** - настройте автоматическое резервное копирование
5. **Мониторинг** - отслеживайте логи и производительность
### Настройка Let's Encrypt (опционально)
```bash
# Установка certbot
sudo apt-get install certbot python3-certbot-apache
# Получение сертификата
sudo certbot --apache -d poker.yourdomain.com
# Автоматическое обновление
sudo certbot renew --dry-run
```
---
## 📞 Поддержка
Если возникли проблемы:
1. Проверьте логи: `docker-compose logs -f`
2. Проверьте статус: `docker-compose ps`
3. Проверьте Apache: `sudo systemctl status apache2`
4. Проверьте логи Apache: `sudo tail -f /var/log/apache2/poker_error.log`
---
## 📝 Лицензия
Этот проект предназначен для личного использования.
---
**Версия:** 1.0.0
**Дата обновления:** 2026-02-01

View File

@ -1,133 +0,0 @@
# 🐳 Docker Quick Start
## Быстрый запуск
### Windows (PowerShell)
```powershell
.\deploy.ps1
```
### Linux/Mac (Bash)
```bash
chmod +x deploy.sh
./deploy.sh
```
### Ручной запуск
```bash
# Сборка и запуск
docker-compose up -d
# Просмотр логов
docker-compose logs -f
# Остановка
docker-compose down
```
## Доступ к приложению
После запуска приложение будет доступно по адресу:
- **http://localhost:3336**
## Полезные команды
```bash
# Просмотр статуса
docker-compose ps
# Просмотр логов
docker-compose logs -f
# Перезапуск
docker-compose restart
# Остановка
docker-compose stop
# Запуск после остановки
docker-compose start
# Полная остановка с удалением контейнеров
docker-compose down
# Пересборка образа
docker-compose up -d --build
```
## NPM скрипты
```bash
# Сборка Docker образа
npm run docker:build
# Запуск контейнера
npm run docker:up
# Остановка контейнера
npm run docker:down
# Просмотр логов
npm run docker:logs
# Перезапуск
npm run docker:restart
```
## Структура данных
```
data/ # База данных SQLite (автоматически создается)
logs/ # Логи приложения (опционально)
```
## Настройка Apache
Подробная инструкция по настройке Apache для проксирования находится в файле `DEPLOYMENT.md`.
Основные шаги:
1. Включить модули Apache: `proxy`, `proxy_http`, `proxy_wstunnel`, `rewrite`
2. Скопировать `apache-config.conf` в `/etc/apache2/sites-available/`
3. Включить сайт: `sudo a2ensite poker.conf`
4. Перезагрузить Apache: `sudo systemctl reload apache2`
## Порты
- **Внутренний порт контейнера**: 3336
- **Внешний порт**: 3336
- **Apache проксирует** запросы на localhost:3336
## Troubleshooting
### Порт занят
```bash
# Проверка занятости порта
netstat -ano | findstr :3336 # Windows
lsof -i :3336 # Linux/Mac
# Остановка существующего контейнера
docker-compose down
```
### Проблемы с правами доступа (Linux)
```bash
sudo chown -R $USER:$USER data/
sudo chmod 755 data/
```
### Просмотр детальных логов
```bash
docker-compose logs --tail=100 poker-app
```
## Документация
- **Полная инструкция по развертыванию**: `DEPLOYMENT.md`
- **Настройка системных промптов ботов**: `BOT_PERSONALITIES_CONFIG.md`
- **Управление пользователями**: `ADMIN_USER_MANAGEMENT.md`
---
**Версия**: 1.0.0
**Порт**: 3336
**Docker**: ✅ Готово к использованию

View File

@ -1,281 +0,0 @@
# 🐳 Подготовка к Docker развертыванию - Сводка изменений
## ✅ Созданные файлы
### Docker конфигурация
1. **`Dockerfile`**
- Основан на Node.js 18 Alpine
- Порт: 3336
- Автоматическая установка зависимостей
- Production режим
2. **`docker-compose.yml`**
- Сервис: poker-app
- Порт маппинг: 3336:3336
- Volumes для данных и логов
- Автоматический рестарт
3. **`.dockerignore`**
- Исключение node_modules
- Исключение баз данных
- Исключение логов и временных файлов
### Apache конфигурация
4. **`apache-config.conf`**
- HTTP (порт 80) → localhost:3336
- HTTPS (порт 443) → localhost:3336
- WebSocket поддержка (proxy_wstunnel)
- Заголовки безопасности
- Готовые настройки SSL
### Скрипты развертывания
5. **`deploy.sh`** (Linux/Mac)
- Проверка Docker и Docker Compose
- Создание директорий
- Автоматическая сборка и запуск
- Проверка статуса
6. **`deploy.ps1`** (Windows PowerShell)
- Аналог deploy.sh для Windows
- Цветной вывод
- Проверка всех зависимостей
### Документация
7. **`DEPLOYMENT.md`**
- Полное руководство по развертыванию
- Инструкции для Docker
- Настройка Apache
- Backup и восстановление
- Troubleshooting
- Безопасность
8. **`DOCKER_README.md`**
- Краткая инструкция по Docker
- Основные команды
- NPM скрипты
- Quick start
9. **`README.md`**
- Обновленный главный README
- Информация о Docker
- Полный список функций
- Структура проекта
- Troubleshooting
### Дополнительные файлы
10. **`.env.example`**
- Пример переменных окружения
- PORT=3336
- NODE_ENV=production
11. **`.gitignore`**
- Обновлен для Docker
- Исключение data/ и logs/
- Исключение .env файлов
---
## 🔧 Измененные файлы
### 1. `package.json`
**Добавлены npm скрипты:**
```json
"docker:build": "docker-compose build",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down",
"docker:logs": "docker-compose logs -f",
"docker:restart": "docker-compose restart"
```
### 2. `public/main.js`
**Исправлена работа с токенами:**
- Изменено `localStorage.getItem('token')` на `localStorage.getItem('authToken')`
- Синхронизировано с auth.js
- Исправлены функции: loadUsers(), saveUserEdit(), toggleBanUser(), deleteUser()
---
## 📋 Структура Docker развертывания
```
Приложение (порт 3336)
Docker контейнер (texas-holdem-poker)
Docker Compose (управление)
Apache (reverse proxy)
Интернет (порт 80/443)
```
---
## 🚀 Способы запуска
### 1. Автоматический (рекомендуется)
**Windows:**
```powershell
.\deploy.ps1
```
**Linux/Mac:**
```bash
chmod +x deploy.sh
./deploy.sh
```
### 2. Docker Compose
```bash
docker-compose up -d
```
### 3. NPM скрипты
```bash
npm run docker:build
npm run docker:up
```
### 4. Docker вручную
```bash
docker build -t texas-holdem-poker .
docker run -d -p 3336:3336 --name poker-app texas-holdem-poker
```
---
## 🌐 Порты
- **3336** - Основной порт приложения (Docker)
- **80** - HTTP через Apache (опционально)
- **443** - HTTPS через Apache (опционально)
**Важно:** Порт изменен с 3000 на 3336 согласно требованию
---
## 📦 Volumes
```yaml
volumes:
- ./data:/app/data # База данных SQLite
- ./logs:/app/logs # Логи приложения
```
Данные сохраняются на хосте, даже при удалении контейнера.
---
## 🔐 Переменные окружения
В `docker-compose.yml`:
```yaml
environment:
- NODE_ENV=production
- PORT=3336
```
В `Dockerfile`:
```dockerfile
ENV NODE_ENV=production
ENV PORT=3336
```
---
## 📚 Документация
| Файл | Описание |
|------|----------|
| `README.md` | Главная документация проекта |
| `DEPLOYMENT.md` | Полное руководство по развертыванию |
| `DOCKER_README.md` | Краткая инструкция Docker |
| `BOT_PERSONALITIES_CONFIG.md` | Настройка ботов |
| `ADMIN_USER_MANAGEMENT.md` | Управление пользователями |
---
## ✅ Контрольный список готовности
- [x] Dockerfile создан
- [x] docker-compose.yml настроен
- [x] .dockerignore настроен
- [x] Apache конфигурация готова
- [x] Скрипты развертывания (Linux & Windows)
- [x] Документация обновлена
- [x] npm скрипты добавлены
- [x] .gitignore обновлен
- [x] Порт изменен на 3336
- [x] Переменные окружения настроены
- [x] Volumes для данных настроены
- [x] WebSocket поддержка в Apache
- [x] SSL конфигурация готова
- [x] Исправлена работа с токенами
---
## 🎯 Следующие шаги
1. **Протестировать локально:**
```bash
docker-compose up -d
```
2. **Открыть в браузере:**
```
http://localhost:3336
```
3. **Проверить логи:**
```bash
docker-compose logs -f
```
4. **Настроить Apache** (если нужно):
```bash
sudo cp apache-config.conf /etc/apache2/sites-available/poker.conf
sudo a2ensite poker.conf
sudo systemctl reload apache2
```
5. **Создать администратора:**
- Зарегистрироваться через интерфейс
- Выполнить SQL: `UPDATE users SET role = 'admin' WHERE username = 'ваш_логин';`
---
## 📞 Команды для проверки
```bash
# Проверка статуса
docker-compose ps
# Просмотр логов
docker-compose logs -f
# Проверка порта
netstat -ano | findstr :3336 # Windows
lsof -i :3336 # Linux/Mac
# Проверка Apache модулей
apache2ctl -M | grep proxy
# Тест подключения
curl http://localhost:3336
```
---
## 🎉 Готово!
Репозиторий полностью подготовлен к развертыванию в Docker на порту **3336** с поддержкой Apache reverse proxy.
**Дата:** 2026-02-01
**Версия:** 1.0.0
**Статус:** ✅ Production Ready

View File

@ -1,27 +0,0 @@
# Используем официальный образ Node.js
FROM node:18-alpine
# Устанавливаем рабочую директорию
WORKDIR /app
# Копируем package.json и package-lock.json
COPY package*.json ./
# Устанавливаем зависимости
RUN npm install --production
# Копируем все файлы приложения
COPY . .
# Создаем директорию для базы данных
RUN mkdir -p /app/data
# Открываем порт 3336
EXPOSE 3336
# Устанавливаем переменные окружения
ENV NODE_ENV=production
ENV PORT=3336
# Запускаем приложение
CMD ["node", "server.js"]

View File

@ -1,128 +0,0 @@
# Пример контекста для LLM
## Что теперь видит бот при ответе на сообщение игрока
### Пример 1: Флоп в игре
**Игрок пишет:** "Виктор, что думаешь?"
**LLM получает следующий контекст:**
```
📍 Фаза: флоп (3 карты на столе)
🃏 Карты на столе: K♥ 9♦ 2♣
💰 Банк: 150 фишек
💵 Текущая ставка: 30 фишек
🤖 Мои данные:
- Имя: Виктор "Акула"
- Карты: A♠ K♠
- Фишки: 470
- Моя ставка: 30 (всего в раздаче: 45)
- Позиция: дилер
👥 Другие игроки:
- Игрок: 480 фишек, ставка 30 🎲 [последнее: call]
- Анна "Блефер": 350 фишек [спасовал ❌]
- Дед Михалыч: 200 фишек [последнее: fold]
✅ Активные: Виктор "Акула", Игрок
❌ Спасовали: Анна "Блефер", Дед Михалыч
💬 Игрок написал: "Виктор, что думаешь?"
```
**Возможный ответ бота:**
"С топ-парой на таком сухом флопе? Думаю, что банк мой. 😎"
---
### Пример 2: Терн, напряжённая ситуация
**Игрок пишет:** "Макс, всё плохо?"
**LLM получает:**
```
📍 Фаза: терн (4 карты на столе)
🃏 Карты на столе: 7♥ 8♦ 9♣ J♠
💰 Банк: 450 фишек
💵 Текущая ставка: 100 фишек
🤖 Мои данные:
- Имя: Макс "ГТО"
- Карты: A♣ K♣
- Фишки: 280
- Моя ставка: 50 (всего в раздаче: 165)
- Позиция: обычная позиция
👥 Другие игроки:
- Игрок: 520 фишек, ставка 100 [последнее: raise]
- Борис "Молчун": 150 фишек [спасовал ❌]
✅ Активные: Макс "ГТО", Игрок
❌ Спасовали: Борис "Молчун"
💬 Игрок написал: "Макс, всё плохо?"
```
**Возможный ответ бота:**
"На этом борде с прямым дро? Просчитываю pot odds... 🤔"
---
### Пример 3: All-in ситуация
**Игрок пишет:** "Катя, рискнёшь?"
**LLM получает:**
```
📍 Фаза: ривер (5 карт на столе)
🃏 Карты на столе: 3♥ 3♦ 8♣ Q♠ A♥
💰 Банк: 800 фишек
💵 Текущая ставка: 200 фишек
🤖 Мои данные:
- Имя: Катя "Удача"
- Карты: 3♠ 8♠
- Фишки: 195
- Моя ставка: 0 (всего в раздаче: 105)
- Позиция: малый блайнд
👥 Другие игроки:
- Игрок: 95 фишек, ставка 200 [ва-банк 💥]
✅ Активные: Катя "Удача", Игрок
❌ Спасовали:
💬 Игрок написал: "Катя, рискнёшь?"
```
**Возможный ответ бота:**
"С фулл-хаусом? Загадала желание! 🍀✨"
---
## Преимущества нового контекста
1. **Видит свои карты** - может блефовать или говорить честно
2. **Видит карты на столе** - может комментировать текстуру борда
3. **Видит действия других игроков** - может реагировать на чужие ставки
4. **Знает размер банка и ставок** - может говорить про pot odds
5. **Знает фазу игры** - может адаптировать ответ под префлоп/флоп/терн/ривер
6. **Видит кто спасовал** - может подшучивать или сочувствовать
## Примеры улучшенных ответов
### До изменений:
- Игрок: "Что думаешь?"
- Бот: "Удачи! Интересная игра."
### После изменений:
- Игрок: "Что думаешь?"
- Бот: "С тремя червами на столе и у меня флеш дро? Думаю поплыть дальше. 😏"
---
Теперь боты отвечают КОНТЕКСТУАЛЬНО, используя реальную информацию о раздаче!

View File

@ -1,155 +0,0 @@
# Отображение баланса игрока
## ✅ Исправлено
Теперь игрок **видит свой баланс фишек** прямо под картами!
---
## 🎯 Что добавлено
### 1. **Визуальное отображение баланса**
На экране игры появилась **панель информации о игроке**:
```
┌────────────────────────────┐
│ Игрок 💰 1000 │ ← Имя и баланс
│ [K♠] [Q♥] │ ← Карты
│ Две старших карты │ ← Сила руки
└────────────────────────────┘
```
**Элементы:**
- **Имя игрока** - слева
- **💰 Баланс** - справа (зелёная подсветка)
- **Карты** - по центру
- **Сила руки** - снизу
---
## 🎨 Визуальные фичи
### **Анимация изменения баланса:**
1. **При увеличении фишек** (выигрыш):
- Зелёная вспышка
- Увеличение на 5%
- 0.5 секунды
2. **При уменьшении фишек** (ставка/проигрыш):
- Красная вспышка
- Уменьшение на 5%
- 0.5 секунды
**Пример:**
```
Выигрыш +500: 💰 500 → 💰 1000 (зелёная вспышка ✨)
Ставка -100: 💰 1000 → 💰 900 (красная вспышка 🔴)
```
---
## 💻 Технические детали
### **Файлы изменены:**
#### 1. `index.html`
Добавлена строка информации о игроке:
```html
<div class="player-info-row">
<div class="player-name-display" id="player-name-display">Игрок</div>
<div class="player-balance-display">
<span class="balance-icon">💰</span>
<span class="balance-amount" id="player-balance">1000</span>
</div>
</div>
```
#### 2. `styles.css`
Добавлены стили:
- `.player-info-row` - контейнер для имени и баланса
- `.player-name-display` - стиль имени
- `.player-balance-display` - значок и сумма
- `@keyframes balanceIncrease` - анимация увеличения
- `@keyframes balanceDecrease` - анимация уменьшения
#### 3. `main.js`
Добавлена функция `updatePlayerBalance()`:
```javascript
function updatePlayerBalance(player) {
// Обновляет имя и баланс
// Добавляет анимацию при изменении
// Отслеживает предыдущее значение
}
```
Интеграция в:
- `updateGameUI()` - одиночная игра
- `updateGameUIFromServer()` - мультиплеер
---
## 🎮 Примеры в действии
### **Префлоп:**
```
┌────────────────────────────┐
│ Игрок 💰 1000 │
│ [A♠] [K♠] │
│ Старшие карты │
└────────────────────────────┘
Ставка Big Blind 10 →
┌────────────────────────────┐
│ Игрок 💰 990 🔴 │ ← Красная вспышка
│ [A♠] [K♠] │
│ Старшие карты │
└────────────────────────────┘
```
### **Выигрыш банка:**
```
Банк: 500 фишек
Победа с флешем!
┌────────────────────────────┐
│ Игрок 💰 1490 ✨ │ ← Зелёная вспышка
│ [A♠] [K♠] │
│ Флеш, туз старший │
└────────────────────────────┘
```
### **All-in:**
```
┌────────────────────────────┐
│ Игрок 💰 0 🔴 │ ← Баланс 0 после all-in
│ [Q♥] [Q♦] │
│ Пара дам │
└────────────────────────────┘
```
---
## 📊 Отличия от ботов
| Элемент | Бот (на столе) | Игрок (снизу) |
|---------|----------------|---------------|
| Имя | Маленькое, внутри бокса | Крупное, отдельная строка |
| Баланс | Под именем, серый текст | Отдельный блок, зелёный, с иконкой |
| Карты | Мини (40x56px) | Большие (70x98px) |
| Анимация | Нет | Есть (при изменении) |
| Позиция | По кругу стола | Внизу по центру |
---
## ✅ Результат
Теперь игрок **всегда видит**:
- ✅ Своё имя
- ✅ Актуальный баланс фишек
- ✅ Визуальную обратную связь при выигрыше/проигрыше
- ✅ Красивую анимацию изменения баланса
🎰💰 **Следите за своим стеком!**

342
README.md
View File

@ -1,342 +1,2 @@
# 🃏 Texas Hold'em No-Limit Poker
# poker
Полнофункциональная онлайн-игра в покер с поддержкой мультиплеера, ИИ-ботами и интеграцией LLM для реалистичного общения.
## ✨ Особенности
- 🎮 **Мультиплеер**: 2-6 игроков в режиме реального времени
- 🤖 **ИИ-боты**: 8 уникальных личностей с разными стилями игры
- 💬 **LLM-чат**: Реалистичное общение ботов через Ollama/LM Studio/OpenAI
- 👤 **Система авторизации**: Регистрация, вход, гостевой режим
- 👑 **Админ-панель**: Управление ботами, пользователями и настройками
- 🎨 **Современный UI**: Glassmorphism дизайн с анимациями
- 🐳 **Docker**: Готов к развертыванию в один клик
## 🚀 Быстрый старт
### Вариант 1: Docker (Рекомендуется)
**Windows (PowerShell):**
```powershell
.\deploy.ps1
```
**Linux/Mac:**
```bash
chmod +x deploy.sh
./deploy.sh
```
**Или вручную:**
```bash
docker-compose up -d
```
Приложение будет доступно: **http://localhost:3336**
### Вариант 2: Локальный запуск
```bash
# Установка зависимостей
npm install
# Запуск сервера
npm start
```
Приложение будет доступно: **http://localhost:3000**
## 📋 Требования
### Docker (рекомендуется)
- Docker 20.10+
- Docker Compose 1.29+
### Локальный запуск
- Node.js 16+
- npm 8+
## 🎮 Игровые режимы
### 🎯 Одиночная игра
- Игра против 1-5 ИИ-ботов
- 3 уровня сложности ИИ
- 8 уникальных личностей ботов
- Настраиваемые системные промпты
### 🌐 Мультиплеер
- Создание приватных комнат
- Поделиться ссылкой для присоединения
- Чат в реальном времени
- До 6 игроков одновременно
### 🤖 ИИ-боты с личностями
1. **Виктор "Акула"** - Агрессивный профессионал
2. **Анна "Блефер"** - Мастер обмана
3. **Сергей "Математик"** - Аналитик-стратег
4. **Мария "Рок"** - Консервативный игрок
5. **Дмитрий "Маньяк"** - Непредсказуемый агрессор
6. **Елена "Читательница"** - Психолог за столом
7. **Игорь "Новичок"** - Неопытный оптимист
8. **Ольга "Тильт"** - Эмоциональный игрок
## 👑 Админ-панель
### Функции администратора:
- **⚙️ Настройки LLM**: Подключение Ollama/LM Studio/OpenAI
- **🤖 Промпты ботов**: Настройка личностей и поведения
- **📋 Логи действий**: Мониторинг активности пользователей
- **👥 Управление пользователями**:
- Просмотр всех пользователей
- Статистика (IP, последняя игра)
- Изменение ролей (admin/user)
- Блокировка пользователей
- Удаление аккаунтов
### Первый администратор
При первом запуске создайте админа:
```sql
-- Используйте любой SQLite клиент для изменения БД
UPDATE users SET role = 'admin' WHERE username = 'ваш_логин';
```
## 🐳 Docker развертывание
### Структура проекта
```
onlinepocker/
├── 📄 Dockerfile # Docker образ
├── 📄 docker-compose.yml # Docker Compose конфигурация
├── 📄 .dockerignore # Исключения для Docker
├── 📄 apache-config.conf # Настройка Apache
├── 📄 deploy.sh # Скрипт развертывания (Linux/Mac)
├── 📄 deploy.ps1 # Скрипт развертывания (Windows)
├── 📚 DEPLOYMENT.md # Полная документация
└── 📚 DOCKER_README.md # Краткая инструкция Docker
```
### Основные команды
```bash
# Запуск
docker-compose up -d
# Остановка
docker-compose down
# Просмотр логов
docker-compose logs -f
# Перезапуск
docker-compose restart
# Пересборка
docker-compose up -d --build
```
### NPM скрипты
```bash
npm run docker:build # Сборка образа
npm run docker:up # Запуск контейнера
npm run docker:down # Остановка контейнера
npm run docker:logs # Просмотр логов
npm run docker:restart # Перезапуск
```
## 🌐 Настройка Apache
Для проксирования через Apache (порт 80/443 → 3336):
```bash
# Включение модулей
sudo a2enmod proxy proxy_http proxy_wstunnel rewrite headers ssl
# Копирование конфигурации
sudo cp apache-config.conf /etc/apache2/sites-available/poker.conf
# Включение сайта
sudo a2ensite poker.conf
# Перезагрузка Apache
sudo systemctl reload apache2
```
Подробнее: `DEPLOYMENT.md`
## 🔧 Конфигурация
### Переменные окружения
| Переменная | По умолчанию | Описание |
|------------|--------------|----------|
| `PORT` | 3336 | Порт сервера |
| `NODE_ENV` | production | Режим работы |
### Настройка LLM (в админ-панели)
1. Выберите провайдера: Ollama / LM Studio / OpenAI
2. Укажите API URL (например, `http://localhost:11434`)
3. Выберите модель (например, `llama3.2`)
4. Сохраните настройки
### Настройка промптов ботов
1. Войдите как администратор
2. Откройте "🤖 Промпты ботов"
3. Выберите бота
4. Отредактируйте системный промпт
5. Сохраните изменения
## 📦 Технологии
### Backend
- **Node.js** - Серверная платформа
- **Express.js** - REST API
- **WebSocket (ws)** - Реальное время
- **SQLite (sql.js)** - База данных
- **bcryptjs** - Хеширование паролей
- **jsonwebtoken** - JWT авторизация
### Frontend
- **Vanilla JavaScript** - Без фреймворков
- **CSS3** - Glassmorphism дизайн
- **HTML5** - Семантическая разметка
### DevOps
- **Docker** - Контейнеризация
- **Docker Compose** - Оркестрация
- **Apache** - Reverse proxy
## 📁 Структура проекта
```
onlinepocker/
├── 📂 public/ # Фронтенд
│ ├── index.html # Главная страница
│ ├── main.js # Основная логика
│ ├── game.js # Игровая логика
│ ├── ai.js # ИИ и личности ботов
│ ├── auth.js # Авторизация
│ └── styles.css # Стили
├── 📂 data/ # База данных (создается автоматически)
│ └── poker.db
├── server.js # WebSocket сервер
├── database.js # Работа с БД
├── package.json # Зависимости
├── Dockerfile # Docker образ
├── docker-compose.yml # Docker Compose
└── 📚 Документация
├── DEPLOYMENT.md # Развертывание
├── DOCKER_README.md # Docker инструкции
├── BOT_PERSONALITIES_CONFIG.md
└── ADMIN_USER_MANAGEMENT.md
```
## 🔐 Безопасность
- ✅ JWT токены с истечением
- ✅ Bcrypt хеширование паролей
- ✅ Middleware авторизации
- ✅ Проверка прав администратора
- ✅ Логирование действий
- ✅ Блокировка пользователей
- ✅ IP tracking
## 💾 Backup базы данных
### Ручной backup
```bash
# Docker
docker cp texas-holdem-poker:/app/data/poker.db ./backup-$(date +%Y%m%d).db
# Локально
cp data/poker.db data/poker.db.backup
```
### Автоматический backup (cron)
```bash
# Каждый день в 3:00
0 3 * * * /opt/poker-backup.sh
```
Подробнее: `DEPLOYMENT.md`
## 📖 Документация
- **[DEPLOYMENT.md](DEPLOYMENT.md)** - Полное руководство по развертыванию
- **[DOCKER_README.md](DOCKER_README.md)** - Быстрый старт с Docker
- **[BOT_PERSONALITIES_CONFIG.md](BOT_PERSONALITIES_CONFIG.md)** - Настройка ботов
- **[ADMIN_USER_MANAGEMENT.md](ADMIN_USER_MANAGEMENT.md)** - Управление пользователями
## 🐛 Troubleshooting
### Порт занят
```bash
# Проверка
netstat -ano | findstr :3336 # Windows
lsof -i :3336 # Linux/Mac
# Решение
docker-compose down
```
### Проблемы с WebSocket
```bash
# Проверка модулей Apache
apache2ctl -M | grep proxy
```
### Права доступа (Linux)
```bash
sudo chown -R $USER:$USER data/
sudo chmod 755 data/
```
## 📞 Поддержка
1. Проверьте логи: `docker-compose logs -f`
2. Проверьте статус: `docker-compose ps`
3. Прочитайте документацию: `DEPLOYMENT.md`
## 📝 Лицензия
MIT License - свободно для личного использования
---
## 🎯 Краткая инструкция
### Для локальной разработки
```bash
npm install
npm start
# Откройте http://localhost:3000
```
### Для production (Docker)
```bash
docker-compose up -d
# Откройте http://localhost:3336
```
### Создание администратора
```bash
# 1. Зарегистрируйтесь через интерфейс
# 2. Подключитесь к БД и выполните:
UPDATE users SET role = 'admin' WHERE username = 'ваш_логин';
```
---
**Версия:** 1.0.0
**Дата:** 2026-02-01
**Статус:** ✅ Готов к production
🃏 **Хорошей игры!**

View File

@ -1,76 +0,0 @@
# 🔗 Руководство по шарингу комнат
## Как пригласить друзей в игру
### Способ 1: Копирование ссылки
1. Создайте комнату или зайдите в существующую
2. В лобби комнаты нажмите на кнопку **📋** рядом со ссылкой
3. Отправьте скопированную ссылку друзьям любым удобным способом
### Способ 2: Прямой шаринг в Telegram
1. В лобби комнаты нажмите кнопку **📱 Telegram**
2. Выберите чат или контакт для отправки
3. Ваш друг получит ссылку с приглашением
### Способ 3: Прямой шаринг в WhatsApp
1. В лобби комнаты нажмите кнопку **💬 WhatsApp**
2. Выберите чат или контакт для отправки
3. Ваш друг получит ссылку с приглашением
### Способ 4: Универсальный шаринг (на мобильных устройствах)
1. В лобби комнаты нажмите кнопку **🔗 Поделиться**
2. Выберите приложение для отправки (доступно на Android/iOS)
3. Отправьте ссылку через выбранное приложение
## Как присоединиться по ссылке
### Вариант A: Автоматическое присоединение
1. Перейдите по полученной ссылке
2. Войдите в игру или зарегистрируйтесь
3. Вы автоматически присоединитесь к комнате
### Вариант B: Ручное присоединение
1. Войдите в игру
2. Перейдите в раздел "Мультиплеер"
3. Найдите комнату в списке и присоединитесь
## Формат ссылки
Ссылка на комнату имеет вид:
```
http://localhost:3000/?room=room_1234567890_abc123
```
Где `room_1234567890_abc123` - уникальный ID комнаты.
## Особенности
- ✅ Ссылка работает до тех пор, пока комната активна
- ✅ По одной ссылке может присоединиться несколько игроков
- ✅ Комната закрывается, когда все игроки выходят
- ✅ Максимальное количество игроков устанавливается при создании комнаты
## Советы
1. **Быстрое копирование**: Кликните на поле со ссылкой - текст выделится автоматически
2. **Проверка ссылки**: Ссылка обновляется автоматически при изменении комнаты
3. **Мобильные устройства**: Используйте кнопку "Поделиться" для быстрого шаринга
4. **Telegram/WhatsApp**: Работает как на десктопе, так и на мобильных устройствах
## Техническая информация
### Поддерживаемые браузеры
- Chrome/Edge: все функции
- Firefox: все функции
- Safari (iOS): все функции включая Web Share API
- Мобильные браузеры: полная поддержка
### API
- **Clipboard API** - для копирования ссылки
- **Web Share API** - для универсального шаринга (мобильные устройства)
- **URL Parameters** - для передачи ID комнаты
### Безопасность
- ID комнаты генерируется на сервере
- Невозможно подделать или угадать ID
- Комната доступна только активным пользователям

View File

@ -1,206 +0,0 @@
# 🧪 Тестирование Docker развертывания
## Перед запуском
Убедитесь, что установлены:
- Docker Desktop (Windows/Mac) или Docker Engine (Linux)
- Docker Compose
## Быстрый тест
### Windows
```powershell
# Проверка версии Docker
docker --version
# Проверка Docker Compose
docker-compose --version
# Запуск приложения
.\deploy.ps1
# Или
docker-compose up -d
```
### Linux/Mac
```bash
# Проверка версии Docker
docker --version
# Проверка Docker Compose
docker-compose --version
# Запуск приложения
chmod +x deploy.sh
./deploy.sh
# Или
docker-compose up -d
```
## Проверка работы
1. **Открыть в браузере:**
```
http://localhost:3336
```
2. **Проверить логи:**
```bash
docker-compose logs -f
```
3. **Проверить статус:**
```bash
docker-compose ps
```
Должно быть:
```
NAME STATUS PORTS
texas-holdem-poker Up 0.0.0.0:3336->3336/tcp
```
4. **Проверить порт:**
```bash
# Windows
netstat -ano | findstr :3336
# Linux/Mac
lsof -i :3336
```
## Тестовые сценарии
### 1. Регистрация и вход
- Откройте http://localhost:3336
- Зарегистрируйте нового пользователя
- Войдите в систему
- Проверьте, что токен сохранился
### 2. Создание игры
- Создайте одиночную игру
- Добавьте ботов
- Проверьте работу игры
### 3. Админ панель (если есть доступ)
- Войдите как администратор
- Откройте админ панель
- Проверьте вкладку "Пользователи"
- Проверьте логи
### 4. WebSocket
- Создайте мультиплеер комнату
- Скопируйте ссылку
- Откройте в другой вкладке
- Проверьте синхронизацию
## Остановка и очистка
```bash
# Остановка
docker-compose stop
# Остановка и удаление контейнеров
docker-compose down
# Полная очистка (включая volumes)
docker-compose down -v
# Удаление образов
docker rmi texas-holdem-poker
```
## Troubleshooting
### Порт 3336 занят
```bash
# Остановите существующие контейнеры
docker-compose down
# Или измените порт в docker-compose.yml
ports:
- "3337:3336" # Используйте другой внешний порт
```
### Контейнер не запускается
```bash
# Просмотрите логи
docker-compose logs
# Пересоберите образ
docker-compose build --no-cache
docker-compose up -d
```
### База данных не создается
```bash
# Проверьте права доступа к директории data
ls -la data/
# Создайте директорию вручную
mkdir -p data
chmod 755 data
```
### WebSocket не работает
- Проверьте, что браузер поддерживает WebSocket
- Откройте консоль разработчика (F12)
- Проверьте наличие ошибок подключения
## Ожидаемый результат
После успешного запуска вы должны увидеть:
1. **В консоли:**
```
✅ Сервер успешно запущен!
📍 Сервер доступен по адресу: http://localhost:3336
```
2. **В браузере:**
- Страница авторизации загружается
- Стили применены корректно
- Анимации работают
3. **В логах Docker:**
```bash
docker-compose logs -f
```
```
🃏 Texas Hold'em Poker Server 🃏
Сервер запущен на http://localhost:3336
```
4. **Проверка WebSocket:**
- Откройте консоль браузера (F12)
- Должны быть сообщения о подключении WebSocket
- Нет ошибок 404 или 500
## Следующие шаги
После успешного тестирования:
1. **Настройте Apache** (опционально):
- Следуйте инструкциям в `DEPLOYMENT.md`
- Настройте SSL сертификаты
2. **Создайте администратора:**
```sql
UPDATE users SET role = 'admin' WHERE username = 'ваш_логин';
```
3. **Настройте LLM** (опционально):
- Откройте админ панель
- Настройте Ollama/LM Studio/OpenAI
4. **Настройте backup:**
- Следуйте инструкциям в `DEPLOYMENT.md`
- Настройте cron задачу
---
**Статус:** ✅ Готово к тестированию
**Порт:** 3336
**Дата:** 2026-02-01

View File

View File

@ -1,735 +0,0 @@
/**
* =============================================================================
* Texas Hold'em - Модуль базы данных (SQLite)
* =============================================================================
*/
const initSqlJs = require('sql.js');
const fs = require('fs');
const path = require('path');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
// Путь к файлу БД
const DB_PATH = path.join(__dirname, 'poker.db');
const JWT_SECRET = process.env.JWT_SECRET || 'poker-secret-key-change-in-production';
const JWT_EXPIRES = '7d';
let db = null;
/**
* Инициализация базы данных
*/
async function initDatabase() {
const SQL = await initSqlJs();
// Загружаем существующую БД или создаём новую
if (fs.existsSync(DB_PATH)) {
const buffer = fs.readFileSync(DB_PATH);
db = new SQL.Database(buffer);
console.log('📦 База данных загружена');
} else {
db = new SQL.Database();
console.log('📦 Создана новая база данных');
}
// Создаём таблицы
createTables();
// Создаём админа по умолчанию
await createDefaultAdmin();
// Всегда проверяем и инициализируем админские настройки
initDefaultAdminSettings();
return db;
}
/**
* Создание таблиц
*/
function createTables() {
// Пользователи
db.run(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'user',
banned INTEGER DEFAULT 0,
ip_address TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
last_login TEXT,
total_games INTEGER DEFAULT 0,
total_wins INTEGER DEFAULT 0,
total_chips_won INTEGER DEFAULT 0
)
`);
// Миграция: добавляем поля banned и ip_address если их нет
try {
const columns = db.exec(`PRAGMA table_info(users)`);
const columnNames = columns[0]?.values.map(col => col[1]) || [];
if (!columnNames.includes('banned')) {
db.run(`ALTER TABLE users ADD COLUMN banned INTEGER DEFAULT 0`);
console.log('✅ Добавлено поле banned в таблицу users');
}
if (!columnNames.includes('ip_address')) {
db.run(`ALTER TABLE users ADD COLUMN ip_address TEXT`);
console.log('✅ Добавлено поле ip_address в таблицу users');
}
} catch (error) {
console.log('⚠️ Миграция таблицы users пропущена:', error.message);
}
// Настройки пользователей
db.run(`
CREATE TABLE IF NOT EXISTS user_settings (
user_id TEXT PRIMARY KEY,
sound INTEGER DEFAULT 1,
animations INTEGER DEFAULT 1,
show_hand_strength INTEGER DEFAULT 1,
autofold INTEGER DEFAULT 1,
card_back_style TEXT DEFAULT 'default',
card_back_custom TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
// Админские настройки (глобальные)
db.run(`
CREATE TABLE IF NOT EXISTS admin_settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_by TEXT
)
`);
// Логи действий
db.run(`
CREATE TABLE IF NOT EXISTS action_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
user_id TEXT,
username TEXT,
action_type TEXT NOT NULL,
action_data TEXT,
room_id TEXT,
ip_address TEXT
)
`);
// Чат сообщения
db.run(`
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
room_id TEXT,
room_type TEXT,
user_id TEXT,
username TEXT NOT NULL,
message TEXT NOT NULL,
is_system INTEGER DEFAULT 0
)
`);
// Сессии игр
db.run(`
CREATE TABLE IF NOT EXISTS game_sessions (
id TEXT PRIMARY KEY,
room_id TEXT,
started_at TEXT DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT,
players TEXT,
winner_id TEXT,
pot_size INTEGER,
game_data TEXT
)
`);
saveDatabase();
console.log('📋 Таблицы созданы');
}
/**
* Создать админа по умолчанию
*/
async function createDefaultAdmin() {
const adminExists = db.exec("SELECT id FROM users WHERE role = 'admin' LIMIT 1");
if (adminExists.length === 0 || adminExists[0].values.length === 0) {
const adminId = uuidv4();
const passwordHash = bcrypt.hashSync('admin123', 10);
db.run(`
INSERT INTO users (id, username, password_hash, role)
VALUES (?, ?, ?, 'admin')
`, [adminId, 'admin', passwordHash]);
saveDatabase();
console.log('👑 Создан администратор по умолчанию: admin / admin123');
}
}
/**
* Инициализировать дефолтные админские настройки
*/
function initDefaultAdminSettings() {
const settingsCheck = db.exec("SELECT COUNT(*) as count FROM admin_settings");
const count = settingsCheck.length > 0 ? settingsCheck[0].values[0][0] : 0;
if (count === 0) {
const defaults = {
llmEnabled: 'false',
llmProvider: 'ollama',
llmApiUrl: 'http://localhost:11434',
llmModel: 'llama3.2',
llmApiKey: '',
serverUrl: 'ws://localhost:3000'
};
Object.entries(defaults).forEach(([key, value]) => {
db.run(`
INSERT INTO admin_settings (key, value, updated_at)
VALUES (?, ?, datetime('now'))
`, [key, value]);
});
saveDatabase();
console.log('⚙️ Инициализированы дефолтные админские настройки');
}
}
/**
* Сохранить БД на диск
*/
function saveDatabase() {
if (db) {
const data = db.export();
const buffer = Buffer.from(data);
fs.writeFileSync(DB_PATH, buffer);
}
}
// =============================================================================
// ПОЛЬЗОВАТЕЛИ И АВТОРИЗАЦИЯ
// =============================================================================
/**
* Регистрация пользователя
*/
function registerUser(username, password) {
try {
// Проверяем, существует ли пользователь
const existing = db.exec("SELECT id FROM users WHERE username = ?", [username]);
if (existing.length > 0 && existing[0].values.length > 0) {
return { success: false, error: 'Пользователь уже существует' };
}
const userId = uuidv4();
const passwordHash = bcrypt.hashSync(password, 10);
db.run(`
INSERT INTO users (id, username, password_hash)
VALUES (?, ?, ?)
`, [userId, username, passwordHash]);
// Создаём настройки по умолчанию
db.run(`
INSERT INTO user_settings (user_id)
VALUES (?)
`, [userId]);
saveDatabase();
// Логируем
logAction(userId, username, 'register', { username });
// Генерируем токен
const token = generateToken(userId, username, 'user');
return {
success: true,
user: { id: userId, username, role: 'user' },
token
};
} catch (error) {
console.error('Ошибка регистрации:', error);
return { success: false, error: 'Ошибка регистрации' };
}
}
/**
* Авторизация пользователя
*/
function loginUser(username, password, ipAddress = null) {
try {
const result = db.exec(`
SELECT id, username, password_hash, role, banned
FROM users
WHERE username = ?
`, [username]);
if (result.length === 0 || result[0].values.length === 0) {
return { success: false, error: 'Неверный логин или пароль' };
}
const [userId, storedUsername, passwordHash, role, banned] = result[0].values[0];
// Проверка на бан
if (banned === 1) {
return { success: false, error: 'Ваш аккаунт заблокирован' };
}
if (!bcrypt.compareSync(password, passwordHash)) {
return { success: false, error: 'Неверный логин или пароль' };
}
// Обновляем last_login и IP
if (ipAddress) {
db.run(`
UPDATE users SET last_login = datetime('now'), ip_address = ? WHERE id = ?
`, [ipAddress, userId]);
} else {
db.run(`
UPDATE users SET last_login = datetime('now') WHERE id = ?
`, [userId]);
}
saveDatabase();
// Логируем
logAction(userId, storedUsername, 'login', { username: storedUsername, ip: ipAddress });
// Генерируем токен
const token = generateToken(userId, storedUsername, role);
return {
success: true,
user: { id: userId, username: storedUsername, role },
token
};
} catch (error) {
console.error('Ошибка авторизации:', error);
return { success: false, error: 'Ошибка авторизации' };
}
}
/**
* Генерировать JWT токен
*/
function generateToken(userId, username, role) {
return jwt.sign(
{ userId, username, role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES }
);
}
/**
* Проверить JWT токен
*/
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (error) {
return null;
}
}
/**
* Получить пользователя по ID
*/
function getUserById(userId) {
const result = db.exec(`
SELECT id, username, role, created_at, last_login, total_games, total_wins, total_chips_won
FROM users
WHERE id = ?
`, [userId]);
if (result.length === 0 || result[0].values.length === 0) {
return null;
}
const [id, username, role, created_at, last_login, total_games, total_wins, total_chips_won] = result[0].values[0];
return { id, username, role, created_at, last_login, total_games, total_wins, total_chips_won };
}
// =============================================================================
// НАСТРОЙКИ
// =============================================================================
/**
* Получить настройки пользователя
*/
function getUserSettings(userId) {
const result = db.exec(`
SELECT sound, animations, show_hand_strength, autofold, card_back_style, card_back_custom
FROM user_settings
WHERE user_id = ?
`, [userId]);
if (result.length === 0 || result[0].values.length === 0) {
return {
sound: true,
animations: true,
showHandStrength: true,
autofold: true,
cardBackStyle: 'default',
cardBackCustom: null
};
}
const [sound, animations, showHandStrength, autofold, cardBackStyle, cardBackCustom] = result[0].values[0];
return {
sound: !!sound,
animations: !!animations,
showHandStrength: !!showHandStrength,
autofold: !!autofold,
cardBackStyle: cardBackStyle || 'default',
cardBackCustom
};
}
/**
* Сохранить настройки пользователя
*/
function saveUserSettings(userId, settings) {
db.run(`
INSERT OR REPLACE INTO user_settings
(user_id, sound, animations, show_hand_strength, autofold, card_back_style, card_back_custom)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [
userId,
settings.sound ? 1 : 0,
settings.animations ? 1 : 0,
settings.showHandStrength ? 1 : 0,
settings.autofold ? 1 : 0,
settings.cardBackStyle || 'default',
settings.cardBackCustom || null
]);
saveDatabase();
return true;
}
/**
* Получить админские настройки
*/
function getAdminSettings() {
const result = db.exec(`SELECT key, value FROM admin_settings`);
const settings = {
llmEnabled: false,
llmProvider: 'ollama',
llmApiUrl: 'http://localhost:11434',
llmModel: 'llama3.2',
llmApiKey: '',
serverUrl: 'ws://localhost:3000'
};
if (result.length > 0) {
result[0].values.forEach(([key, value]) => {
if (key === 'llmEnabled') {
settings.llmEnabled = value === 'true';
} else {
settings[key] = value;
}
});
}
return settings;
}
/**
* Сохранить админские настройки
*/
function saveAdminSettings(settings, adminId) {
const keys = ['llmEnabled', 'llmProvider', 'llmApiUrl', 'llmModel', 'llmApiKey', 'serverUrl'];
keys.forEach(key => {
if (settings[key] !== undefined) {
const value = typeof settings[key] === 'boolean' ? settings[key].toString() : settings[key];
db.run(`
INSERT OR REPLACE INTO admin_settings (key, value, updated_at, updated_by)
VALUES (?, ?, datetime('now'), ?)
`, [key, value, adminId]);
}
});
saveDatabase();
return true;
}
// =============================================================================
// ЛОГИРОВАНИЕ
// =============================================================================
/**
* Логировать действие
*/
function logAction(userId, username, actionType, actionData = {}, roomId = null, ipAddress = null) {
db.run(`
INSERT INTO action_logs (user_id, username, action_type, action_data, room_id, ip_address)
VALUES (?, ?, ?, ?, ?, ?)
`, [userId, username, actionType, JSON.stringify(actionData), roomId, ipAddress]);
saveDatabase();
}
/**
* Получить логи (для админа)
*/
function getLogs(options = {}) {
const { limit = 100, offset = 0, actionType = null, userId = null, startDate = null, endDate = null } = options;
let sql = `SELECT * FROM action_logs WHERE 1=1`;
const params = [];
if (actionType) {
sql += ` AND action_type = ?`;
params.push(actionType);
}
if (userId) {
sql += ` AND user_id = ?`;
params.push(userId);
}
if (startDate) {
sql += ` AND timestamp >= ?`;
params.push(startDate);
}
if (endDate) {
sql += ` AND timestamp <= ?`;
params.push(endDate);
}
sql += ` ORDER BY timestamp DESC LIMIT ? OFFSET ?`;
params.push(limit, offset);
const result = db.exec(sql, params);
if (result.length === 0) return [];
return result[0].values.map(row => ({
id: row[0],
timestamp: row[1],
userId: row[2],
username: row[3],
actionType: row[4],
actionData: JSON.parse(row[5] || '{}'),
roomId: row[6],
ipAddress: row[7]
}));
}
/**
* Получить статистику логов
*/
function getLogStats() {
const totalResult = db.exec(`SELECT COUNT(*) FROM action_logs`);
const total = totalResult[0]?.values[0]?.[0] || 0;
const byTypeResult = db.exec(`
SELECT action_type, COUNT(*) as count
FROM action_logs
GROUP BY action_type
ORDER BY count DESC
`);
const byType = byTypeResult.length > 0
? byTypeResult[0].values.map(([type, count]) => ({ type, count }))
: [];
const todayResult = db.exec(`
SELECT COUNT(*) FROM action_logs
WHERE date(timestamp) = date('now')
`);
const today = todayResult[0]?.values[0]?.[0] || 0;
return { total, byType, today };
}
// =============================================================================
// ЧАТ
// =============================================================================
/**
* Сохранить сообщение чата
*/
function saveChatMessage(roomId, roomType, userId, username, message, isSystem = false) {
db.run(`
INSERT INTO chat_messages (room_id, room_type, user_id, username, message, is_system)
VALUES (?, ?, ?, ?, ?, ?)
`, [roomId, roomType, userId, username, message, isSystem ? 1 : 0]);
saveDatabase();
}
/**
* Получить историю чата комнаты
*/
function getChatHistory(roomId, limit = 50) {
const result = db.exec(`
SELECT timestamp, username, message, is_system
FROM chat_messages
WHERE room_id = ?
ORDER BY timestamp DESC
LIMIT ?
`, [roomId, limit]);
if (result.length === 0) return [];
return result[0].values.map(row => ({
timestamp: row[0],
username: row[1],
message: row[2],
isSystem: !!row[3]
})).reverse();
}
/**
* Очистить чат комнаты
*/
function clearRoomChat(roomId) {
db.run(`DELETE FROM chat_messages WHERE room_id = ?`, [roomId]);
saveDatabase();
}
// =============================================================================
// УПРАВЛЕНИЕ ПОЛЬЗОВАТЕЛЯМИ (АДМИН)
// =============================================================================
/**
* Получить всех пользователей
*/
function getAllUsers() {
const result = db.exec(`
SELECT id, username, role, banned, ip_address, last_login, created_at,
total_games, total_wins, total_chips_won
FROM users
ORDER BY created_at DESC
`);
if (result.length === 0) return [];
return result[0].values.map(row => ({
id: row[0],
username: row[1],
role: row[2],
banned: row[3],
ipAddress: row[4],
lastLogin: row[5],
createdAt: row[6],
totalGames: row[7],
totalWins: row[8],
totalChipsWon: row[9]
}));
}
/**
* Получить пользователя по логину
*/
function getUserByUsername(username) {
const result = db.exec(`
SELECT id, username, role, banned, ip_address, last_login, created_at
FROM users
WHERE username = ?
`, [username]);
if (result.length === 0 || result[0].values.length === 0) {
return null;
}
const [id, uname, role, banned, ipAddress, lastLogin, createdAt] = result[0].values[0];
return { id, username: uname, role, banned, ipAddress, lastLogin, createdAt };
}
/**
* Обновить пользователя
*/
function updateUser(username, updates) {
const user = getUserByUsername(username);
if (!user) {
throw new Error('Пользователь не найден');
}
const fields = [];
const values = [];
if (updates.role !== undefined) {
fields.push('role = ?');
values.push(updates.role);
}
if (updates.banned !== undefined) {
fields.push('banned = ?');
values.push(updates.banned);
}
if (updates.password) {
const hashedPassword = bcrypt.hashSync(updates.password, 10);
fields.push('password_hash = ?');
values.push(hashedPassword);
}
if (fields.length === 0) {
return; // Нечего обновлять
}
values.push(username);
db.run(`
UPDATE users
SET ${fields.join(', ')}
WHERE username = ?
`, values);
saveDatabase();
}
/**
* Удалить пользователя
*/
function deleteUser(username) {
db.run(`DELETE FROM users WHERE username = ?`, [username]);
db.run(`DELETE FROM user_settings WHERE user_id = (SELECT id FROM users WHERE username = ?)`, [username]);
saveDatabase();
}
// =============================================================================
// ЭКСПОРТ
// =============================================================================
module.exports = {
initDatabase,
saveDatabase,
// Пользователи
registerUser,
loginUser,
verifyToken,
getUserById,
getAllUsers,
getUserByUsername,
updateUser,
deleteUser,
// Настройки
getUserSettings,
saveUserSettings,
getAdminSettings,
saveAdminSettings,
// Логирование
logAction,
getLogs,
getLogStats,
// Чат
saveChatMessage,
getChatHistory,
clearRoomChat
};

View File

View File

@ -1,81 +0,0 @@
#!/bin/bash
# =============================================================================
# Texas Hold'em Poker - Скрипт развертывания
# =============================================================================
set -e
echo "🃏 Texas Hold'em Poker - Развертывание"
echo "======================================"
echo ""
# Проверка Docker
if ! command -v docker &> /dev/null; then
echo "❌ Docker не установлен!"
echo "Установите Docker: https://docs.docker.com/get-docker/"
exit 1
fi
# Проверка Docker Compose
if ! command -v docker-compose &> /dev/null; then
echo "❌ Docker Compose не установлен!"
echo "Установите Docker Compose: https://docs.docker.com/compose/install/"
exit 1
fi
echo "✅ Docker и Docker Compose установлены"
echo ""
# Создание директорий
echo "📁 Создание необходимых директорий..."
mkdir -p data
mkdir -p logs
echo "✅ Директории созданы"
echo ""
# Остановка старого контейнера
echo "🛑 Остановка старых контейнеров..."
docker-compose down 2>/dev/null || true
echo "✅ Старые контейнеры остановлены"
echo ""
# Сборка образа
echo "🔨 Сборка Docker образа..."
docker-compose build
echo "✅ Образ собран"
echo ""
# Запуск контейнера
echo "🚀 Запуск контейнера..."
docker-compose up -d
echo "✅ Контейнер запущен"
echo ""
# Ожидание запуска
echo "⏳ Ожидание запуска сервера..."
sleep 3
# Проверка статуса
if docker-compose ps | grep -q "Up"; then
echo "✅ Сервер успешно запущен!"
echo ""
echo "=========================================="
echo "🎉 Развертывание завершено!"
echo "=========================================="
echo ""
echo "📍 Сервер доступен по адресу:"
echo " http://localhost:3336"
echo ""
echo "📊 Полезные команды:"
echo " docker-compose logs -f # Просмотр логов"
echo " docker-compose ps # Статус контейнеров"
echo " docker-compose stop # Остановка"
echo " docker-compose restart # Перезапуск"
echo " docker-compose down # Остановка и удаление"
echo ""
else
echo "❌ Ошибка запуска сервера!"
echo "Проверьте логи: docker-compose logs"
exit 1
fi

View File

@ -1,25 +0,0 @@
version: '3.8'
services:
poker-app:
build:
context: .
dockerfile: Dockerfile
container_name: texas-holdem-poker
restart: unless-stopped
ports:
- "3336:3336"
volumes:
# Монтируем директорию для сохранения базы данных
- ./data:/app/data
# Опционально: монтируем логи
- ./logs:/app/logs
environment:
- NODE_ENV=production
- PORT=3336
networks:
- poker-network
networks:
poker-network:
driver: bridge

1000
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,26 +0,0 @@
{
"name": "texas-holdem-online",
"version": "1.0.0",
"description": "Онлайн Texas Hold'em No-Limit Poker с мультиплеером и ИИ",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.3",
"sql.js": "^1.13.0",
"uuid": "^13.0.0",
"ws": "^8.16.0"
},
"keywords": [
"poker",
"texas-holdem",
"websocket",
"game"
],
"author": "",
"license": "MIT"
}

BIN
poker.db

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -1,570 +0,0 @@
/**
* =============================================================================
* Texas Hold'em - Модуль авторизации (клиент)
* =============================================================================
*/
// Текущий пользователь
let currentUser = null;
let authToken = null;
let isGuest = false;
// Пагинация логов
let currentLogPage = 1;
const logsPerPage = 20;
// =============================================================================
// АВТОРИЗАЦИЯ
// =============================================================================
/**
* Инициализация авторизации
*/
async function initAuth() {
// Проверяем сохранённый токен
const savedToken = localStorage.getItem('authToken');
if (savedToken) {
try {
const response = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${savedToken}` }
});
if (response.ok) {
const data = await response.json();
currentUser = data.user;
authToken = savedToken;
onAuthSuccess();
return;
}
} catch (error) {
console.error('Ошибка проверки токена:', error);
}
// Токен недействителен
localStorage.removeItem('authToken');
}
// Показываем экран авторизации
showScreen('auth-screen');
}
/**
* Переключить вкладку авторизации
*/
function switchAuthTab(tab) {
const tabs = document.querySelectorAll('#auth-tabs .btn-tab');
tabs.forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('login-form').style.display = tab === 'login' ? 'block' : 'none';
document.getElementById('register-form').style.display = tab === 'register' ? 'block' : 'none';
}
/**
* Обработчик входа
*/
async function handleLogin() {
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
if (!username || !password) {
showNotification('Введите логин и пароль', 'error');
return;
}
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
currentUser = data.user;
authToken = data.token;
localStorage.setItem('authToken', authToken);
isGuest = false;
onAuthSuccess();
} else {
showNotification(data.error || 'Ошибка входа', 'error');
}
} catch (error) {
console.error('Ошибка входа:', error);
showNotification('Ошибка соединения с сервером', 'error');
}
}
/**
* Обработчик регистрации
*/
async function handleRegister() {
const username = document.getElementById('register-username').value.trim();
const password = document.getElementById('register-password').value;
const passwordConfirm = document.getElementById('register-password-confirm').value;
if (!username || !password) {
showNotification('Заполните все поля', 'error');
return;
}
if (password !== passwordConfirm) {
showNotification('Пароли не совпадают', 'error');
return;
}
if (username.length < 3) {
showNotification('Логин должен быть минимум 3 символа', 'error');
return;
}
if (password.length < 6) {
showNotification('Пароль должен быть минимум 6 символов', 'error');
return;
}
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
currentUser = data.user;
authToken = data.token;
localStorage.setItem('authToken', authToken);
isGuest = false;
showNotification('Регистрация успешна!', 'success');
onAuthSuccess();
} else {
showNotification(data.error || 'Ошибка регистрации', 'error');
}
} catch (error) {
console.error('Ошибка регистрации:', error);
showNotification('Ошибка соединения с сервером', 'error');
}
}
/**
* Играть как гость
*/
function playAsGuest() {
currentUser = {
id: 'guest_' + Date.now(),
username: ость_' + Math.floor(Math.random() * 10000),
role: 'guest'
};
isGuest = true;
authToken = null;
onAuthSuccess();
}
/**
* Выход
*/
function handleLogout() {
currentUser = null;
authToken = null;
isGuest = false;
localStorage.removeItem('authToken');
showScreen('auth-screen');
showNotification('Вы вышли из системы', 'info');
}
/**
* После успешной авторизации
*/
function onAuthSuccess() {
showScreen('main-menu');
updateUserDisplay();
loadLLMStatus();
// Загружаем настройки с сервера если не гость
if (!isGuest && authToken) {
loadUserSettingsFromServer();
}
// Проверяем наличие комнаты в URL для автоматического присоединения
if (typeof checkRoomInUrl === 'function') {
checkRoomInUrl();
}
}
/**
* Обновить отображение пользователя
*/
function updateUserDisplay() {
const nameEl = document.getElementById('user-display-name');
const badgeEl = document.getElementById('user-role-badge');
const adminBtn = document.getElementById('admin-btn');
const serverUrlGroup = document.getElementById('server-url-group');
const adminLlmButtonGroup = document.getElementById('admin-llm-button-group');
if (currentUser) {
nameEl.textContent = currentUser.username;
if (currentUser.role === 'admin') {
badgeEl.style.display = 'inline';
badgeEl.textContent = 'ADMIN';
if (adminBtn) adminBtn.style.display = 'block';
if (serverUrlGroup) serverUrlGroup.style.display = 'block';
if (adminLlmButtonGroup) adminLlmButtonGroup.style.display = 'block';
} else {
badgeEl.style.display = 'none';
if (adminBtn) adminBtn.style.display = 'none';
if (serverUrlGroup) serverUrlGroup.style.display = 'none';
if (adminLlmButtonGroup) adminLlmButtonGroup.style.display = 'none';
}
// Обновляем поля имени в формах
document.getElementById('sp-player-name').value = currentUser.username;
document.getElementById('mp-player-name').value = currentUser.username;
}
}
// =============================================================================
// НАСТРОЙКИ С СЕРВЕРА
// =============================================================================
/**
* Загрузить пользовательские настройки с сервера
*/
async function loadUserSettingsFromServer() {
if (!authToken) return;
try {
const response = await fetch('/api/settings/user', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (response.ok) {
const data = await response.json();
applyServerSettings(data.settings);
}
} catch (error) {
console.error('Ошибка загрузки настроек:', error);
}
}
/**
* Сохранить настройки на сервер
*/
async function saveUserSettingsToServer(settings) {
if (!authToken) return;
try {
await fetch('/api/settings/user', {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
});
} catch (error) {
console.error('Ошибка сохранения настроек:', error);
}
}
/**
* Применить настройки с сервера
*/
function applyServerSettings(serverSettings) {
settings = {
...settings,
sound: serverSettings.sound,
animations: serverSettings.animations,
showHandStrength: serverSettings.showHandStrength,
autofold: serverSettings.autofold,
cardBackStyle: serverSettings.cardBackStyle,
cardBackCustom: serverSettings.cardBackCustom
};
// Применяем к UI
document.getElementById('setting-sound').checked = settings.sound !== false;
document.getElementById('setting-animations').checked = settings.animations !== false;
document.getElementById('setting-hand-strength').checked = settings.showHandStrength !== false;
document.getElementById('setting-autofold').checked = settings.autofold !== false;
soundEnabled = settings.sound !== false;
// Применяем рубашку карт
if (serverSettings.cardBackCustom) {
localStorage.setItem('customCardBack', serverSettings.cardBackCustom);
}
applyCardBackStyle(serverSettings.cardBackStyle || 'default');
}
/**
* Загрузить статус LLM
*/
async function loadLLMStatus() {
try {
const response = await fetch('/api/settings/public');
const data = await response.json();
const statusBox = document.getElementById('llm-status-box');
const statusIcon = document.getElementById('llm-status-icon');
const statusText = document.getElementById('llm-status-text');
if (statusBox) {
if (data.llmEnabled) {
statusBox.classList.add('enabled');
statusBox.classList.remove('disabled');
statusIcon.textContent = '✅';
statusText.textContent = `LLM чат включён (${data.llmProvider})`;
// Сохраняем настройки LLM глобально
window.llmSettings = {
enabled: true,
provider: data.llmProvider,
apiUrl: data.llmApiUrl,
model: data.llmModel
};
} else {
statusBox.classList.add('disabled');
statusBox.classList.remove('enabled');
statusIcon.textContent = '❌';
statusText.textContent = 'LLM чат отключён администратором';
window.llmSettings = { enabled: false };
}
}
} catch (error) {
console.error('Ошибка загрузки статуса LLM:', error);
}
}
// =============================================================================
// АДМИН-ПАНЕЛЬ
// =============================================================================
/**
* Переключить вкладку админки
*/
function switchAdminTab(tab) {
const tabs = document.querySelectorAll('.admin-tabs .btn-tab');
tabs.forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
document.querySelectorAll('.admin-tab-content').forEach(el => {
el.style.display = 'none';
});
document.getElementById(`admin-tab-${tab}`).style.display = 'block';
// Загружаем данные вкладки
if (tab === 'logs') {
loadLogStats();
loadLogs(1);
} else if (tab === 'settings') {
loadAdminSettings();
}
}
/**
* Загрузить админские настройки
*/
async function loadAdminSettings() {
if (!authToken || currentUser?.role !== 'admin') {
console.log('❌ Нет прав для загрузки админ настроек');
return;
}
console.log('📥 Загрузка админских настроек...');
try {
const response = await fetch('/api/settings/admin', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (response.ok) {
const data = await response.json();
const s = data.settings;
console.log('✅ Получены настройки:', s);
document.getElementById('admin-llm-enabled').checked = s.llmEnabled;
document.getElementById('admin-llm-provider').value = s.llmProvider || 'ollama';
document.getElementById('admin-llm-url').value = s.llmApiUrl || 'http://localhost:11434';
document.getElementById('admin-llm-model').value = s.llmModel || 'llama3.2';
document.getElementById('admin-llm-apikey').value = s.llmApiKey || '';
} else {
console.error('❌ Ошибка ответа сервера:', response.status);
}
} catch (error) {
console.error('❌ Ошибка загрузки админ настроек:', error);
}
}
/**
* Сохранить админские настройки
*/
async function saveAdminSettings() {
if (!authToken || currentUser?.role !== 'admin') return;
const settings = {
llmEnabled: document.getElementById('admin-llm-enabled').checked,
llmProvider: document.getElementById('admin-llm-provider').value,
llmApiUrl: document.getElementById('admin-llm-url').value,
llmModel: document.getElementById('admin-llm-model').value,
llmApiKey: document.getElementById('admin-llm-apikey').value
};
console.log('💾 Сохранение админских настроек:', settings);
try {
const response = await fetch('/api/settings/admin', {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
});
if (response.ok) {
showNotification('Настройки сохранены', 'success');
// Обновляем статус LLM для отображения в настройках
loadLLMStatus();
} else {
showNotification('Ошибка сохранения', 'error');
}
} catch (error) {
showNotification('Ошибка сохранения', 'error');
console.error('Ошибка:', error);
}
}
/**
* Тестировать LLM из админки
*/
async function testAdminLLM() {
// Сохраняем настройки сначала
await saveAdminSettings();
showNotification('Тестирование LLM...', 'info');
// Здесь можно добавить реальный тест через сервер
setTimeout(() => {
showNotification('LLM тест выполнен (проверьте логи сервера)', 'success');
}, 1000);
}
/**
* Загрузить статистику логов
*/
async function loadLogStats() {
if (!authToken || currentUser?.role !== 'admin') return;
try {
const response = await fetch('/api/logs/stats', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (response.ok) {
const data = await response.json();
document.getElementById('stat-total').textContent = data.stats.total;
document.getElementById('stat-today').textContent = data.stats.today;
}
} catch (error) {
console.error('Ошибка загрузки статистики:', error);
}
}
/**
* Загрузить логи
*/
async function loadLogs(page = 1) {
if (!authToken || currentUser?.role !== 'admin') return;
currentLogPage = Math.max(1, page);
const offset = (currentLogPage - 1) * logsPerPage;
const actionType = document.getElementById('log-filter-type').value;
try {
let url = `/api/logs?limit=${logsPerPage}&offset=${offset}`;
if (actionType) url += `&actionType=${actionType}`;
const response = await fetch(url, {
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (response.ok) {
const data = await response.json();
renderLogs(data.logs);
// Обновляем пагинацию
document.getElementById('log-page-info').textContent = `Страница ${currentLogPage}`;
document.getElementById('log-prev-btn').disabled = currentLogPage === 1;
document.getElementById('log-next-btn').disabled = data.logs.length < logsPerPage;
}
} catch (error) {
console.error('Ошибка загрузки логов:', error);
}
}
/**
* Отрисовать логи
*/
function renderLogs(logs) {
const tbody = document.getElementById('log-table-body');
tbody.innerHTML = '';
if (logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; color: var(--text-muted);">Логи не найдены</td></tr>';
return;
}
logs.forEach(log => {
const tr = document.createElement('tr');
const time = new Date(log.timestamp).toLocaleString('ru-RU');
const details = typeof log.actionData === 'object'
? JSON.stringify(log.actionData).substring(0, 50) + '...'
: log.actionData;
tr.innerHTML = `
<td>${time}</td>
<td>${log.username || 'Гость'}</td>
<td><span class="log-action-badge ${log.actionType}">${log.actionType}</span></td>
<td title="${JSON.stringify(log.actionData)}">${details}</td>
`;
tbody.appendChild(tr);
});
}
// =============================================================================
// ОЧИСТКА ЧАТА
// =============================================================================
/**
* Очистить чат при смене режима игры
*/
function clearGameChat() {
const container = document.getElementById('game-chat-messages');
if (container) {
container.innerHTML = '';
}
}
/**
* Очистить чат лобби
*/
function clearLobbyChat() {
const container = document.getElementById('lobby-chat-messages');
if (container) {
container.innerHTML = '';
}
}
// Экспортируем для глобального использования
window.currentUser = currentUser;
window.authToken = authToken;
window.isGuest = isGuest;

View File

@ -1,912 +0,0 @@
/**
* =============================================================================
* Texas Hold'em - Игровая логика (клиентская сторона)
* Колода, раздача, оценка рук, определение победителя
* =============================================================================
*/
// =============================================================================
// КОНСТАНТЫ
// =============================================================================
const SUITS = ['hearts', 'diamonds', 'clubs', 'spades'];
const RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
const SUIT_SYMBOLS = {
hearts: '♥',
diamonds: '♦',
clubs: '♣',
spades: '♠'
};
const HAND_RANKINGS = {
1: 'Старшая карта',
2: 'Пара',
3: 'Две пары',
4: 'Сет',
5: 'Стрит',
6: 'Флеш',
7: 'Фулл-хаус',
8: 'Каре',
9: 'Стрит-флеш',
10: 'Роял-флеш'
};
// =============================================================================
// КЛАСС КАРТЫ
// =============================================================================
class Card {
constructor(suit, rank) {
this.suit = suit;
this.rank = rank;
this.value = RANKS.indexOf(rank) + 2; // 2-14 (A=14)
}
/**
* Получить HTML элемент карты
*/
toHTML(isSmall = false, isBack = false) {
const card = document.createElement('div');
card.className = `card ${this.suit}${isSmall ? ' card-small' : ''}${isBack ? ' card-back' : ''}`;
if (!isBack) {
card.innerHTML = `
<span class="card-rank">${this.rank}</span>
<span class="card-suit">${SUIT_SYMBOLS[this.suit]}</span>
`;
}
return card;
}
toString() {
return `${this.rank}${SUIT_SYMBOLS[this.suit]}`;
}
}
// =============================================================================
// КЛАСС КОЛОДЫ
// =============================================================================
class Deck {
constructor() {
this.reset();
}
/**
* Сбросить и перемешать колоду
*/
reset() {
this.cards = [];
for (const suit of SUITS) {
for (const rank of RANKS) {
this.cards.push(new Card(suit, rank));
}
}
this.shuffle();
}
/**
* Перемешать колоду (Fisher-Yates)
*/
shuffle() {
for (let i = this.cards.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.cards[i], this.cards[j]] = [this.cards[j], this.cards[i]];
}
}
/**
* Раздать карты
*/
deal(count = 1) {
return this.cards.splice(0, count);
}
}
// =============================================================================
// КЛАСС ИГРОКА
// =============================================================================
class Player {
constructor(id, name, chips = 1000, isAI = false, aiLevel = 1) {
this.id = id;
this.name = name;
this.chips = chips;
this.hand = [];
this.bet = 0;
this.totalBet = 0;
this.folded = false;
this.allIn = false;
this.isDealer = false;
this.isSmallBlind = false;
this.isBigBlind = false;
this.isAI = isAI;
this.aiLevel = aiLevel;
this.lastAction = null;
this.isConnected = true;
}
/**
* Сбросить состояние для новой раздачи
*/
reset() {
this.hand = [];
this.bet = 0;
this.totalBet = 0;
this.folded = false;
this.allIn = false;
this.isDealer = false;
this.isSmallBlind = false;
this.isBigBlind = false;
this.lastAction = null;
}
}
// =============================================================================
// КЛАСС ИГРЫ
// =============================================================================
class PokerGame {
constructor(options = {}) {
this.players = [];
this.deck = new Deck();
this.communityCards = [];
this.pot = 0;
this.sidePots = [];
this.currentBet = 0;
this.dealerIndex = -1;
this.currentPlayerIndex = 0;
this.gamePhase = 'waiting'; // waiting, preflop, flop, turn, river, showdown
this.smallBlind = options.smallBlind || 5;
this.bigBlind = options.bigBlind || 10;
this.minRaise = this.bigBlind;
this.lastRaiseAmount = this.bigBlind;
this.isGameStarted = false;
this.onUpdate = options.onUpdate || (() => {});
this.onAction = options.onAction || (() => {});
this.onHandEnd = options.onHandEnd || (() => {});
}
/**
* Добавить игрока
*/
addPlayer(player) {
this.players.push(player);
}
/**
* Получить активных игроков
*/
getActivePlayers() {
return this.players.filter(p => !p.folded && p.chips >= 0);
}
/**
* Получить игроков с фишками
*/
getPlayersWithChips() {
return this.players.filter(p => p.chips > 0);
}
/**
* Начать новую раздачу
*/
startNewHand() {
if (this.getPlayersWithChips().length < 2) {
return false;
}
// Сброс состояния
this.deck.reset();
this.communityCards = [];
this.pot = 0;
this.sidePots = [];
this.currentBet = 0;
this.minRaise = this.bigBlind;
this.lastRaiseAmount = this.bigBlind;
// Сброс игроков
for (const player of this.players) {
player.reset();
}
// Определение позиций
this.moveDealer();
this.assignBlinds();
// Раздача карт
this.dealHoleCards();
// Начало игры
this.gamePhase = 'preflop';
this.isGameStarted = true;
// Ставки блайндов
this.postBlinds();
// Первый ход
this.setFirstPlayer();
this.onUpdate();
// Если первый ход - ИИ
this.checkAITurn();
return true;
}
/**
* Передвинуть баттон дилера
*/
moveDealer() {
const playersWithChips = this.getPlayersWithChips();
if (playersWithChips.length < 2) return;
// Находим следующего дилера
do {
this.dealerIndex = (this.dealerIndex + 1) % this.players.length;
} while (this.players[this.dealerIndex].chips <= 0);
this.players[this.dealerIndex].isDealer = true;
}
/**
* Назначить блайнды
*/
assignBlinds() {
const playersWithChips = this.getPlayersWithChips();
if (playersWithChips.length === 2) {
// Heads-up: дилер = SB
this.players[this.dealerIndex].isSmallBlind = true;
let bbIndex = this.getNextActivePlayerIndex(this.dealerIndex);
this.players[bbIndex].isBigBlind = true;
} else {
// 3+ игроков
let sbIndex = this.getNextActivePlayerIndex(this.dealerIndex);
this.players[sbIndex].isSmallBlind = true;
let bbIndex = this.getNextActivePlayerIndex(sbIndex);
this.players[bbIndex].isBigBlind = true;
}
}
/**
* Поставить блайнды
*/
postBlinds() {
const sbPlayer = this.players.find(p => p.isSmallBlind);
const bbPlayer = this.players.find(p => p.isBigBlind);
if (sbPlayer) {
const sbAmount = Math.min(sbPlayer.chips, this.smallBlind);
sbPlayer.chips -= sbAmount;
sbPlayer.bet = sbAmount;
sbPlayer.totalBet = sbAmount;
this.pot += sbAmount;
sbPlayer.lastAction = `SB ${sbAmount}`;
}
if (bbPlayer) {
const bbAmount = Math.min(bbPlayer.chips, this.bigBlind);
bbPlayer.chips -= bbAmount;
bbPlayer.bet = bbAmount;
bbPlayer.totalBet = bbAmount;
this.pot += bbAmount;
this.currentBet = bbAmount;
bbPlayer.lastAction = `BB ${bbAmount}`;
}
}
/**
* Раздать карманные карты
*/
dealHoleCards() {
for (const player of this.getPlayersWithChips()) {
player.hand = this.deck.deal(2);
}
}
/**
* Установить первого игрока для хода
*/
setFirstPlayer() {
if (this.gamePhase === 'preflop') {
// UTG - после BB
const bbPlayer = this.players.find(p => p.isBigBlind);
const bbIndex = this.players.indexOf(bbPlayer);
this.currentPlayerIndex = this.getNextActivePlayerIndex(bbIndex);
} else {
// После флопа - первый активный после дилера
this.currentPlayerIndex = this.getNextActivePlayerIndex(this.dealerIndex);
}
}
/**
* Получить индекс следующего активного игрока
*/
getNextActivePlayerIndex(fromIndex) {
let index = (fromIndex + 1) % this.players.length;
let attempts = 0;
while (attempts < this.players.length) {
const player = this.players[index];
if (!player.folded && player.chips >= 0 && !player.allIn) {
return index;
}
index = (index + 1) % this.players.length;
attempts++;
}
return -1;
}
/**
* Текущий игрок
*/
getCurrentPlayer() {
return this.players[this.currentPlayerIndex];
}
/**
* Получить доступные действия для игрока
*/
getAvailableActions(player) {
const actions = [];
const toCall = this.currentBet - player.bet;
// Фолд всегда доступен
actions.push('fold');
if (toCall === 0) {
// Можно чекнуть
actions.push('check');
// Можно поставить
if (player.chips > 0) {
actions.push('bet');
}
} else {
// Нужно колл или рейз
if (player.chips > toCall) {
actions.push('call');
actions.push('raise');
} else {
// Можно только колл (олл-ин)
actions.push('call');
}
}
// All-in всегда доступен если есть фишки
if (player.chips > 0) {
actions.push('allin');
}
return actions;
}
/**
* Обработать действие игрока
*/
processAction(playerId, action, amount = 0) {
const player = this.players.find(p => p.id === playerId);
if (!player) return { success: false, error: 'Игрок не найден' };
const currentPlayer = this.players[this.currentPlayerIndex];
if (currentPlayer.id !== playerId) {
return { success: false, error: 'Сейчас не ваш ход' };
}
const result = { success: true, action, amount: 0 };
switch (action) {
case 'fold':
player.folded = true;
player.lastAction = 'Фолд';
break;
case 'check':
if (player.bet < this.currentBet) {
return { success: false, error: 'Нельзя чекнуть' };
}
player.lastAction = 'Чек';
break;
case 'call':
const callAmount = Math.min(this.currentBet - player.bet, player.chips);
player.chips -= callAmount;
player.bet += callAmount;
player.totalBet += callAmount;
this.pot += callAmount;
result.amount = callAmount;
player.lastAction = `Колл ${callAmount}`;
if (player.chips === 0) {
player.allIn = true;
player.lastAction = 'Олл-ин';
}
break;
case 'bet':
if (this.currentBet > 0) {
return { success: false, error: 'Используйте рейз' };
}
if (amount < this.bigBlind) {
amount = this.bigBlind;
}
if (amount > player.chips) {
amount = player.chips;
}
player.chips -= amount;
player.bet = amount;
player.totalBet += amount;
this.pot += amount;
this.currentBet = amount;
this.lastRaiseAmount = amount;
this.minRaise = amount;
result.amount = amount;
player.lastAction = `Бет ${amount}`;
if (player.chips === 0) {
player.allIn = true;
player.lastAction = 'Олл-ин';
}
break;
case 'raise':
const toCall = this.currentBet - player.bet;
const minRaiseTotal = this.currentBet + this.lastRaiseAmount;
if (amount < minRaiseTotal && amount < player.chips + player.bet) {
amount = minRaiseTotal;
}
const raiseTotal = Math.min(amount, player.chips + player.bet);
const raiseAmount = raiseTotal - player.bet;
player.chips -= raiseAmount;
this.pot += raiseAmount;
this.lastRaiseAmount = raiseTotal - this.currentBet;
this.currentBet = raiseTotal;
player.bet = raiseTotal;
player.totalBet += raiseAmount;
result.amount = raiseTotal;
player.lastAction = `Рейз до ${raiseTotal}`;
if (player.chips === 0) {
player.allIn = true;
player.lastAction = 'Олл-ин';
}
break;
case 'allin':
const allInAmount = player.chips;
this.pot += allInAmount;
player.bet += allInAmount;
player.totalBet += allInAmount;
result.amount = player.bet;
if (player.bet > this.currentBet) {
this.lastRaiseAmount = player.bet - this.currentBet;
this.currentBet = player.bet;
}
player.chips = 0;
player.allIn = true;
player.lastAction = `Олл-ин ${player.bet}`;
break;
default:
return { success: false, error: 'Неизвестное действие' };
}
// Уведомляем о действии
this.onAction(player, action, result.amount);
// Проверка завершения раунда ставок
if (this.isBettingRoundComplete()) {
this.nextPhase();
} else {
this.moveToNextPlayer();
this.onUpdate();
this.checkAITurn();
}
return result;
}
/**
* Перейти к следующему игроку
*/
moveToNextPlayer() {
const nextIndex = this.getNextActivePlayerIndex(this.currentPlayerIndex);
if (nextIndex !== -1) {
this.currentPlayerIndex = nextIndex;
}
}
/**
* Проверить, ход ли ИИ
*/
checkAITurn() {
if (!this.isGameStarted) return;
const currentPlayer = this.getCurrentPlayer();
if (currentPlayer && currentPlayer.isAI) {
// Задержка для реалистичности
setTimeout(() => {
this.makeAIMove(currentPlayer);
}, 1000 + Math.random() * 1500);
}
}
/**
* Ход ИИ
*/
makeAIMove(player) {
if (!this.isGameStarted || player.folded || player.allIn) return;
const decision = pokerAI.makeDecision(player, this);
this.processAction(player.id, decision.action, decision.amount);
}
/**
* Проверить завершение раунда ставок
*/
isBettingRoundComplete() {
const activePlayers = this.getActivePlayers().filter(p => !p.allIn);
// Все фолднули кроме одного
if (this.getActivePlayers().length === 1) {
return true;
}
// Все активные (не all-in) игроки уравняли ставку
if (activePlayers.length === 0) {
return true;
}
// Все поставили одинаково и сделали действие
const allEqual = activePlayers.every(p => p.bet === this.currentBet);
const allActed = activePlayers.every(p => p.lastAction !== null &&
!p.lastAction.includes('SB') && !p.lastAction.includes('BB'));
return allEqual && allActed;
}
/**
* Переход к следующей фазе
*/
nextPhase() {
// Сброс ставок для новой улицы
for (const player of this.players) {
player.bet = 0;
if (!player.folded && !player.allIn) {
player.lastAction = null;
}
}
this.currentBet = 0;
const activePlayers = this.getActivePlayers();
// Один игрок остался - он победитель
if (activePlayers.length === 1) {
this.endHand([activePlayers[0]]);
return;
}
switch (this.gamePhase) {
case 'preflop':
this.gamePhase = 'flop';
this.communityCards = this.deck.deal(3);
break;
case 'flop':
this.gamePhase = 'turn';
this.communityCards.push(...this.deck.deal(1));
break;
case 'turn':
this.gamePhase = 'river';
this.communityCards.push(...this.deck.deal(1));
break;
case 'river':
this.gamePhase = 'showdown';
this.determineWinner();
return;
}
// Установка первого игрока для новой улицы
this.setFirstPlayer();
this.onUpdate();
// Если все в all-in или только один активный, автоматически переходим
const nonAllInPlayers = this.getActivePlayers().filter(p => !p.allIn);
if (nonAllInPlayers.length <= 1) {
setTimeout(() => this.nextPhase(), 1500);
} else {
this.checkAITurn();
}
}
/**
* Определить победителя
*/
determineWinner() {
const activePlayers = this.getActivePlayers();
if (activePlayers.length === 1) {
this.endHand([activePlayers[0]]);
return;
}
// Оценка рук
const playerHands = activePlayers.map(player => ({
player,
handResult: evaluateHand([...player.hand, ...this.communityCards])
}));
// Сортировка по силе руки
playerHands.sort((a, b) => {
if (b.handResult.rank !== a.handResult.rank) {
return b.handResult.rank - a.handResult.rank;
}
for (let i = 0; i < a.handResult.kickers.length; i++) {
if (b.handResult.kickers[i] !== a.handResult.kickers[i]) {
return b.handResult.kickers[i] - a.handResult.kickers[i];
}
}
return 0;
});
// Находим победителей (может быть сплит)
const winners = [playerHands[0]];
for (let i = 1; i < playerHands.length; i++) {
const cmp = this.compareHands(playerHands[0].handResult, playerHands[i].handResult);
if (cmp === 0) {
winners.push(playerHands[i]);
} else {
break;
}
}
this.endHand(
winners.map(w => w.player),
playerHands.map(ph => ({ player: ph.player, hand: ph.handResult }))
);
}
/**
* Сравнение двух рук
*/
compareHands(h1, h2) {
if (h1.rank !== h2.rank) return h2.rank - h1.rank;
for (let i = 0; i < h1.kickers.length; i++) {
if (h1.kickers[i] !== h2.kickers[i]) {
return h2.kickers[i] - h1.kickers[i];
}
}
return 0;
}
/**
* Завершить раздачу
*/
endHand(winners, allHands = null) {
const winAmount = Math.floor(this.pot / winners.length);
for (const winner of winners) {
winner.chips += winAmount;
}
// Остаток при нечётном сплите
const remainder = this.pot % winners.length;
if (remainder > 0) {
winners[0].chips += remainder;
}
this.gamePhase = 'showdown';
this.isGameStarted = false;
const result = {
winners: winners.map(w => ({
id: w.id,
name: w.name,
amount: winAmount,
hand: allHands ? allHands.find(h => h.player.id === w.id)?.hand : null
})),
pot: this.pot,
hands: allHands
};
this.onHandEnd(result);
this.onUpdate();
return result;
}
}
// =============================================================================
// ОЦЕНКА РУК
// =============================================================================
/**
* Оценить покерную руку из 7 карт
*/
function evaluateHand(cards) {
const allCombinations = getCombinations(cards, 5);
let bestHand = null;
for (const combo of allCombinations) {
const hand = evaluateFiveCards(combo);
if (!bestHand || compareHandResults(hand, bestHand) > 0) {
bestHand = hand;
}
}
return bestHand;
}
/**
* Получить все комбинации из n элементов
*/
function getCombinations(arr, n) {
if (n === 0) return [[]];
if (arr.length === 0) return [];
const [first, ...rest] = arr;
const withFirst = getCombinations(rest, n - 1).map(c => [first, ...c]);
const withoutFirst = getCombinations(rest, n);
return [...withFirst, ...withoutFirst];
}
/**
* Оценить 5 карт
*/
function evaluateFiveCards(cards) {
const sortedCards = [...cards].sort((a, b) => b.value - a.value);
const values = sortedCards.map(c => c.value);
const suits = sortedCards.map(c => c.suit);
const isFlush = suits.every(s => s === suits[0]);
const isStraight = checkStraight(values);
const isWheel = values.join(',') === '14,5,4,3,2'; // A-2-3-4-5
const valueCounts = {};
for (const v of values) {
valueCounts[v] = (valueCounts[v] || 0) + 1;
}
const counts = Object.values(valueCounts).sort((a, b) => b - a);
const uniqueValues = Object.keys(valueCounts)
.map(Number)
.sort((a, b) => {
if (valueCounts[b] !== valueCounts[a]) {
return valueCounts[b] - valueCounts[a];
}
return b - a;
});
// Рояль-флеш
if (isFlush && isStraight && values[0] === 14) {
return { rank: 10, name: 'Роял-флеш', kickers: values };
}
// Стрит-флеш
if (isFlush && (isStraight || isWheel)) {
return { rank: 9, name: 'Стрит-флеш', kickers: isWheel ? [5, 4, 3, 2, 1] : values };
}
// Каре
if (counts[0] === 4) {
return { rank: 8, name: 'Каре', kickers: uniqueValues };
}
// Фулл-хаус
if (counts[0] === 3 && counts[1] === 2) {
return { rank: 7, name: 'Фулл-хаус', kickers: uniqueValues };
}
// Флеш
if (isFlush) {
return { rank: 6, name: 'Флеш', kickers: values };
}
// Стрит
if (isStraight || isWheel) {
return { rank: 5, name: 'Стрит', kickers: isWheel ? [5, 4, 3, 2, 1] : values };
}
// Сет
if (counts[0] === 3) {
return { rank: 4, name: 'Сет', kickers: uniqueValues };
}
// Две пары
if (counts[0] === 2 && counts[1] === 2) {
return { rank: 3, name: 'Две пары', kickers: uniqueValues };
}
// Пара
if (counts[0] === 2) {
return { rank: 2, name: 'Пара', kickers: uniqueValues };
}
// Старшая карта
return { rank: 1, name: 'Старшая карта', kickers: values };
}
/**
* Проверка на стрит
*/
function checkStraight(values) {
for (let i = 0; i < values.length - 1; i++) {
if (values[i] - values[i + 1] !== 1) {
return false;
}
}
return true;
}
/**
* Сравнение результатов рук
*/
function compareHandResults(h1, h2) {
if (h1.rank !== h2.rank) return h1.rank - h2.rank;
for (let i = 0; i < h1.kickers.length; i++) {
if (h1.kickers[i] !== h2.kickers[i]) {
return h1.kickers[i] - h2.kickers[i];
}
}
return 0;
}
/**
* Получить силу руки для отображения
*/
function getHandStrength(cards, communityCards) {
if (cards.length < 2) return null;
const allCards = [...cards];
if (communityCards && communityCards.length > 0) {
allCards.push(...communityCards);
}
if (allCards.length < 5) {
// Только карманные карты - оцениваем префлоп силу
return getPreflopHandName(cards);
}
const hand = evaluateHand(allCards);
return hand.name;
}
/**
* Получить название префлоп руки
*/
function getPreflopHandName(cards) {
if (cards.length !== 2) return '';
const [c1, c2] = cards;
const highCard = c1.value > c2.value ? c1 : c2;
const lowCard = c1.value > c2.value ? c2 : c1;
const suited = c1.suit === c2.suit;
let name = '';
if (c1.value === c2.value) {
name = `Пара ${c1.rank}`;
} else {
name = `${highCard.rank}${lowCard.rank}${suited ? 's' : 'o'}`;
}
return name;
}
// Экспорт для использования в других модулях
if (typeof module !== 'undefined' && module.exports) {
module.exports = { Card, Deck, Player, PokerGame, evaluateHand, getHandStrength };
}

View File

@ -1,899 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🃏 Texas Hold'em Poker</title>
<link rel="stylesheet" href="styles.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<!-- Экран авторизации -->
<div id="auth-screen" class="screen active">
<div class="glass-container menu-container">
<div class="logo">
<span class="logo-icon">🃏</span>
<h1>Texas Hold'em</h1>
<p class="subtitle">No-Limit Poker</p>
</div>
<div id="auth-tabs" class="auth-tabs">
<button class="btn btn-tab active" onclick="switchAuthTab('login')">Вход</button>
<button class="btn btn-tab" onclick="switchAuthTab('register')">Регистрация</button>
</div>
<!-- Форма входа -->
<div id="login-form" class="auth-form">
<div class="form-group">
<label>Логин</label>
<input type="text" id="login-username" class="input" placeholder="Введите логин" autocomplete="username">
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" id="login-password" class="input" placeholder="Введите пароль" autocomplete="current-password">
</div>
<button class="btn btn-primary btn-large" onclick="handleLogin()">Войти</button>
</div>
<!-- Форма регистрации -->
<div id="register-form" class="auth-form" style="display: none;">
<div class="form-group">
<label>Логин</label>
<input type="text" id="register-username" class="input" placeholder="Минимум 3 символа" autocomplete="username">
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" id="register-password" class="input" placeholder="Минимум 6 символов" autocomplete="new-password">
</div>
<div class="form-group">
<label>Повторите пароль</label>
<input type="password" id="register-password-confirm" class="input" placeholder="Повторите пароль" autocomplete="new-password">
</div>
<button class="btn btn-primary btn-large" onclick="handleRegister()">Зарегистрироваться</button>
</div>
<div class="auth-guest">
<span>или</span>
<button class="btn btn-outline" onclick="playAsGuest()">Играть как гость</button>
</div>
</div>
</div>
<!-- Главное меню -->
<div id="main-menu" class="screen">
<div class="glass-container menu-container">
<div class="logo">
<span class="logo-icon">🃏</span>
<h1>Texas Hold'em</h1>
<p class="subtitle">No-Limit Poker</p>
</div>
<!-- Информация о пользователе -->
<div id="user-info" class="user-info">
<span id="user-display-name">Гость</span>
<span id="user-role-badge" class="role-badge" style="display: none;">ADMIN</span>
<button class="btn btn-small btn-outline" onclick="handleLogout()">Выход</button>
</div>
<div class="menu-buttons">
<button class="btn btn-primary btn-large" onclick="showScreen('single-player-menu')">
<span class="btn-icon">🤖</span>
Одиночная игра
</button>
<button class="btn btn-secondary btn-large" onclick="showScreen('multiplayer-menu')">
<span class="btn-icon">👥</span>
Мультиплеер
</button>
<button class="btn btn-outline btn-large" onclick="showScreen('leaderboard-screen')">
<span class="btn-icon">🏆</span>
Таблица лидеров
</button>
</div>
<div class="settings-row">
<button class="btn btn-icon-only" onclick="toggleSound()" id="sound-toggle">
🔊
</button>
<button class="btn btn-icon-only" onclick="showScreen('settings-screen')">
⚙️
</button>
<!-- Кнопка админ-панели (только для админов) -->
<button class="btn btn-icon-only admin-only" onclick="showScreen('admin-screen')" style="display: none;" id="admin-btn">
👑
</button>
</div>
</div>
</div>
<!-- Меню одиночной игры -->
<div id="single-player-menu" class="screen">
<div class="glass-container menu-container">
<button class="btn-back" onclick="showScreen('main-menu')">← Назад</button>
<h2>Одиночная игра</h2>
<div class="form-group">
<label>Ваше имя</label>
<input type="text" id="sp-player-name" class="input" placeholder="Введите имя" value="Игрок">
</div>
<div class="form-group">
<label>Количество ботов</label>
<div class="btn-group">
<button class="btn btn-option active" data-value="1" onclick="selectOption(this, 'bot-count')">1</button>
<button class="btn btn-option" data-value="2" onclick="selectOption(this, 'bot-count')">2</button>
<button class="btn btn-option" data-value="3" onclick="selectOption(this, 'bot-count')">3</button>
<button class="btn btn-option" data-value="4" onclick="selectOption(this, 'bot-count')">4</button>
<button class="btn btn-option" data-value="5" onclick="selectOption(this, 'bot-count')">5</button>
</div>
</div>
<div class="form-group">
<label>Сложность ИИ</label>
<div class="btn-group difficulty-group">
<button class="btn btn-option active" data-value="1" onclick="selectOption(this, 'ai-difficulty')">
<span class="diff-icon">🎲</span>
<span class="diff-name">Новичок</span>
</button>
<button class="btn btn-option" data-value="2" onclick="selectOption(this, 'ai-difficulty')">
<span class="diff-icon">🎯</span>
<span class="diff-name">Опытный</span>
</button>
<button class="btn btn-option" data-value="3" onclick="selectOption(this, 'ai-difficulty')">
<span class="diff-icon">🧠</span>
<span class="diff-name">Эксперт</span>
</button>
</div>
</div>
<div class="form-group">
<label>Начальный стек</label>
<div class="btn-group">
<button class="btn btn-option" data-value="500" onclick="selectOption(this, 'starting-stack')">500</button>
<button class="btn btn-option active" data-value="1000" onclick="selectOption(this, 'starting-stack')">1000</button>
<button class="btn btn-option" data-value="2000" onclick="selectOption(this, 'starting-stack')">2000</button>
<button class="btn btn-option" data-value="5000" onclick="selectOption(this, 'starting-stack')">5000</button>
</div>
</div>
<button class="btn btn-primary btn-large" onclick="startSinglePlayer()">
Начать игру
</button>
</div>
</div>
<!-- Меню мультиплеера -->
<div id="multiplayer-menu" class="screen">
<div class="glass-container menu-container">
<button class="btn-back" onclick="showScreen('main-menu')">← Назад</button>
<h2>Мультиплеер</h2>
<div class="form-group">
<label>Ваше имя</label>
<input type="text" id="mp-player-name" class="input" placeholder="Введите имя" value="Игрок">
</div>
<div class="tabs">
<button class="tab active" onclick="switchTab('join-tab')">Присоединиться</button>
<button class="tab" onclick="switchTab('create-tab')">Создать комнату</button>
</div>
<div id="join-tab" class="tab-content active">
<div class="room-list" id="room-list">
<div class="room-list-loading">Подключение к серверу...</div>
</div>
<button class="btn btn-outline" onclick="refreshRooms()">🔄 Обновить</button>
</div>
<div id="create-tab" class="tab-content">
<div class="form-group">
<label>Название комнаты</label>
<input type="text" id="room-name" class="input" placeholder="Моя комната">
</div>
<div class="form-group">
<label>Блайнды</label>
<div class="btn-group">
<button class="btn btn-option active" data-value="5/10" onclick="selectOption(this, 'blinds')">5/10</button>
<button class="btn btn-option" data-value="10/20" onclick="selectOption(this, 'blinds')">10/20</button>
<button class="btn btn-option" data-value="25/50" onclick="selectOption(this, 'blinds')">25/50</button>
</div>
</div>
<div class="form-group">
<label>Макс. игроков</label>
<div class="btn-group">
<button class="btn btn-option" data-value="2" onclick="selectOption(this, 'max-players')">2</button>
<button class="btn btn-option" data-value="4" onclick="selectOption(this, 'max-players')">4</button>
<button class="btn btn-option active" data-value="6" onclick="selectOption(this, 'max-players')">6</button>
</div>
</div>
<button class="btn btn-primary btn-large" onclick="createRoom()">
Создать комнату
</button>
</div>
</div>
</div>
<!-- Лобби комнаты -->
<div id="room-lobby" class="screen">
<div class="glass-container lobby-container">
<button class="btn-back" onclick="leaveRoom()">← Выйти</button>
<h2 id="lobby-room-name">Комната</h2>
<!-- Блок для шаринга ссылки на комнату -->
<div class="room-share-section">
<div class="room-link-display">
<input type="text" id="room-link-input" class="input" readonly onclick="this.select()">
<button class="btn btn-secondary btn-icon-only" onclick="copyRoomLink()" title="Копировать ссылку">
📋
</button>
</div>
<div class="share-buttons">
<button class="btn btn-outline btn-small" onclick="shareToTelegram()">
<span>📱 Telegram</span>
</button>
<button class="btn btn-outline btn-small" onclick="shareToWhatsApp()">
<span>💬 WhatsApp</span>
</button>
<button class="btn btn-outline btn-small" onclick="shareGeneric()" id="share-generic-btn">
<span>🔗 Поделиться</span>
</button>
</div>
</div>
<div class="lobby-players" id="lobby-players">
<!-- Игроки будут добавлены динамически -->
</div>
<div class="lobby-info">
<span>Блайнды: <strong id="lobby-blinds">5/10</strong></span>
<span>Игроки: <strong id="lobby-player-count">1/6</strong></span>
</div>
<button class="btn btn-primary btn-large" id="start-game-btn" onclick="startMultiplayerGame()">
Начать игру
</button>
<div class="lobby-chat">
<div class="chat-messages" id="lobby-chat-messages"></div>
<div class="chat-input-row">
<input type="text" id="lobby-chat-input" class="input" placeholder="Сообщение..." onkeypress="handleLobbyChatKey(event)">
<button class="btn btn-primary" onclick="sendLobbyChat()"></button>
</div>
</div>
</div>
</div>
<!-- Игровой экран -->
<div id="game-screen" class="screen">
<div class="game-container">
<!-- Информация о столе -->
<div class="table-info">
<div class="pot-display glass-card">
<span class="pot-label">Банк</span>
<span class="pot-amount" id="pot-amount">0</span>
</div>
<div class="phase-display glass-card" id="game-phase">
Ожидание
</div>
</div>
<!-- Покерный стол -->
<div class="poker-table">
<div class="table-felt">
<!-- Общие карты -->
<div class="community-cards" id="community-cards">
<!-- Карты будут добавлены динамически -->
</div>
<!-- Позиции игроков -->
<div class="player-positions" id="player-positions">
<!-- Игроки будут добавлены динамически -->
</div>
</div>
</div>
<!-- Карты игрока -->
<div class="player-hand-container glass-card">
<div class="player-info-row">
<div class="player-name-display" id="player-name-display">Игрок</div>
<div class="player-balance-display">
<span class="balance-icon">💰</span>
<span class="balance-amount" id="player-balance">1000</span>
</div>
</div>
<div class="player-cards" id="player-cards">
<div class="card card-back"></div>
<div class="card card-back"></div>
</div>
<div class="hand-strength" id="hand-strength"></div>
</div>
<!-- Панель действий -->
<div class="action-panel glass-card" id="action-panel">
<div class="action-buttons">
<button class="btn btn-fold" onclick="playerAction('fold')">
Фолд
</button>
<button class="btn btn-check" onclick="playerAction('check')" id="btn-check">
Чек
</button>
<button class="btn btn-call" onclick="playerAction('call')" id="btn-call">
Колл <span id="call-amount"></span>
</button>
<button class="btn btn-bet" onclick="showBetSlider()" id="btn-bet">
Бет
</button>
<button class="btn btn-raise" onclick="showBetSlider()" id="btn-raise">
Рейз
</button>
<button class="btn btn-allin" onclick="playerAction('allin')">
All-In
</button>
</div>
<div class="bet-slider-container" id="bet-slider-container" style="display: none;">
<input type="range" id="bet-slider" class="bet-slider" min="0" max="1000" value="0" oninput="updateBetValue()">
<div class="bet-value-display">
<input type="number" id="bet-value" class="bet-input" value="0" onchange="updateSliderFromInput()">
</div>
<div class="bet-presets">
<button class="btn btn-small" onclick="setBetPreset(0.5)">½ Пот</button>
<button class="btn btn-small" onclick="setBetPreset(0.75)">¾ Пот</button>
<button class="btn btn-small" onclick="setBetPreset(1)">Пот</button>
</div>
<div class="bet-confirm">
<button class="btn btn-secondary" onclick="hideBetSlider()">Отмена</button>
<button class="btn btn-primary" onclick="confirmBet()">Подтвердить</button>
</div>
</div>
</div>
<!-- Чат в игре -->
<div class="game-chat glass-card" id="game-chat">
<div class="chat-header" onclick="toggleChat()">
💬 Чат
<span class="chat-toggle"></span>
</div>
<div class="chat-body">
<div class="chat-messages" id="game-chat-messages"></div>
<div class="chat-input-row">
<input type="text" id="game-chat-input" class="input" placeholder="Сообщение..." onkeypress="handleGameChatKey(event)">
<button class="btn btn-primary" onclick="sendGameChat()"></button>
</div>
</div>
</div>
<!-- Правила игры и статистика -->
<div class="game-info-panel glass-card" id="game-info-panel">
<div class="info-header" onclick="toggleGameInfo()">
📊 Правила & Статистика
<span class="info-toggle"></span>
</div>
<div class="info-body">
<div class="info-tabs">
<button class="info-tab active" onclick="switchInfoTab('rules')">Правила</button>
<button class="info-tab" onclick="switchInfoTab('stats')">Статистика</button>
</div>
<!-- Вкладка правил -->
<div class="info-tab-content active" id="info-tab-rules">
<div class="rules-section">
<h4>Комбинации (от старшей к младшей):</h4>
<div class="hand-rankings">
<div class="hand-rank-item">
<span class="rank-number">10</span>
<span class="rank-name">Роял-флеш</span>
<span class="rank-example">A♠ K♠ Q♠ J♠ 10♠</span>
</div>
<div class="hand-rank-item">
<span class="rank-number">9</span>
<span class="rank-name">Стрит-флеш</span>
<span class="rank-example">9♥ 8♥ 7♥ 6♥ 5♥</span>
</div>
<div class="hand-rank-item">
<span class="rank-number">8</span>
<span class="rank-name">Каре</span>
<span class="rank-example">K♠ K♥ K♦ K♣ 3♠</span>
</div>
<div class="hand-rank-item">
<span class="rank-number">7</span>
<span class="rank-name">Фулл-хаус</span>
<span class="rank-example">A♠ A♥ A♦ 8♣ 8♠</span>
</div>
<div class="hand-rank-item">
<span class="rank-number">6</span>
<span class="rank-name">Флеш</span>
<span class="rank-example">Q♦ 9♦ 7♦ 4♦ 2♦</span>
</div>
<div class="hand-rank-item">
<span class="rank-number">5</span>
<span class="rank-name">Стрит</span>
<span class="rank-example">J♠ 10♥ 9♦ 8♣ 7♠</span>
</div>
<div class="hand-rank-item">
<span class="rank-number">4</span>
<span class="rank-name">Сет (тройка)</span>
<span class="rank-example">7♠ 7♥ 7♦ K♣ 2♠</span>
</div>
<div class="hand-rank-item">
<span class="rank-number">3</span>
<span class="rank-name">Две пары</span>
<span class="rank-example">Q♠ Q♥ 5♦ 5♣ 9♠</span>
</div>
<div class="hand-rank-item">
<span class="rank-number">2</span>
<span class="rank-name">Пара</span>
<span class="rank-example">10♠ 10♥ A♦ 6♣ 3♠</span>
</div>
<div class="hand-rank-item">
<span class="rank-number">1</span>
<span class="rank-name">Старшая карта</span>
<span class="rank-example">A♠ J♥ 8♦ 6♣ 2♠</span>
</div>
</div>
</div>
</div>
<!-- Вкладка статистики -->
<div class="info-tab-content" id="info-tab-stats">
<div class="stats-section">
<h4>Ваша текущая рука:</h4>
<div class="current-hand-display" id="current-hand-display">
<div class="hand-cards-preview" id="hand-cards-preview">
Карты не розданы
</div>
<div class="hand-strength-display" id="hand-strength-display">
</div>
</div>
<h4>Шансы на победу:</h4>
<div class="win-probability" id="win-probability">
<div class="probability-bar-container">
<div class="probability-bar" id="probability-bar" style="width: 0%"></div>
</div>
<div class="probability-text" id="probability-text">0%</div>
</div>
<h4>Вероятности улучшения:</h4>
<div class="improvement-odds" id="improvement-odds">
<div class="odds-item">
<span class="odds-label">Следующая карта:</span>
<span class="odds-value" id="odds-next"></span>
</div>
<div class="odds-item">
<span class="odds-label">До вскрытия:</span>
<span class="odds-value" id="odds-river"></span>
</div>
</div>
<h4>Статистика сессии:</h4>
<div class="session-stats">
<div class="stat-row">
<span class="stat-label">Раздач сыграно:</span>
<span class="stat-value" id="stat-hands-played">0</span>
</div>
<div class="stat-row">
<span class="stat-label">Раздач выиграно:</span>
<span class="stat-value" id="stat-hands-won">0</span>
</div>
<div class="stat-row">
<span class="stat-label">Всего выиграно:</span>
<span class="stat-value stat-highlight" id="stat-total-won">0</span>
</div>
<div class="stat-row">
<span class="stat-label">Лучшая рука:</span>
<span class="stat-value" id="stat-best-hand"></span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Кнопки управления -->
<div class="game-controls">
<button class="btn btn-icon-only" onclick="toggleSound()">🔊</button>
<button class="btn btn-icon-only" onclick="leaveGame()">🚪</button>
</div>
<!-- Кнопка новой раздачи (для одиночной игры) -->
<button class="btn btn-primary btn-large new-hand-btn" id="new-hand-btn" style="display: none;" onclick="startNewHand()">
Новая раздача
</button>
</div>
</div>
<!-- Таблица лидеров -->
<div id="leaderboard-screen" class="screen">
<div class="glass-container menu-container">
<button class="btn-back" onclick="showScreen('main-menu')">← Назад</button>
<h2>🏆 Таблица лидеров</h2>
<div class="leaderboard-tabs">
<button class="tab active" onclick="switchLeaderboardTab('local')">Локальные</button>
<button class="tab" onclick="switchLeaderboardTab('global')">Глобальные</button>
</div>
<div class="leaderboard-list" id="leaderboard-list">
<!-- Записи будут добавлены динамически -->
</div>
<button class="btn btn-outline" onclick="clearLeaderboard()">Очистить записи</button>
</div>
</div>
<!-- Настройки -->
<div id="settings-screen" class="screen">
<div class="glass-container menu-container">
<button class="btn-back" onclick="showScreen('main-menu')">← Назад</button>
<h2>⚙️ Настройки</h2>
<div class="settings-list">
<div class="setting-item">
<span>Звуковые эффекты</span>
<label class="switch">
<input type="checkbox" id="setting-sound" checked onchange="updateSettings()">
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<span>Анимации</span>
<label class="switch">
<input type="checkbox" id="setting-animations" checked onchange="updateSettings()">
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<span>Показывать силу руки</span>
<label class="switch">
<input type="checkbox" id="setting-hand-strength" checked onchange="updateSettings()">
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<span>Автофолд при отключении</span>
<label class="switch">
<input type="checkbox" id="setting-autofold" checked onchange="updateSettings()">
<span class="slider"></span>
</label>
</div>
</div>
<h3 style="margin-top: 24px; margin-bottom: 16px;">🎴 Рубашка карт</h3>
<div class="card-back-settings">
<div class="card-back-preview">
<div class="card card-back" id="card-back-preview">
<div class="card-back-pattern"></div>
</div>
<span class="preview-label">Превью</span>
</div>
<div class="card-back-options">
<div class="form-group">
<label>Выберите стиль рубашки</label>
<div class="btn-group card-back-styles">
<button class="btn btn-option active" data-style="default" onclick="selectCardBack(this)">🎰 Классика</button>
<button class="btn btn-option" data-style="red" onclick="selectCardBack(this)">🔴 Красная</button>
<button class="btn btn-option" data-style="blue" onclick="selectCardBack(this)">🔵 Синяя</button>
<button class="btn btn-option" data-style="custom" onclick="selectCardBack(this)">🖼️ Своя</button>
</div>
</div>
<div class="form-group" id="custom-card-back-group" style="display: none;">
<label>Загрузить изображение</label>
<input type="file" id="card-back-file" class="input" accept="image/*" onchange="loadCustomCardBack(this)">
<small style="color: var(--text-muted); display: block; margin-top: 4px;">Рекомендуемый размер: 70×100 px</small>
</div>
<div class="form-group" id="card-back-url-group" style="display: none;">
<label>Или вставьте URL изображения</label>
<input type="text" id="card-back-url" class="input" placeholder="https://example.com/card-back.png" onchange="setCardBackUrl(this.value)">
</div>
</div>
</div>
<div class="form-group admin-only" id="server-url-group" style="display: none;">
<label>Сервер WebSocket</label>
<input type="text" id="server-url" class="input" value="ws://localhost:3000" onchange="updateSettings()">
</div>
<!-- LLM статус (только чтение для пользователей) -->
<div class="llm-status-box" id="llm-status-box">
<span class="llm-status-icon" id="llm-status-icon">🤖</span>
<span id="llm-status-text">LLM чат: загрузка...</span>
</div>
<!-- Кнопка настройки LLM для админа -->
<div class="admin-only" id="admin-llm-button-group" style="display: none; margin-top: 12px;">
<button class="btn btn-primary" onclick="showScreen('admin-screen')">
👑 Настроить LLM (Админ-панель)
</button>
</div>
<div style="margin-top: 16px;"></div>
<button class="btn btn-outline" onclick="resetSettings()">Сбросить настройки</button>
</div>
</div>
<!-- Админ-панель -->
<div id="admin-screen" class="screen">
<div class="glass-container admin-container">
<button class="btn-back" onclick="showScreen('main-menu')">← Назад</button>
<h2>👑 Админ-панель</h2>
<div class="admin-tabs">
<button class="btn btn-tab active" onclick="switchAdminTab('settings')">⚙️ Настройки</button>
<button class="btn btn-tab" onclick="switchAdminTab('bot-prompts')">🤖 Промпты ботов</button>
<button class="btn btn-tab" onclick="switchAdminTab('logs')">📋 Логи</button>
<button class="btn btn-tab" onclick="switchAdminTab('users')">👥 Пользователи</button>
</div>
<!-- Вкладка настроек -->
<div id="admin-tab-settings" class="admin-tab-content">
<h3>🤖 Настройки LLM чата</h3>
<div class="settings-list">
<div class="setting-item">
<span>Включить LLM чат для всех</span>
<label class="switch">
<input type="checkbox" id="admin-llm-enabled" onchange="saveAdminSettings()">
<span class="slider"></span>
</label>
</div>
</div>
<div class="form-group">
<label>LLM провайдер</label>
<select id="admin-llm-provider" class="input" onchange="saveAdminSettings()">
<option value="ollama">Ollama</option>
<option value="lmstudio">LM Studio</option>
<option value="openai">OpenAI</option>
</select>
</div>
<div class="form-group">
<label>URL API</label>
<input type="text" id="admin-llm-url" class="input" value="http://localhost:11434" onchange="saveAdminSettings()">
</div>
<div class="form-group">
<label>Модель</label>
<input type="text" id="admin-llm-model" class="input" value="llama3.2" onchange="saveAdminSettings()">
</div>
<div class="form-group">
<label>API Key (для OpenAI)</label>
<input type="password" id="admin-llm-apikey" class="input" placeholder="sk-..." onchange="saveAdminSettings()">
</div>
<button class="btn btn-secondary" onclick="testAdminLLM()">🔗 Проверить подключение</button>
</div>
<!-- Вкладка промптов ботов -->
<div id="admin-tab-bot-prompts" class="admin-tab-content" style="display: none;">
<h3>🤖 Персональности ботов</h3>
<p style="color: var(--text-muted); margin-bottom: 16px; font-size: 13px;">
Настройте системные промпты для персональностей ботов.
Каждый бот имеет уникальную личность и стиль общения.
</p>
<!-- Селектор персональности -->
<div class="form-group">
<label>Выберите персональность для настройки</label>
<select id="bot-personality-selector" class="input" onchange="loadBotPersonalityPrompt()">
<!-- Опции будут загружены динамически из ai.js -->
</select>
</div>
<!-- Системный промпт -->
<div class="form-group">
<label>Системный промпт персональности</label>
<textarea
id="bot-system-prompt"
class="input prompt-textarea"
rows="20"
placeholder="Полный системный промпт персональности..."
onchange="saveBotPersonalityPrompt()"
></textarea>
<small style="color: var(--text-muted); display: block; margin-top: 4px;">
Определяет характер, стиль общения и поведение бота во всех ситуациях
</small>
</div>
<div class="prompt-actions">
<button class="btn btn-secondary" onclick="resetBotPersonalityPrompt()">
🔄 Сбросить к оригиналу
</button>
<button class="btn btn-primary" onclick="testBotPersonalityPrompt()">
🧪 Тестировать промпт
</button>
<button class="btn btn-success" onclick="saveBotPersonalityPrompt()">
💾 Сохранить промпт
</button>
</div>
<!-- Результат теста -->
<div id="prompt-test-result" class="prompt-test-result" style="display: none;">
<h4>Результат тестирования:</h4>
<div class="test-result-content" id="test-result-content"></div>
</div>
</div>
<!-- Вкладка логов -->
<div id="admin-tab-logs" class="admin-tab-content" style="display: none;">
<h3>📋 История действий</h3>
<div class="log-stats" id="log-stats">
<div class="stat-item">
<span class="stat-value" id="stat-total">0</span>
<span class="stat-label">Всего</span>
</div>
<div class="stat-item">
<span class="stat-value" id="stat-today">0</span>
<span class="stat-label">Сегодня</span>
</div>
</div>
<div class="log-filters">
<select id="log-filter-type" class="input" onchange="loadLogs()">
<option value="">Все действия</option>
<option value="login">Входы</option>
<option value="register">Регистрации</option>
<option value="room_created">Создание комнат</option>
<option value="chat_message">Сообщения чата</option>
<option value="game_action">Игровые действия</option>
</select>
<button class="btn btn-secondary" onclick="loadLogs()">🔄 Обновить</button>
</div>
<div class="log-table-container">
<table class="log-table" id="log-table">
<thead>
<tr>
<th>Время</th>
<th>Пользователь</th>
<th>Действие</th>
<th>Детали</th>
</tr>
</thead>
<tbody id="log-table-body">
</tbody>
</table>
</div>
<div class="log-pagination">
<button class="btn btn-small" onclick="loadLogs(currentLogPage - 1)" id="log-prev-btn">← Назад</button>
<span id="log-page-info">Страница 1</span>
<button class="btn btn-small" onclick="loadLogs(currentLogPage + 1)" id="log-next-btn">Вперёд →</button>
</div>
</div>
<!-- Вкладка пользователей -->
<div id="admin-tab-users" class="admin-tab-content" style="display: none;">
<h3>👥 Управление пользователями</h3>
<!-- Статистика -->
<div class="user-stats" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 20px;">
<div class="stat-card">
<div class="stat-value" id="stat-total-users">0</div>
<div class="stat-label">Всего</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-active-users">0</div>
<div class="stat-label">Активных (сегодня)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-banned-users">0</div>
<div class="stat-label">Заблокировано</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-admin-users">0</div>
<div class="stat-label">Админов</div>
</div>
</div>
<!-- Фильтры -->
<div class="user-filters" style="display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap;">
<select id="user-filter-role" class="input" onchange="loadUsers()" style="flex: 1; min-width: 150px;">
<option value="">Все роли</option>
<option value="admin">Админы</option>
<option value="user">Пользователи</option>
</select>
<select id="user-filter-status" class="input" onchange="loadUsers()" style="flex: 1; min-width: 150px;">
<option value="">Все статусы</option>
<option value="active">Активные</option>
<option value="banned">Заблокированные</option>
</select>
<input type="text" id="user-search" class="input" placeholder="🔍 Поиск по логину..."
oninput="loadUsers()" style="flex: 2; min-width: 200px;">
</div>
<!-- Таблица пользователей -->
<div class="users-table-container" style="overflow-x: auto;">
<table class="users-table" style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: var(--bg-secondary); text-align: left;">
<th style="padding: 12px; border-bottom: 2px solid var(--border-color);">Логин</th>
<th style="padding: 12px; border-bottom: 2px solid var(--border-color);">Роль</th>
<th style="padding: 12px; border-bottom: 2px solid var(--border-color);">IP адрес</th>
<th style="padding: 12px; border-bottom: 2px solid var(--border-color);">Последний вход</th>
<th style="padding: 12px; border-bottom: 2px solid var(--border-color);">Статус</th>
<th style="padding: 12px; border-bottom: 2px solid var(--border-color); text-align: center;">Действия</th>
</tr>
</thead>
<tbody id="users-table-body">
<tr>
<td colspan="6" style="padding: 20px; text-align: center; color: var(--text-muted);">
Загрузка пользователей...
</td>
</tr>
</tbody>
</table>
</div>
<!-- Модальное окно редактирования пользователя -->
<div id="edit-user-modal" class="modal" style="display: none;">
<div class="modal-content glass-container" style="max-width: 500px;">
<h3>✏️ Редактирование пользователя</h3>
<div class="form-group">
<label>Логин</label>
<input type="text" id="edit-user-username" class="input" readonly style="background: var(--bg-secondary);">
</div>
<div class="form-group">
<label>Роль</label>
<select id="edit-user-role" class="input">
<option value="user">👤 Пользователь</option>
<option value="admin">👑 Администратор</option>
</select>
</div>
<div class="form-group">
<label>Статус</label>
<select id="edit-user-status" class="input">
<option value="active">✅ Активен</option>
<option value="banned">🚫 Заблокирован</option>
</select>
</div>
<div class="form-group">
<label>Новый пароль (оставьте пустым, чтобы не менять)</label>
<input type="password" id="edit-user-password" class="input" placeholder="Новый пароль...">
</div>
<div style="display: flex; gap: 12px; margin-top: 20px;">
<button class="btn btn-success" onclick="saveUserEdit()">💾 Сохранить</button>
<button class="btn btn-secondary" onclick="closeEditUserModal()">❌ Отмена</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Результаты раздачи -->
<div id="hand-result-modal" class="modal">
<div class="modal-content glass-container">
<h3 id="result-title">Результат</h3>
<div class="result-details" id="result-details"></div>
<button class="btn btn-primary" onclick="closeResultModal()">Продолжить</button>
</div>
</div>
<!-- Уведомления -->
<div class="notifications" id="notifications"></div>
<script src="game.js"></script>
<script src="ai.js"></script>
<script src="auth.js"></script>
<script src="main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1588
server.js

File diff suppressed because it is too large Load Diff