From 511f86d4e4fc4c0e420459f5b7c9a1e0e672dd88 Mon Sep 17 00:00:00 2001
From: jahlib <60108339+jahlib@users.noreply.github.com>
Date: Sat, 1 Nov 2025 15:55:11 +0300
Subject: [PATCH] Czech Fool - cards game: initial commit
---
POSTGRES_SETUP.md | 107 ++
README.md | 248 +++++
START_POSTGRES.md | 113 ++
SYSTEMD_SETUP.md | 156 +++
client/game.js | 1375 ++++++++++++++++++++++++
client/icon-192.png | Bin 0 -> 2719 bytes
client/icon-512.png | Bin 0 -> 8433 bytes
client/index.html | 386 +++++++
client/manifest.json | 26 +
client/marked.min.js | 69 ++
client/rules.md | 165 +++
client/sounds/ace.aac | Bin 0 -> 17833 bytes
client/sounds/ace.ogg | Bin 0 -> 11565 bytes
client/sounds/alert.aac | Bin 0 -> 21177 bytes
client/sounds/alert.ogg | Bin 0 -> 12441 bytes
client/sounds/change.aac | Bin 0 -> 27307 bytes
client/sounds/change.ogg | Bin 0 -> 18655 bytes
client/sounds/chat.aac | Bin 0 -> 14490 bytes
client/sounds/chat.ogg | Bin 0 -> 8455 bytes
client/sounds/drawcard.aac | Bin 0 -> 5378 bytes
client/sounds/drawcard.ogg | Bin 0 -> 6387 bytes
client/sounds/eight.aac | Bin 0 -> 12261 bytes
client/sounds/eight.ogg | Bin 0 -> 10233 bytes
client/sounds/eightplace.aac | Bin 0 -> 40682 bytes
client/sounds/eightplace.ogg | Bin 0 -> 17106 bytes
client/sounds/lose.aac | Bin 0 -> 56843 bytes
client/sounds/lose.ogg | Bin 0 -> 38168 bytes
client/sounds/playcard.aac | Bin 0 -> 9474 bytes
client/sounds/playcard.ogg | Bin 0 -> 8415 bytes
client/sounds/seven.aac | Bin 0 -> 14490 bytes
client/sounds/seven.ogg | Bin 0 -> 10220 bytes
client/sounds/shuffle.aac | Bin 0 -> 52942 bytes
client/sounds/shuffle.ogg | Bin 0 -> 29158 bytes
client/sounds/six.aac | Bin 0 -> 13932 bytes
client/sounds/six.ogg | Bin 0 -> 6402 bytes
client/sounds/skip.aac | Bin 0 -> 37338 bytes
client/sounds/skip.ogg | Bin 0 -> 11391 bytes
client/sounds/two.aac | Bin 0 -> 33697 bytes
client/sounds/two.ogg | Bin 0 -> 21654 bytes
client/sounds/win.aac | Bin 0 -> 194600 bytes
client/sounds/win.ogg | Bin 0 -> 122246 bytes
client/style.css | 1906 ++++++++++++++++++++++++++++++++++
client/sw.js | 88 ++
czech-fool.service | 23 +
database_postgres.py | 338 ++++++
requirements.txt | 2 +
server.py | 1667 +++++++++++++++++++++++++++++
setup_postgres.sql | 23 +
ПРАВИЛА_ИГРЫ.md | 145 +++
49 files changed, 6837 insertions(+)
create mode 100644 POSTGRES_SETUP.md
create mode 100644 README.md
create mode 100644 START_POSTGRES.md
create mode 100644 SYSTEMD_SETUP.md
create mode 100644 client/game.js
create mode 100644 client/icon-192.png
create mode 100644 client/icon-512.png
create mode 100644 client/index.html
create mode 100644 client/manifest.json
create mode 100644 client/marked.min.js
create mode 100644 client/rules.md
create mode 100644 client/sounds/ace.aac
create mode 100644 client/sounds/ace.ogg
create mode 100644 client/sounds/alert.aac
create mode 100644 client/sounds/alert.ogg
create mode 100644 client/sounds/change.aac
create mode 100644 client/sounds/change.ogg
create mode 100644 client/sounds/chat.aac
create mode 100644 client/sounds/chat.ogg
create mode 100644 client/sounds/drawcard.aac
create mode 100644 client/sounds/drawcard.ogg
create mode 100644 client/sounds/eight.aac
create mode 100644 client/sounds/eight.ogg
create mode 100644 client/sounds/eightplace.aac
create mode 100644 client/sounds/eightplace.ogg
create mode 100644 client/sounds/lose.aac
create mode 100644 client/sounds/lose.ogg
create mode 100644 client/sounds/playcard.aac
create mode 100644 client/sounds/playcard.ogg
create mode 100644 client/sounds/seven.aac
create mode 100644 client/sounds/seven.ogg
create mode 100644 client/sounds/shuffle.aac
create mode 100644 client/sounds/shuffle.ogg
create mode 100644 client/sounds/six.aac
create mode 100644 client/sounds/six.ogg
create mode 100644 client/sounds/skip.aac
create mode 100644 client/sounds/skip.ogg
create mode 100644 client/sounds/two.aac
create mode 100644 client/sounds/two.ogg
create mode 100644 client/sounds/win.aac
create mode 100644 client/sounds/win.ogg
create mode 100644 client/style.css
create mode 100644 client/sw.js
create mode 100644 czech-fool.service
create mode 100644 database_postgres.py
create mode 100644 requirements.txt
create mode 100644 server.py
create mode 100644 setup_postgres.sql
create mode 100644 ПРАВИЛА_ИГРЫ.md
diff --git a/POSTGRES_SETUP.md b/POSTGRES_SETUP.md
new file mode 100644
index 0000000..8127064
--- /dev/null
+++ b/POSTGRES_SETUP.md
@@ -0,0 +1,107 @@
+# Настройка PostgreSQL для игры
+
+## 1. Установка PostgreSQL
+
+```bash
+# Ubuntu/Debian
+sudo apt update
+sudo apt install postgresql postgresql-contrib
+
+# Проверка статуса
+sudo systemctl status postgresql
+```
+
+## 2. Создание базы данных и пользователя
+
+```bash
+# Войти в PostgreSQL
+sudo -u postgres psql
+
+# Выполнить SQL команды
+\i /path/to/setup_postgres.sql
+
+# Или вручную:
+CREATE DATABASE card_game;
+CREATE USER card_game_user WITH PASSWORD 'your_secure_password_here';
+GRANT ALL PRIVILEGES ON DATABASE card_game TO card_game_user;
+\c card_game
+GRANT ALL ON SCHEMA public TO card_game_user;
+GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO card_game_user;
+GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO card_game_user;
+ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO card_game_user;
+ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO card_game_user;
+
+# Выход
+\q
+```
+
+## 3. Настройка подключения
+
+Отредактируйте `/etc/postgresql/*/main/pg_hba.conf`:
+
+```
+# Добавьте строку для локального подключения
+local card_game card_game_user md5
+host card_game card_game_user 127.0.0.1/32 md5
+```
+
+Перезапустите PostgreSQL:
+```bash
+sudo systemctl restart postgresql
+```
+
+## 4. Установка Python зависимостей
+
+```bash
+pip install asyncpg
+```
+
+## 5. Настройка в коде
+
+В `server.py` измените параметры подключения:
+
+```python
+from database_postgres import GameDatabase
+
+# В __init__:
+self.db = GameDatabase(
+ host="localhost",
+ port=5432,
+ database="card_game",
+ user="card_game_user",
+ password="your_secure_password_here"
+)
+
+# В main():
+await game_server.db.connect()
+```
+
+## 6. Проверка подключения
+
+```bash
+psql -U card_game_user -d card_game -h localhost
+# Введите пароль
+```
+
+## Преимущества PostgreSQL vs SQLite
+
+- ✅ **Асинхронные операции** - не блокируют игру
+- ✅ **Пул соединений** - быстрое переиспользование
+- ✅ **Параллельные запросы** - несколько игроков одновременно
+- ✅ **Лучшая производительность** - оптимизированные индексы
+- ✅ **MVCC** - нет блокировок при чтении
+- ✅ **Масштабируемость** - готово к росту нагрузки
+
+## Мониторинг
+
+```sql
+-- Активные соединения
+SELECT * FROM pg_stat_activity WHERE datname = 'card_game';
+
+-- Размер базы
+SELECT pg_size_pretty(pg_database_size('card_game'));
+
+-- Статистика таблиц
+SELECT schemaname, tablename, n_tup_ins, n_tup_upd, n_tup_del
+FROM pg_stat_user_tables;
+```
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6bdc8b0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,248 @@
+# 🎴 Чешский дурак
+
+Многопользовательская карточная игра на WebSocket с уникальными правилами и специальными картами.
+
+## Правила игры
+
+### Колода
+- Стандартная колода из 52 карт
+- 4 масти: ♥️ Черви, ♦️ Бубны, ♣️ Трефы, ♠️ Пики
+- 13 рангов: 2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K, A
+- Когда колода заканчивается, все карты из сброса (кроме верхней) перемешиваются и становятся новой колодой
+
+### Начало игры
+- Каждому игроку раздаётся по 5 карт
+- Одна карта кладётся рубашкой вниз на поле (первый ход дилера)
+- Колода кладётся рядом
+- Ход делает игрок после дилера
+- **Первая карта имеет эффект:**
+ - Если **6** или **7**: Следующий игрок берёт карты и пропускает ход
+ - Если **8**: Следующий игрок должен брать карты или положить двойку
+ - Если **A**: Следующий игрок пропускает ход
+ - Если **Q**: Дилер автоматически выбирает масть (случайную)
+
+### Ходы
+- Карты кладутся по принципу UNO: масть на масть или номинал на номинал
+- **Взятие карт:**
+ - Можно взять карту из колоды даже если есть чем ходить (только 1 раз за ход)
+ - После взятия карты появляется кнопка "Пропустить ход"
+ - Вы можете:
+ - Сыграть любую подходящую карту из руки (включая только что взятую)
+ - Или нажать "Пропустить ход" чтобы передать ход следующему игроку
+- Верхняя карта на поле остаётся лежать пока её не покроют следующей картой
+
+### Специальные карты
+
+**Шестёрка (6)**: Следующий игрок берёт 1 карту и пропускает ход
+
+**Семёрка (7)**: Следующий игрок берёт 2 карты и пропускает ход
+
+**Восьмёрка (8)**: Следующий игрок должен:
+- **Из руки**: Может положить только любую двойку (2) любой масти
+- **Из колоды**: Берёт карты пока не попадётся:
+ - Любая двойка (2)
+ - Любая дама (Q)
+ - Карта той же масти что и восьмёрка
+
+**Туз (A)**: Следующий игрок пропускает ход
+
+**Дама (Q)**:
+- Можно положить на любую карту
+- Игрок выбирает следующую масть
+- Выбранная масть отображается над картой на поле в золотом бейдже
+- Индикатор масти остаётся пока её не покроют другой картой
+- Следующий игрок должен положить карту выбранной масти (или даму)
+- **ВАЖНО**: Если дама - последняя карта, вычитается 20 очков (пиковая дама - 40 очков)
+
+### Победа и подсчёт очков
+
+Побеждает тот, кто первым сбросил все карты.
+
+Остальным начисляются очки за оставшиеся карты:
+- Цифры (2-10) = номинал карты
+- Валет (J) = 2 очка
+- Дама (Q) = 20 очков (пиковая дама = 40 очков)
+- Король (K) = 4 очка
+- Туз (A) = 11 очков
+
+**Специальные правила:**
+- **Победа дамой**: Вычитается 20 очков (пиковая дама - 40)
+- **Ровно 101 очко**: Счёт обнуляется до 0
+- **Больше 101 очка**: Игрок выбывает из игры
+- **Дилер следующего раунда**: Проигравший предыдущего раунда
+
+## Установка и запуск
+
+### Требования
+- Python 3.8+
+
+### Установка зависимостей
+
+```bash
+pip install -r requirements.txt
+```
+
+### Запуск сервера
+
+```bash
+python server.py
+```
+
+Сервер запустится на:
+- WebSocket: `ws://localhost:8765`
+- HTTP: `http://localhost:8080`
+
+### Игра
+
+#### Локально (на одном компьютере):
+1. Откройте браузер и перейдите на `http://localhost:8080`
+2. Введите никнейм и создайте комнату или присоединитесь к существующей
+3. Дождитесь минимум 2 игроков
+4. Все игроки нажимают "Готов"
+5. Игра начинается автоматически!
+
+#### По сети (с друзьями):
+1. **Хост (тот кто запустил сервер):**
+ - Узнайте свой IP адрес:
+ - Linux/Mac: `ip addr` или `ifconfig`
+ - Windows: `ipconfig`
+ - Откройте `http://localhost:8080` или `http://ВАШ_IP:8080`
+ - Создайте комнату
+
+2. **Другие игроки:**
+ - Откройте `http://IP_ХОСТА:8080` в браузере
+ - Присоединитесь к комнате
+
+3. **Важно:** Убедитесь что порты 8080 и 8765 открыты в файрволе
+
+**Примечание:** Каждый игрок видит только свои карты! Карты других игроков скрыты.
+
+#### С reverse proxy (HTTPS/WSS):
+Если вы используете reverse proxy (nginx, caddy и т.д.) с доменами:
+
+1. **Настройте reverse proxy:**
+ - HTTP/HTTPS домен (например `domain.com`) → `localhost:8080`
+ - WebSocket домен (например `ws.domain.com`) → `localhost:8765`
+
+2. **Раскомментируйте в `client/index.html`:**
+ ```html
+
+ ```
+ Укажите ваш WebSocket домен без протокола (wss:// добавится автоматически)
+
+3. **Пример конфигурации nginx:**
+ ```nginx
+ # HTTP сервер
+ server {
+ listen 443 ssl;
+ server_name domain.com;
+
+ location / {
+ proxy_pass http://localhost:8080;
+ proxy_http_version 1.1;
+ }
+ }
+
+ # WebSocket сервер
+ server {
+ listen 443 ssl;
+ server_name ws.domain.com;
+
+ location / {
+ proxy_pass http://localhost:8765;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+ }
+ ```
+
+## Структура проекта
+
+```
+chekh/
+├── server.py # Серверная часть на Python
+├── requirements.txt # Зависимости Python
+├── game_state.db # SQLite база данных (создаётся автоматически)
+├── client/
+│ ├── index.html # HTML интерфейс
+│ ├── style.css # Стили (разработка)
+│ ├── style.min.css # Минифицированные стили (продакшн)
+│ ├── game.js # Клиентская логика
+│ ├── sw.js # Service Worker для PWA
+│ ├── manifest.json # PWA манифест
+│ ├── rules.md # Правила игры (Markdown)
+│ ├── sounds/ # Звуковые эффекты (.aac)
+│ └── icons/ # Иконки для PWA
+├── rules.md # Правила игры
+└── README.md # Документация
+```
+
+## Технологии
+
+- **Backend**: Python, websockets, aiohttp, SQLite
+- **Frontend**: Vanilla JavaScript, HTML5, CSS3
+- **Протокол**: WebSocket для real-time коммуникации
+- **База данных**: SQLite для сохранения состояния игры
+- **Хранилище**: localStorage для идентификации игроков
+
+## Особенности
+
+### 🎮 Игровой процесс
+- ✅ Real-time многопользовательская игра (2-4 игрока)
+- ✅ **Круговой игровой стол** с визуальным отображением
+- ✅ **Карты противников отображаются рубашками** по количеству
+- ✅ Полная реализация всех правил и специальных карт
+- ✅ Система подсчёта очков с особыми правилами (101 очко)
+- ✅ **Игра с ботами** (можно играть с 1, 2 или 3 ботами)
+- ✅ **Умные боты** с приоритетом специальных карт и оценкой очков
+
+### 💾 Сохранение и переподключение
+- ✅ **Сохранение состояния игры в SQLite**
+- ✅ **URL для комнат**: `/room/{room_id}`
+- ✅ **Продолжение игры после обновления страницы**
+- ✅ **localStorage для идентификации игроков**
+- ✅ Автоматическое переподключение при разрыве связи
+- ✅ **Page Visibility API** - игра останавливается в фоне
+
+### 🎨 Интерфейс
+- ✅ Современный и красивый UI с градиентами
+- ✅ Адаптивный дизайн (десктоп + мобильные)
+- ✅ **Анимации** карт и действий
+- ✅ **Звуковые эффекты** для всех игровых событий (.aac)
+- ✅ **Лог игры** (последние 10 событий)
+- ✅ **Чат** для общения между игроками
+- ✅ **Правила игры** встроены в интерфейс (Markdown)
+
+### ⌨️ Управление
+- ✅ **Горячие клавиши**:
+ - `T` / `Е` - открыть чат
+ - `Enter` - отправить сообщение
+ - `Пробел` - взять карту / пропустить ход
+- ✅ Кнопки управления звуком, полноэкранным режимом
+
+### 📱 PWA (Progressive Web App)
+- ✅ **Установка как приложение** на телефон/десктоп
+- ✅ **Service Worker** для офлайн-кэширования
+- ✅ **Манифест** с иконками и настройками
+- ✅ Работает без интернета (после первой загрузки)
+
+### Игровой интерфейс:
+
+**Круговое расположение игроков:**
+- **Центр**: Текущая карта на поле + колода справа
+- **Внизу**: Ваши карты (повёрнуты к вам)
+- **Сверху**: Противник (2 игрока) - карты рубашками
+- **Слева**: Противник (3+ игрока) - карты повёрнуты на 90°
+- **Справа**: Противник (4 игрока) - карты повёрнуты на 90°
+- **Подсветка**: Текущий игрок подсвечивается синим с анимацией
+
+### Как работает сохранение:
+
+1. При создании/присоединении к комнате URL меняется на `/room/{room_id}`
+2. Ваш `player_id` сохраняется в localStorage
+3. При обновлении страницы игра автоматически восстанавливается
+4. При выходе из комнаты localStorage очищается
+5. Все состояние игры хранится в SQLite базе данных
+
+Приятной игры! 🎮
diff --git a/START_POSTGRES.md b/START_POSTGRES.md
new file mode 100644
index 0000000..fd71954
--- /dev/null
+++ b/START_POSTGRES.md
@@ -0,0 +1,113 @@
+# Запуск с PostgreSQL
+
+## 1. Установите asyncpg
+
+```bash
+pip install asyncpg
+```
+
+## 2. Укажите пароль в server.py
+
+Откройте `/opt/czech-fool/server.py` и найдите строку 80:
+
+```python
+password="password" # Замените на ваш пароль
+```
+
+Замените `password` на пароль пользователя `czech` в PostgreSQL.
+
+## 3. Проверьте подключение
+
+```bash
+psql -U czech -d czechdb -h localhost
+# Введите пароль
+```
+
+Если подключение успешно, выйдите: `\q`
+
+## 4. Запустите сервер
+
+```bash
+cd /opt/czech-fool
+python3 server.py
+```
+
+Вы должны увидеть:
+```
+Connecting to PostgreSQL...
+PostgreSQL connected successfully!
+Room stats: 0 total, 0 active games, 0 inactive >1h, 0 inactive >24h
+WebSocket server started on ws://0.0.0.0:8765
+HTTP server started on http://0.0.0.0:8080
+Open https://game.ruwk.ru in your browser
+Automatic cleanup task started (runs every 6 hours)
+```
+
+## Что изменилось
+
+### Производительность
+- ✅ **Асинхронные операции** - игра не зависает при сохранении
+- ✅ **Пул соединений** - 2-10 одновременных подключений
+- ✅ **Батчинг** - массовая вставка карт
+- ✅ **Нет блокировок** - MVCC в PostgreSQL
+
+### База данных
+- База: `czechdb`
+- Пользователь: `czech`
+- Порт: `5432`
+- Хост: `localhost`
+
+### Сохранение
+Теперь сохранение происходит:
+- ✅ При создании комнаты
+- ✅ При присоединении игрока
+- ✅ При старте игры
+- ✅ При каждом ходе (асинхронно!)
+- ✅ При взятии карты
+- ✅ При окончании игры
+- ❌ НЕ блокирует игру!
+
+## Мониторинг
+
+### Проверка активных соединений
+```sql
+SELECT * FROM pg_stat_activity WHERE datname = 'czechdb';
+```
+
+### Статистика таблиц
+```sql
+SELECT tablename, n_tup_ins, n_tup_upd, n_tup_del
+FROM pg_stat_user_tables;
+```
+
+### Размер базы
+```sql
+SELECT pg_size_pretty(pg_database_size('czechdb'));
+```
+
+## Troubleshooting
+
+### Ошибка: "password authentication failed"
+Проверьте пароль в `server.py` строка 80.
+
+### Ошибка: "database does not exist"
+Создайте базу:
+```bash
+sudo -u postgres psql
+CREATE DATABASE czechdb;
+GRANT ALL PRIVILEGES ON DATABASE czechdb TO czech;
+```
+
+### Ошибка: "role does not exist"
+Создайте пользователя:
+```bash
+sudo -u postgres psql
+CREATE USER czech WITH PASSWORD 'password';
+```
+
+### Ошибка: "could not connect to server"
+Запустите PostgreSQL:
+```bash
+sudo systemctl start postgresql
+sudo systemctl enable postgresql
+```
diff --git a/SYSTEMD_SETUP.md b/SYSTEMD_SETUP.md
new file mode 100644
index 0000000..a6a73ea
--- /dev/null
+++ b/SYSTEMD_SETUP.md
@@ -0,0 +1,156 @@
+# Настройка systemd service для игры
+
+## Установка service
+
+### 1. Скопируйте файл service
+
+```bash
+sudo cp /opt/czech-fool/czech-fool.service /etc/systemd/system/
+```
+
+### 2. Перезагрузите systemd
+
+```bash
+sudo systemctl daemon-reload
+```
+
+### 3. Включите автозапуск
+
+```bash
+sudo systemctl enable czech-fool
+```
+
+### 4. Запустите сервис
+
+```bash
+sudo systemctl start czech-fool
+```
+
+## Управление сервисом
+
+### Проверка статуса
+```bash
+sudo systemctl status czech-fool
+```
+
+### Просмотр логов
+```bash
+sudo journalctl -u czech-fool -f
+```
+
+### Просмотр последних 100 строк логов
+```bash
+sudo journalctl -u czech-fool -n 100
+```
+
+### Перезапуск (с очисткой комнат)
+```bash
+sudo systemctl restart czech-fool
+```
+
+### Остановка
+```bash
+sudo systemctl stop czech-fool
+```
+
+### Отключение автозапуска
+```bash
+sudo systemctl disable czech-fool
+```
+
+## Флаг --clearrooms
+
+Флаг `--clearrooms` в файле service:
+```
+ExecStart=/usr/bin/python3 /opt/czech-fool/server.py --clearrooms
+```
+
+### Что делает:
+- ✅ Удаляет **ВСЕ** комнаты из базы данных при запуске
+- ✅ Очищает зависшие комнаты с отключенными игроками
+- ✅ Начинает с чистого листа
+
+### Когда использовать:
+- При рестарте сервера
+- После обновления кода
+- Для очистки зависших комнат
+
+### Без флага:
+Если убрать `--clearrooms` из ExecStart:
+```
+ExecStart=/usr/bin/python3 /opt/czech-fool/server.py
+```
+- Загружает существующие комнаты из БД
+- Удаляет только старые (>24 часов)
+- Игроки могут продолжить игру после рестарта
+
+## Редактирование service
+
+```bash
+sudo systemctl edit --full czech-fool
+```
+
+После изменений:
+```bash
+sudo systemctl daemon-reload
+sudo systemctl restart czech-fool
+```
+
+## Проверка работы
+
+### 1. Проверьте что сервис запущен
+```bash
+sudo systemctl is-active czech-fool
+# Должно вывести: active
+```
+
+### 2. Проверьте логи запуска
+```bash
+sudo journalctl -u czech-fool -n 50
+```
+
+Должны увидеть:
+```
+Starting with --clearrooms flag: all rooms will be deleted
+Connecting to PostgreSQL...
+PostgreSQL connected successfully!
+Clearing all rooms from database...
+Deleted X rooms from database
+WebSocket server started on ws://0.0.0.0:8765
+HTTP server started on http://0.0.0.0:8080
+```
+
+### 3. Проверьте порты
+```bash
+sudo netstat -tulpn | grep python3
+```
+
+Должны быть открыты:
+- `0.0.0.0:8765` (WebSocket)
+- `0.0.0.0:8080` (HTTP)
+
+## Troubleshooting
+
+### Сервис не запускается
+```bash
+sudo journalctl -u czech-fool -n 100
+```
+
+### Ошибка подключения к PostgreSQL
+Проверьте что PostgreSQL запущен:
+```bash
+sudo systemctl status postgresql
+```
+
+### Ошибка прав доступа
+Убедитесь что ваш пользователь может читать файлы:
+```bash
+ls -la /opt/czech-fool/server.py
+```
+
+### Порты заняты
+Проверьте что порты 8765 и 8080 свободны:
+```bash
+sudo lsof -i :8765
+sudo lsof -i :8080
+```
diff --git a/client/game.js b/client/game.js
new file mode 100644
index 0000000..1c28f2e
--- /dev/null
+++ b/client/game.js
@@ -0,0 +1,1375 @@
+class CardGame {
+ constructor() {
+ this.ws = null;
+ this.playerId = null;
+ this.roomId = null;
+ this.currentRoom = null;
+ this.hand = [];
+ this.pendingCardToPlay = null;
+ this.eightDrawnCards = []; // ID карт взятых из колоды на восьмёрку
+
+ // Загружаем состояние звука из localStorage
+ this.soundEnabled = localStorage.getItem('soundEnabled') !== 'false';
+
+ // Инициализация звуков
+ this.sounds = {
+ playcard: new Audio('/sounds/playcard.aac'),
+ drawcard: new Audio('/sounds/drawcard.aac'),
+ eight: new Audio('/sounds/eight.aac'),
+ change: new Audio('/sounds/change.aac'),
+ skip: new Audio('/sounds/skip.aac'),
+ alert: new Audio('/sounds/alert.aac'),
+ chat: new Audio('/sounds/chat.aac'),
+ win: new Audio('/sounds/win.aac'),
+ lose: new Audio('/sounds/lose.aac'),
+ two: new Audio('/sounds/two.aac'),
+ six: new Audio('/sounds/six.aac'),
+ seven: new Audio('/sounds/seven.aac'),
+ shuffle: new Audio('/sounds/shuffle.aac'),
+ ace: new Audio('/sounds/ace.aac'),
+ eightplace: new Audio('/sounds/eightplace.aac')
+ };
+
+ this.initElements();
+ this.initEventListeners();
+
+ // Загружаем правила игры
+ this.loadRules();
+
+ // Отслеживаем видимость страницы
+ this.pageVisible = !document.hidden;
+ this.setupVisibilityHandling();
+
+ // Проверяем URL и localStorage для переподключения
+ this.checkReconnect();
+
+ // Инициализируем PWA
+ this.initPWA();
+ }
+
+ setupVisibilityHandling() {
+ // Обработка видимости страницы
+ document.addEventListener('visibilitychange', () => {
+ this.pageVisible = !document.hidden;
+
+ // Проверяем на каком экране мы находимся
+ const lobbyScreen = document.getElementById('lobby-screen');
+ const isOnLobby = lobbyScreen && lobbyScreen.classList.contains('active');
+
+ if (document.hidden) {
+ // Страница скрыта - закрываем WebSocket ТОЛЬКО на главной странице
+ if (isOnLobby) {
+ console.log('Page hidden on lobby, closing WebSocket');
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ this.ws.close();
+ }
+ } else {
+ console.log('Page hidden during game, keeping connection');
+ }
+ } else {
+ // Страница видима - переподключаемся если нужно
+ console.log('Page visible, checking connection');
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
+ this.connect(true);
+ }
+ }
+ });
+ }
+
+ playSound(soundName) {
+ // Не воспроизводим звуки если страница скрыта
+ if (this.soundEnabled && this.sounds[soundName] && this.pageVisible) {
+ // Используем существующий объект вместо клонирования
+ const sound = this.sounds[soundName];
+ sound.currentTime = 0; // Сбрасываем на начало для повторного воспроизведения
+ sound.volume = 0.5;
+ sound.play().catch(err => {}); // Убираем console.log для производительности
+ }
+ }
+
+ toggleSound() {
+ this.soundEnabled = !this.soundEnabled;
+ localStorage.setItem('soundEnabled', this.soundEnabled);
+ this.updateSoundButton();
+ }
+
+ updateSoundButton() {
+ const btn = document.getElementById('toggle-sound-btn');
+ const icon = btn.querySelector('.sound-icon');
+ if (this.soundEnabled) {
+ btn.classList.remove('muted');
+ icon.textContent = '🔊';
+ btn.title = 'Выключить звук';
+ } else {
+ btn.classList.add('muted');
+ icon.textContent = '🔇';
+ btn.title = 'Включить звук';
+ }
+ }
+
+ showAlert(message) {
+ if (!this.alertModal || !this.alertText) return;
+ this.playSound('alert');
+ this.alertText.textContent = message;
+ this.alertModal.classList.add('active');
+ }
+
+ checkReconnect() {
+ // Получаем room_id из URL
+ const path = window.location.pathname;
+ const match = path.match(/\/room\/([a-f0-9-]+)/);
+
+ if (match) {
+ this.roomId = match[1];
+ // Получаем player_id из localStorage
+ this.playerId = localStorage.getItem(`player_${this.roomId}`);
+
+ // Пытаемся переподключиться (даже без player_id, чтобы получить ошибку)
+ this.connect(true);
+ return;
+ }
+
+ // Обычное подключение
+ this.connect(false);
+ }
+
+ saveToLocalStorage() {
+ if (this.roomId && this.playerId) {
+ localStorage.setItem(`player_${this.roomId}`, this.playerId);
+ }
+ }
+
+ clearLocalStorage() {
+ if (this.roomId) {
+ localStorage.removeItem(`player_${this.roomId}`);
+ }
+ }
+
+ updateURL(roomId) {
+ const newUrl = `/room/${roomId}`;
+ window.history.pushState({roomId}, '', newUrl);
+ }
+
+ goToLobby() {
+ this.clearLocalStorage();
+ window.history.pushState({}, '', '/');
+ location.reload();
+ }
+
+ initElements() {
+ // Screens
+ this.lobbyScreen = document.getElementById('lobby-screen');
+ this.roomScreen = document.getElementById('room-screen');
+ this.errorScreen = document.getElementById('error-screen');
+ this.gameScreen = document.getElementById('game-screen');
+
+ // Lobby elements
+ this.nicknameInput = document.getElementById('nickname-input');
+ this.createRoomBtn = document.getElementById('create-room-btn');
+ this.roomsList = document.getElementById('rooms-list');
+
+ // Room elements
+ this.playersList = document.getElementById('players-list');
+ this.readyToggleBtn = document.getElementById('ready-toggle-btn');
+ this.leaveRoomBtn = document.getElementById('leave-room-btn');
+
+ // Game elements
+ this.deckCount = document.getElementById('deck-count');
+ this.discardPile = document.getElementById('discard-pile');
+ this.chosenSuitIndicator = document.getElementById('chosen-suit-indicator');
+ this.handCards = document.getElementById('hand-cards');
+ this.drawCardBtn = document.getElementById('draw-card-btn');
+ this.skipTurnBtn = document.getElementById('skip-turn-btn');
+
+ // Game state
+ this.cardDrawnThisTurn = false;
+ this.waitingForEight = false;
+ this.eightDrawUsed = false;
+
+ // Countdown elements
+ this.countdownDisplay = document.getElementById('countdown-display');
+ this.countdownNumber = document.getElementById('countdown-number');
+
+ // Game log
+ this.gameLog = document.getElementById('game-log');
+
+ // Modals
+ this.suitModal = document.getElementById('suit-modal');
+ this.resultsModal = document.getElementById('results-modal');
+ this.joinModal = document.getElementById('join-modal');
+ this.joinNicknameInput = document.getElementById('join-nickname-input');
+ this.joinConfirmBtn = document.getElementById('join-confirm-btn');
+ this.joinCancelBtn = document.getElementById('join-cancel-btn');
+ this.closeResultsBtn = document.getElementById('close-results-btn');
+ this.cancelSuitBtn = document.getElementById('cancel-suit-btn');
+ this.alertModal = document.getElementById('alert-modal');
+ this.alertText = document.getElementById('alert-text');
+ this.alertOkBtn = document.getElementById('alert-ok-btn');
+
+ // Chat elements
+ this.chatBtn = document.getElementById('chat-btn');
+ this.chatModal = document.getElementById('chat-modal');
+ this.chatInput = document.getElementById('chat-input');
+ this.sendChatBtn = document.getElementById('send-chat-btn');
+ this.closeChatBtn = document.getElementById('close-chat-btn');
+
+ // Rules elements
+ this.rulesBtn = document.getElementById('rules-btn');
+ this.rulesModal = document.getElementById('rules-modal');
+ this.rulesContent = document.getElementById('rules-content'); // Модалка
+ this.lobbyRulesContent = document.getElementById('lobby-rules-content'); // Главная страница
+ this.closeRulesBtn = document.getElementById('close-rules-btn');
+
+ // Sound button
+ this.toggleSoundBtn = document.getElementById('toggle-sound-btn');
+
+ // Leave game button and modal
+ this.leaveGameBtn = document.getElementById('leave-game-btn');
+ this.leaveConfirmModal = document.getElementById('leave-confirm-modal');
+ this.leaveConfirmBtn = document.getElementById('leave-confirm-btn');
+ this.leaveCancelBtn = document.getElementById('leave-cancel-btn');
+
+ // Bot game buttons
+ this.playWith1BotBtn = document.getElementById('play-with-1-bot-btn');
+ this.playWith2BotsBtn = document.getElementById('play-with-2-bots-btn');
+ this.playWith3BotsBtn = document.getElementById('play-with-3-bots-btn');
+
+ // Инициализируем состояние кнопки звука
+ this.updateSoundButton();
+ }
+
+ initEventListeners() {
+ this.createRoomBtn.addEventListener('click', () => this.createRoom());
+ this.nicknameInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') this.createRoom();
+ });
+
+ this.readyToggleBtn.addEventListener('click', () => this.toggleReady());
+ this.leaveRoomBtn.addEventListener('click', () => this.leaveRoom());
+
+ // Bot game buttons
+ this.playWith1BotBtn.addEventListener('click', () => this.createBotGame(1));
+ this.playWith2BotsBtn.addEventListener('click', () => this.createBotGame(2));
+ this.playWith3BotsBtn.addEventListener('click', () => this.createBotGame(3));
+
+ this.drawCardBtn.addEventListener('click', () => this.drawCard());
+ this.skipTurnBtn.addEventListener('click', () => this.skipTurn());
+
+ // Sound toggle
+ this.toggleSoundBtn.addEventListener('click', () => this.toggleSound());
+
+ // Leave game button
+ this.leaveGameBtn.addEventListener('click', () => this.showLeaveConfirm());
+ this.leaveConfirmBtn.addEventListener('click', () => this.confirmLeaveGame());
+ this.leaveCancelBtn.addEventListener('click', () => this.cancelLeaveGame());
+
+ // Fullscreen button
+ const fullscreenBtn = document.getElementById('fullscreen-btn');
+ if (fullscreenBtn) {
+ fullscreenBtn.addEventListener('click', () => this.toggleFullscreen());
+ }
+
+ // Toggle log button
+ const toggleLogBtn = document.getElementById('toggle-log-btn');
+ if (toggleLogBtn) {
+ toggleLogBtn.addEventListener('click', () => this.toggleLog());
+ }
+
+ // Suit selection
+ document.querySelectorAll('.suit-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const suit = e.target.dataset.suit;
+ this.selectSuit(suit);
+ });
+ });
+
+ this.joinConfirmBtn.addEventListener('click', () => this.confirmJoin());
+ this.joinCancelBtn.addEventListener('click', () => this.cancelJoin());
+ this.joinNicknameInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') this.confirmJoin();
+ });
+
+ this.closeResultsBtn.addEventListener('click', () => {
+ this.resultsModal.classList.remove('active');
+ // Переходим в комнату для новой игры
+ this.showScreen('room');
+ });
+
+ // Chat handlers
+ this.chatBtn.addEventListener('click', () => this.openChat());
+ this.sendChatBtn.addEventListener('click', () => this.sendChatMessage());
+ this.closeChatBtn.addEventListener('click', () => this.closeChat());
+ this.chatInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ this.sendChatMessage();
+ }
+ });
+
+ // Rules handlers
+ this.rulesBtn.addEventListener('click', () => this.openRules());
+ this.closeRulesBtn.addEventListener('click', () => this.closeRules());
+
+ const goHomeBtn = document.getElementById('go-home-btn');
+ if (goHomeBtn) {
+ goHomeBtn.addEventListener('click', () => this.goToLobby());
+ }
+
+ // Горячие клавиши
+ document.addEventListener('keydown', (e) => this.handleKeyPress(e));
+
+ if (this.alertOkBtn && this.alertModal) {
+ this.alertOkBtn.addEventListener('click', () => {
+ this.alertModal.classList.remove('active');
+ });
+ }
+
+ if (this.cancelSuitBtn) {
+ this.cancelSuitBtn.addEventListener('click', () => {
+ this.suitModal.classList.remove('active');
+ this.pendingCardToPlay = null;
+ });
+ }
+ }
+
+ connect(reconnect = false) {
+ // Определяем протокол WebSocket (ws или wss)
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+
+ // Проверяем, есть ли кастомный WebSocket домен в meta теге
+ const wsHostMeta = document.querySelector('meta[name="ws-host"]');
+ let wsUrl;
+
+ if (wsHostMeta && wsHostMeta.content) {
+ // Используем кастомный домен для WebSocket
+ wsUrl = `${wsProtocol}//${wsHostMeta.content}`;
+ } else {
+ // Используем текущий хост с портом 8765
+ wsUrl = `${wsProtocol}//${window.location.hostname}:8765`;
+ }
+
+ console.log('Connecting to:', wsUrl);
+ this.ws = new WebSocket(wsUrl);
+
+ this.ws.onopen = () => {
+ console.log('Connected to server');
+
+ if (reconnect && this.roomId) {
+ // Переподключаемся к существующей игре
+ // Если нет playerId - сервер вернёт ошибку и покажется экран ошибки
+ this.send({
+ type: 'reconnect',
+ player_id: this.playerId || 'temp_' + Date.now(),
+ room_id: this.roomId
+ });
+ } else {
+ // Обычное подключение
+ this.send({ type: 'get_rooms' });
+ }
+ };
+
+ this.ws.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+ this.handleMessage(data);
+ };
+
+ this.ws.onerror = (error) => {
+ console.error('WebSocket error:', error);
+ // Показываем алерт только если страница видима и мы не в игре
+ const lobbyScreen = document.getElementById('lobby-screen');
+ const isOnLobby = lobbyScreen && lobbyScreen.classList.contains('active');
+ if (this.pageVisible && isOnLobby) {
+ this.showAlert('Ошибка подключения к серверу');
+ }
+ };
+
+ this.ws.onclose = () => {
+ console.log('Disconnected from server');
+ // Переподключаемся всегда если страница видима, или если мы в игре (даже если скрыта)
+ const lobbyScreen = document.getElementById('lobby-screen');
+ const isOnLobby = lobbyScreen && lobbyScreen.classList.contains('active');
+
+ if (this.pageVisible || !isOnLobby) {
+ // Переподключаемся если: страница видима ИЛИ мы в игре (не на лобби)
+ setTimeout(() => this.connect(), 3000);
+ }
+ };
+ }
+
+ send(data) {
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(data));
+ }
+ }
+
+ handleMessage(data) {
+ // console.log('Received:', data); // Закомментировано для производительности
+
+ switch (data.type) {
+ case 'rooms_list':
+ this.updateRoomsList(data.rooms);
+ break;
+ case 'room_created':
+ this.playerId = data.player_id;
+ this.roomId = data.room_id;
+ this.currentRoom = data.room;
+ this.saveToLocalStorage();
+ this.updateURL(this.roomId);
+ this.showScreen('room');
+ this.updatePlayersInRoom(data.room.players);
+ break;
+ case 'room_joined':
+ this.playerId = data.player_id;
+ this.roomId = data.room_id;
+ this.currentRoom = data.room;
+ this.saveToLocalStorage();
+ this.updateURL(this.roomId);
+ this.showScreen('room');
+ this.updatePlayersInRoom(data.room.players);
+ break;
+ case 'player_joined':
+ if (this.currentRoom) {
+ this.currentRoom.players.push(data.player);
+ this.updatePlayersInRoom(this.currentRoom.players);
+ }
+ break;
+ case 'player_left':
+ if (this.currentRoom) {
+ this.currentRoom.players = this.currentRoom.players.filter(
+ p => p.id !== data.player_id
+ );
+ this.updatePlayersInRoom(this.currentRoom.players);
+ }
+ break;
+ case 'player_ready_changed':
+ if (this.currentRoom) {
+ const player = this.currentRoom.players.find(p => p.id === data.player_id);
+ if (player) {
+ player.ready = data.ready;
+ this.updatePlayersInRoom(this.currentRoom.players);
+ }
+ }
+ break;
+ case 'room_updated':
+ this.currentRoom = data.room;
+ this.updatePlayersInRoom(data.room.players);
+ break;
+ case 'game_started':
+ this.hand = data.hand;
+ this.currentPlayerId = data.current_player;
+ this.dealerId = data.dealer;
+ this.deckCount.textContent = data.deck_count;
+ this.waitingForEight = data.waiting_for_eight || false;
+ this.eightDrawUsed = data.eight_draw_used || false;
+ this.eightDrawnCards = data.eight_drawn_cards || [];
+ this.clearLog();
+ this.addLogEntry('Игра началась!');
+ this.hideCountdown();
+ this.showScreen('game');
+
+ // Воспроизводим звук если игра началась со специальной карты
+ if (data.top_card) {
+ const rank = data.top_card.rank;
+ if (rank === 'A') {
+ this.playSound('ace');
+ } else if (rank === '8') {
+ this.playSound('eightplace');
+ } else if (rank === '6') {
+ this.playSound('six');
+ } else if (rank === '7') {
+ this.playSound('seven');
+ } else if (rank === 'Q') {
+ this.playSound('change');
+ }
+ }
+
+ // Если кто-то принудительно взял карты при старте (6 или 7)
+ if (data.forced_draw_player_id && data.forced_draw_count > 0) {
+ const cardName = `${data.top_card.rank}${this.getSuitSymbol(data.top_card.suit)}`;
+ const cardsText = data.forced_draw_count === 1 ? '1 карту' : `${data.forced_draw_count} карты`;
+ this.addLogEntry(`${data.forced_draw_player_nickname} взял ${cardsText} от ${cardName}`);
+ }
+
+ this.updateGameState(data);
+ break;
+ case 'card_played':
+ // Сохраняем старое значение waiting_for_eight перед обновлением
+ const wasWaitingForEight = this.waitingForEight;
+
+ this.hand = data.hand;
+ this.currentPlayerId = data.current_player;
+ this.deckCount.textContent = data.deck_count;
+ this.cardDrawnThisTurn = data.card_drawn_this_turn || false;
+ this.waitingForEight = data.waiting_for_eight || false;
+ this.eightDrawUsed = data.eight_draw_used || false;
+ this.eightDrawnCards = data.eight_drawn_cards || [];
+
+ // Звук игры карты
+ // Специальные карты - звук для всех игроков
+ if (data.card.rank === 'A') {
+ this.playSound('playcard');
+ setTimeout(() => this.playSound('ace'), 60);
+ } else if (data.card.rank === '8') {
+ this.playSound('eightplace');
+ } else if (data.card.rank === '6') {
+ this.playSound('playcard');
+ setTimeout(() => this.playSound('six'), 40);
+ } else if (data.card.rank === '7') {
+ this.playSound('playcard');
+ setTimeout(() => this.playSound('seven'), 40);
+ } else if (data.card.rank === '2' && data.waiting_for_eight === false && wasWaitingForEight === true) {
+ this.playSound('two');
+ } else {
+ this.playSound('playcard');
+ }
+
+ // Логируем событие
+ const cardName = `${data.card.rank}${this.getSuitSymbol(data.card.suit)}`;
+ this.addLogEntry(`${data.player_nickname} сыграл ${cardName}`);
+
+ // Если выбрана масть дамой
+ if (data.card.rank === 'Q' && data.chosen_suit) {
+ // Звук смены масти для всех игроков
+ this.playSound('change');
+ this.addLogEntry(`${data.player_nickname} назвал ${this.getSuitName(data.chosen_suit)}`);
+ }
+
+ // Если кто-то принудительно взял карты (6 или 7)
+ if (data.forced_draw_player_id && data.forced_draw_count > 0) {
+ const cardsText = data.forced_draw_count === 1 ? '1 карту' : `${data.forced_draw_count} карты`;
+ this.addLogEntry(`${data.forced_draw_player_nickname} взял ${cardsText} от ${cardName}`);
+ }
+
+ this.updateGameState(data);
+ break;
+ case 'card_drawn':
+ this.hand = data.hand;
+ this.currentPlayerId = data.current_player;
+ this.deckCount.textContent = data.deck_count;
+ this.cardDrawnThisTurn = data.card_drawn_this_turn || false;
+ this.waitingForEight = data.waiting_for_eight || false;
+ this.eightDrawUsed = data.eight_draw_used || false;
+ this.eightDrawnCards = data.eight_drawn_cards || [];
+
+ // Звук взятия карты
+ if (data.waiting_for_eight) {
+ this.playSound('eight');
+ } else {
+ this.playSound('drawcard');
+ }
+
+ // Логируем событие
+ const cardsText = data.cards_count === 1 ? '1 карту' : `${data.cards_count} карты`;
+ if (data.waiting_for_eight) {
+ const topCard = data.top_card;
+ this.addLogEntry(`${data.player_nickname} взял ${cardsText} от ${topCard.rank}${this.getSuitSymbol(topCard.suit)}`);
+ } else {
+ const cardsText = data.cards_count === 1 ? 'карту' : 'карты';
+ this.addLogEntry(`${data.player_nickname} взял ${data.cards_count} ${cardsText}`);
+ }
+
+ this.updateGameState(data);
+ break;
+ case 'turn_skipped':
+ this.hand = data.hand;
+ this.currentPlayerId = data.current_player;
+ this.cardDrawnThisTurn = false;
+ this.waitingForEight = data.waiting_for_eight || false;
+ this.eightDrawUsed = data.eight_draw_used || false;
+ this.eightDrawnCards = data.eight_drawn_cards || [];
+
+ // Звук пропуска хода (для всех игроков)
+ this.playSound('skip');
+
+ // Логируем пропуск хода
+ if (data.log_message) {
+ this.addLogEntry(data.log_message);
+ }
+
+ this.updateGameState(data);
+ break;
+ case 'deck_shuffled':
+ // Звук перемешивания колоды (для всех игроков)
+ this.playSound('shuffle');
+
+ // Логируем перемешивание колоды
+ if (data.log_message) {
+ this.addLogEntry(data.log_message);
+ }
+ break;
+ case 'chat_message':
+ // Отображаем сообщение в логе с выделением
+ if (data.message) {
+ // Звук чата для всех
+ this.playSound('chat');
+
+ // Определяем класс для сообщения
+ let chatClass = 'chat-other';
+ if (data.player_id === this.playerId) {
+ chatClass = 'chat-player';
+ } else {
+ // Для других игроков назначаем цвет по их ID
+ const colorIndex = this.getPlayerColorIndex(data.player_id);
+ chatClass = `chat-other chat-color-${colorIndex}`;
+ }
+
+ this.addLogEntry(data.message, chatClass);
+ }
+ break;
+ case 'game_ended':
+ this.showResults(data);
+ break;
+ case 'player_kicked':
+ // Игрок вылетел из комнаты (набрал >101)
+ if (data.player_id === this.playerId) {
+ // Это мы вылетели
+ this.showAlert(`Вы набрали больше 101 очка и выбыли из игры`);
+ setTimeout(() => this.goToLobby(), 6000);
+ } else {
+ // Другой игрок вылетел
+ this.addLogEntry(`${data.player_nickname} выбыл (>101 очка)`);
+ if (this.currentRoom) {
+ this.currentRoom.players = this.currentRoom.players.filter(
+ p => p.id !== data.player_id
+ );
+ this.updatePlayersInRoom(this.currentRoom.players);
+ }
+ }
+ break;
+ case 'final_winner':
+ // Финальная победа - комната закрывается
+ const winnerText = data.winner_id === this.playerId
+ ? 'Поздравляем! Вы победили!'
+ : `Победитель: ${data.winner_nickname}`;
+ this.showAlert(winnerText);
+ setTimeout(() => this.goToLobby(), 6000);
+ break;
+ case 'countdown_tick':
+ this.showCountdown(data.seconds);
+ break;
+ case 'countdown_cancelled':
+ this.hideCountdown();
+ break;
+ case 'error':
+ // Если ошибка связана с комнатой/игроком - показываем экран ошибки
+ if (data.message.includes('not found') || data.message.includes('не найден')) {
+ this.showError(data.message);
+ } else {
+ this.showAlert(data.message);
+ }
+ break;
+ }
+ }
+
+ showScreen(screen) {
+ this.lobbyScreen.classList.remove('active');
+ this.roomScreen.classList.remove('active');
+ this.errorScreen.classList.remove('active');
+ this.gameScreen.classList.remove('active');
+
+ if (screen === 'lobby') {
+ this.lobbyScreen.classList.add('active');
+ } else if (screen === 'room') {
+ this.roomScreen.classList.add('active');
+ } else if (screen === 'error') {
+ this.errorScreen.classList.add('active');
+ } else if (screen === 'game') {
+ this.gameScreen.classList.add('active');
+ }
+ }
+
+ showError(message) {
+ const errorMessage = document.getElementById('error-message');
+ if (errorMessage) {
+ errorMessage.textContent = message;
+ }
+ this.showScreen('error');
+ }
+
+ createRoom() {
+ const nickname = this.nicknameInput.value.trim();
+ if (!nickname) {
+ this.showAlert('Введите никнейм');
+ return;
+ }
+
+ this.send({
+ type: 'create_room',
+ nickname: nickname
+ });
+ }
+
+ createBotGame(botCount) {
+ const nickname = this.nicknameInput.value.trim();
+ if (!nickname) {
+ this.showAlert('Введите никнейм');
+ return;
+ }
+
+ this.send({
+ type: 'create_bot_game',
+ nickname: nickname,
+ bot_count: botCount
+ });
+ }
+
+ updateRoomsList(rooms) {
+ this.roomsList.innerHTML = '';
+
+ if (rooms.length === 0) {
+ this.roomsList.innerHTML = '
Нет доступных комнат
';
+ return;
+ }
+
+ rooms.forEach(room => {
+ const roomCard = document.createElement('div');
+ const isFull = room.player_count >= 4;
+
+ roomCard.className = 'room-card' + (isFull ? ' room-full' : '');
+ roomCard.innerHTML = `
+ Комната
+ Игроков: ${room.player_count}/4
+ Статус: ${isFull ? 'Полная' : 'Ожидание'}
+ `;
+
+ if (!isFull) {
+ roomCard.addEventListener('click', () => this.showJoinModal(room.id));
+ }
+
+ this.roomsList.appendChild(roomCard);
+ });
+ }
+
+ showJoinModal(roomId) {
+ this.pendingRoomId = roomId;
+ this.joinModal.classList.add('active');
+ this.joinNicknameInput.value = '';
+ this.joinNicknameInput.focus();
+ }
+
+ confirmJoin() {
+ const nickname = this.joinNicknameInput.value.trim();
+ if (!nickname) {
+ this.showAlert('Введите никнейм');
+ return;
+ }
+
+ this.send({
+ type: 'join_room',
+ room_id: this.pendingRoomId,
+ nickname: nickname
+ });
+
+ this.joinModal.classList.remove('active');
+ }
+
+ cancelJoin() {
+ this.joinModal.classList.remove('active');
+ }
+
+ updatePlayersInRoom(players) {
+ this.playersList.innerHTML = '';
+
+ players.forEach(player => {
+ const playerCard = document.createElement('div');
+ playerCard.className = 'player-card';
+ if (player.ready) {
+ playerCard.classList.add('ready');
+ }
+
+ playerCard.innerHTML = `
+ ${player.nickname} ${player.id === this.playerId ? '(Вы)' : ''}
+ Очки: ${player.score || 0}
+ ${player.ready ? '✓ Готов' : 'Не готов'}
+ `;
+
+ this.playersList.appendChild(playerCard);
+ });
+ }
+
+ toggleReady() {
+ this.send({ type: 'toggle_ready' });
+ }
+
+ leaveRoom() {
+ this.goToLobby();
+ }
+
+ showLeaveConfirm() {
+ this.leaveConfirmModal.classList.add('active');
+ }
+
+ confirmLeaveGame() {
+ this.leaveConfirmModal.classList.remove('active');
+ this.goToLobby();
+ }
+
+ cancelLeaveGame() {
+ this.leaveConfirmModal.classList.remove('active');
+ }
+
+ updateGameState(data) {
+ // Update top card
+ this.discardPile.innerHTML = '';
+ if (data.top_card) {
+ const cardElement = this.createCardElement(data.top_card, false);
+ this.discardPile.appendChild(cardElement);
+ }
+
+ // Update chosen suit indicator
+ // Показываем индикатор всегда когда есть выбранная масть (после дамы)
+ if (data.chosen_suit) {
+ this.chosenSuitIndicator.style.display = 'block';
+ this.chosenSuitIndicator.textContent = this.getSuitSymbol(data.chosen_suit);
+ } else {
+ this.chosenSuitIndicator.style.display = 'none';
+ }
+
+ // Update opponents around the table
+ this.updateOpponents(data.players, this.currentPlayerId);
+
+ // Update hand
+ this.updateHand(data.top_card, data.chosen_suit);
+
+ // Управление кнопками
+ const isMyTurn = this.currentPlayerId === this.playerId;
+
+ if (isMyTurn) {
+ if (this.waitingForEight) {
+ // При восьмёрке - только кнопка взятия карты
+ // Пропуск хода невозможен - нужно брать карты
+ this.drawCardBtn.style.display = 'block';
+ // Отключаем кнопку если взятие уже использовано
+ this.drawCardBtn.disabled = this.eightDrawUsed;
+ this.skipTurnBtn.style.display = 'none';
+ } else if (this.cardDrawnThisTurn) {
+ // Карта уже взята - скрываем кнопку взятия, показываем пропуск
+ // Игрок может играть карты или пропустить ход
+ this.drawCardBtn.style.display = 'none';
+ this.skipTurnBtn.style.display = 'block';
+ this.skipTurnBtn.disabled = false;
+ } else {
+ // Карта не взята - показываем только кнопку взятия
+ this.drawCardBtn.style.display = 'block';
+ this.drawCardBtn.disabled = false;
+ this.skipTurnBtn.style.display = 'none';
+ }
+ } else {
+ // Не наш ход - отключаем все кнопки
+ this.drawCardBtn.style.display = 'block';
+ this.drawCardBtn.disabled = true;
+ this.skipTurnBtn.style.display = 'none';
+ }
+ }
+
+ updateOpponents(players, currentPlayerId) {
+ // Скрываем все позиции
+ const opponentTop = document.getElementById('opponent-top');
+ const opponentLeft = document.getElementById('opponent-left');
+ const opponentRight = document.getElementById('opponent-right');
+
+ opponentTop.style.display = 'none';
+ opponentLeft.style.display = 'none';
+ opponentRight.style.display = 'none';
+
+ // Убираем подсветку со всех
+ opponentTop.classList.remove('current-turn');
+ opponentLeft.classList.remove('current-turn');
+ opponentRight.classList.remove('current-turn');
+
+ // Получаем противников (все кроме нас)
+ // Сортируем их в порядке хода (по часовой стрелке от нас)
+ const myIndex = players.findIndex(p => p.id === this.playerId);
+ const opponents = [];
+
+ for (let i = 1; i < players.length; i++) {
+ const opponentIndex = (myIndex + i) % players.length;
+ opponents.push(players[opponentIndex]);
+ }
+
+ if (opponents.length === 0) return;
+
+ // Получаем контейнер для противников
+ const opponentsContainer = document.getElementById('opponents-container');
+
+ // Выбираем позиции в зависимости от количества противников
+ let positions;
+ if (opponents.length === 1) {
+ // Один противник - только центр
+ positions = [opponentTop];
+ opponentsContainer.className = 'opponents-1';
+ } else if (opponents.length === 2) {
+ // Два противника - слева и справа
+ positions = [opponentLeft, opponentRight];
+ opponentsContainer.className = 'opponents-2';
+ } else {
+ // Три противника - слева, центр, справа
+ positions = [opponentLeft, opponentTop, opponentRight];
+ opponentsContainer.className = 'opponents-3';
+ }
+
+ opponents.forEach((opponent, index) => {
+ if (index >= positions.length) return;
+
+ const position = positions[index];
+ position.style.display = 'flex';
+
+ const info = position.querySelector('.opponent-info');
+ const cardsContainer = position.querySelector('.opponent-cards');
+
+ // Обновляем информацию
+ info.querySelector('.opponent-name').textContent = opponent.nickname;
+ info.querySelector('.opponent-score').textContent = `Очки: ${opponent.score}`;
+
+ // Подсвечиваем текущего игрока
+ if (opponent.id === currentPlayerId) {
+ position.classList.add('current-turn');
+ } else {
+ position.classList.remove('current-turn');
+ }
+
+ // Отображаем карты-рубашки
+ cardsContainer.innerHTML = '';
+
+ // Если игроков больше 2 (т.е. противников больше 1), показываем только одну рубашку с цифрой
+ if (opponents.length > 1) {
+ const cardBack = document.createElement('div');
+ cardBack.className = 'opponent-card single-card';
+ cardBack.innerHTML = `
+ 🎴
+ ${opponent.hand_count}
+ `;
+ cardsContainer.appendChild(cardBack);
+ } else {
+ // Если только 1 противник, показываем все его карты
+ for (let i = 0; i < opponent.hand_count; i++) {
+ const cardBack = document.createElement('div');
+ cardBack.className = 'opponent-card';
+ cardBack.textContent = '🎴';
+ cardsContainer.appendChild(cardBack);
+ }
+ }
+ });
+
+ // Подсвечиваем наши карты, если наш ход
+ const playerHand = document.querySelector('.player-hand');
+ if (currentPlayerId === this.playerId) {
+ playerHand.classList.add('current-turn');
+ } else {
+ playerHand.classList.remove('current-turn');
+ }
+ }
+
+ updateHand(topCard, chosenSuit) {
+ this.handCards.innerHTML = '';
+
+ this.hand.forEach(card => {
+ const cardElement = this.createCardElement(card, true);
+
+ // Check if card can be played
+ const canPlay = this.currentPlayerId === this.playerId &&
+ this.canPlayCard(card, topCard, chosenSuit, this.waitingForEight, this.eightDrawnCards);
+
+ if (!canPlay) {
+ cardElement.classList.add('disabled');
+ } else {
+ cardElement.addEventListener('click', () => this.playCard(card));
+ }
+
+ this.handCards.appendChild(cardElement);
+ });
+ }
+
+ canPlayCard(card, topCard, chosenSuit, waitingForEight, eightDrawnCards = []) {
+ // На восьмёрку можно класть:
+ // - Любую двойку (из руки)
+ // - Карты взятые из колоды, которые подходят (двойка, дама, восьмёрка или та же масть)
+ if (waitingForEight) {
+ // Двойка из руки всегда разрешена
+ if (card.rank === '2') return true;
+
+ // Карты взятые из колоды разрешены, только если они подходят
+ if (eightDrawnCards.includes(card.id)) {
+ // Проверяем что карта действительно подходит для восьмёрки
+ return card.rank === '2' ||
+ card.rank === 'Q' ||
+ card.rank === '8' ||
+ card.suit === topCard.suit;
+ }
+
+ // Остальные карты заблокированы
+ return false;
+ }
+
+ // Дама может быть положена на любую карту
+ if (card.rank === 'Q') return true;
+
+ // Если была выбрана масть дамой
+ if (chosenSuit) {
+ return card.suit === chosenSuit || card.rank === topCard.rank;
+ }
+
+ // Обычная проверка: масть или номинал
+ return card.suit === topCard.suit || card.rank === topCard.rank;
+ }
+
+ createCardElement(card, clickable) {
+ const cardDiv = document.createElement('div');
+ cardDiv.className = `card ${card.suit}`;
+
+ cardDiv.innerHTML = `
+ ${card.rank}
+ ${this.getSuitSymbol(card.suit)}
+ `;
+
+ return cardDiv;
+ }
+
+ getSuitSymbol(suit) {
+ const symbols = {
+ 'hearts': '♥️',
+ 'diamonds': '♦️',
+ 'clubs': '♣️',
+ 'spades': '♠️'
+ };
+ return symbols[suit] || '';
+ }
+
+ getSuitName(suit) {
+ const names = {
+ 'hearts': 'черви',
+ 'diamonds': 'бубны',
+ 'clubs': 'трефы',
+ 'spades': 'пики'
+ };
+ return names[suit] || suit;
+ }
+
+ playCard(card) {
+ if (this.currentPlayerId !== this.playerId) {
+ return;
+ }
+
+ // If it's a Queen, show suit selector
+ if (card.rank === 'Q') {
+ this.pendingCardToPlay = card;
+ this.suitModal.classList.add('active');
+ } else {
+ // Отправляем карту без воспроизведения звука
+ // Звук будет воспроизведен когда придет событие card_played
+ this.send({
+ type: 'play_card',
+ card_id: card.id
+ });
+ }
+ }
+
+ selectSuit(suit) {
+ this.suitModal.classList.remove('active');
+
+ if (this.pendingCardToPlay) {
+ // Отправляем карту без воспроизведения звука
+ // Звук будет воспроизведен когда придет событие card_played
+ this.send({
+ type: 'play_card',
+ card_id: this.pendingCardToPlay.id,
+ chosen_suit: suit
+ });
+ this.pendingCardToPlay = null;
+ }
+ }
+
+ drawCard() {
+ if (this.currentPlayerId !== this.playerId) {
+ return;
+ }
+
+ this.send({ type: 'draw_card' });
+ }
+
+ skipTurn() {
+ if (this.currentPlayerId !== this.playerId) {
+ return;
+ }
+
+ this.send({ type: 'skip_turn' });
+ }
+
+ showCountdown(seconds) {
+ this.countdownNumber.textContent = seconds;
+ this.countdownDisplay.style.display = 'block';
+ }
+
+ hideCountdown() {
+ this.countdownDisplay.style.display = 'none';
+ }
+
+ toggleFullscreen() {
+ if (!document.fullscreenElement) {
+ // Входим в полноэкранный режим
+ document.documentElement.requestFullscreen().catch(err => {
+ console.log('Error attempting to enable fullscreen:', err);
+ });
+ } else {
+ // Выходим из полноэкранного режима
+ document.exitFullscreen();
+ }
+ }
+
+ toggleLog() {
+ if (!this.gameLog) return;
+
+ if (this.gameLog.classList.contains('hidden')) {
+ this.gameLog.classList.remove('hidden');
+ } else {
+ this.gameLog.classList.add('hidden');
+ }
+ }
+
+ addLogEntry(message, extraClass = '') {
+ if (!this.gameLog) return;
+
+ const entry = document.createElement('div');
+ entry.className = extraClass ? `log-entry ${extraClass}` : 'log-entry';
+ entry.textContent = message;
+
+ // Добавляем в начало (новые сверху)
+ this.gameLog.insertBefore(entry, this.gameLog.firstChild);
+
+ // Обновляем прозрачность для всех записей
+ const entries = this.gameLog.querySelectorAll('.log-entry');
+ entries.forEach((e, index) => {
+ if (index === 0) {
+ e.style.opacity = '1';
+ } else {
+ // Каждая следующая запись на 15% прозрачнее
+ e.style.opacity = Math.max(0.1, 1 - (index * 0.15)).toString();
+ }
+ });
+
+ // Ограничиваем количество записей (максимум 20)
+ if (entries.length > 20) {
+ this.gameLog.removeChild(entries[entries.length - 1]);
+ }
+ }
+
+ clearLog() {
+ if (this.gameLog) {
+ this.gameLog.innerHTML = '';
+ }
+ }
+
+ showResults(data) {
+ const resultsContent = document.getElementById('results-content');
+ resultsContent.innerHTML = '';
+
+ // Воспроизводим звук win или lose
+ if (data.winner_id === this.playerId) {
+ this.playSound('win');
+ } else {
+ this.playSound('lose');
+ }
+
+ data.results.forEach(result => {
+ const resultItem = document.createElement('div');
+ resultItem.className = 'result-item';
+
+ if (result.player_id === data.winner_id) {
+ resultItem.classList.add('winner');
+
+ // Отображаем последнюю карту победителя
+ const winningCardHtml = data.winning_card
+ ? `
+
+ ${data.winning_card.rank}
+ ${this.getSuitSymbol(data.winning_card.suit)}
+
+
`
+ : '';
+
+ // Текст для бонуса за даму
+ const queenBonusText = result.queen_bonus && result.queen_bonus < 0
+ ? `Бонус за даму: ${result.queen_bonus} 👑
`
+ : '';
+
+ resultItem.innerHTML = `
+ 🏆 ${result.nickname} - ПОБЕДИТЕЛЬ!
+ Очки за раунд: ${result.points}
+ ${queenBonusText}
+ Всего очков: ${result.total_score}
+ ${winningCardHtml}
+ `;
+ } else {
+ const cardsHtml = result.hand && result.hand.length > 0
+ ? `
+ ${result.hand.map(card => `
+
+ ${card.rank}
+ ${this.getSuitSymbol(card.suit)}
+
+ `).join('')}
+
`
+ : '';
+
+ resultItem.innerHTML = `
+ ${result.nickname}
+ Очки за раунд: +${result.points}
+ Всего очков: ${result.total_score}
+ ${cardsHtml}
+ `;
+ }
+
+ resultsContent.appendChild(resultItem);
+ });
+
+ this.resultsModal.classList.add('active');
+ }
+
+ openChat() {
+ this.chatModal.classList.add('active');
+ this.chatInput.value = '';
+ this.chatInput.focus();
+ }
+
+ closeChat() {
+ this.chatModal.classList.remove('active');
+ this.chatInput.value = '';
+ }
+
+ sendChatMessage() {
+ const message = this.chatInput.value.trim();
+ if (!message) {
+ return;
+ }
+
+ // Отправляем сообщение на сервер
+ this.send({
+ type: 'chat_message',
+ message: message
+ });
+
+ this.closeChat();
+ }
+
+ handleKeyPress(e) {
+ // Проверяем что мы не в поле ввода (кроме чата)
+ const isInInput = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA';
+ const isChatInput = e.target === this.chatInput;
+
+ // T (или Е на русской раскладке) - открыть чат
+ if ((e.key === 't' || e.key === 'T' || e.key === 'е' || e.key === 'Е') && !isInInput) {
+ e.preventDefault();
+ this.openChat();
+ return;
+ }
+
+ // Enter в чате - отправить сообщение (уже обработано в chatInput.addEventListener)
+
+ // Пробел - Взять/Нету (только если чат закрыт и не в других полях ввода)
+ if (e.key === ' ' && !isInInput && !this.chatModal.classList.contains('active')) {
+ e.preventDefault();
+
+ // Проверяем что мы на экране игры и это наш ход
+ const gameScreen = document.getElementById('game-screen');
+ if (!gameScreen || !gameScreen.classList.contains('active')) {
+ return;
+ }
+
+ if (this.currentPlayerId !== this.playerId) {
+ return;
+ }
+
+ // Если кнопка "Взять" видима и активна - нажимаем её
+ if (this.drawCardBtn.style.display !== 'none' && !this.drawCardBtn.disabled) {
+ this.drawCard();
+ }
+ // Если кнопка "Нету" видима и активна - нажимаем её
+ else if (this.skipTurnBtn.style.display !== 'none' && !this.skipTurnBtn.disabled) {
+ this.skipTurn();
+ }
+ }
+ }
+
+ async openRules() {
+ // Загружаем правила если еще не загружены
+ if (!this.rulesContent.innerHTML) {
+ await this.loadRules();
+ }
+ this.rulesModal.classList.add('active');
+ }
+
+ closeRules() {
+ this.rulesModal.classList.remove('active');
+ }
+
+ async loadRules() {
+ try {
+ const response = await fetch('/rules.md');
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const markdown = await response.text();
+
+ // Рендерим markdown в HTML
+ let htmlContent;
+ if (typeof marked !== 'undefined') {
+ htmlContent = marked.parse(markdown);
+ } else {
+ console.error('marked.js not loaded');
+ // Показываем как обычный текст если marked не загружен
+ htmlContent = `${markdown} `;
+ }
+
+ // Загружаем в оба контейнера
+ if (this.lobbyRulesContent) {
+ this.lobbyRulesContent.innerHTML = htmlContent;
+ }
+ if (this.rulesContent) {
+ this.rulesContent.innerHTML = htmlContent;
+ }
+ } catch (error) {
+ console.error('Failed to load rules:', error);
+ const errorHtml = `Не удалось загрузить правила игры.
Ошибка: ${error.message}
`;
+ if (this.lobbyRulesContent) {
+ this.lobbyRulesContent.innerHTML = errorHtml;
+ }
+ if (this.rulesContent) {
+ this.rulesContent.innerHTML = errorHtml;
+ }
+ }
+ }
+
+ initPWA() {
+ // Слушаем событие beforeinstallprompt
+ window.addEventListener('beforeinstallprompt', (e) => {
+ // Не предотвращаем - позволяем браузеру показать стандартный баннер
+ console.log('PWA install prompt available');
+ });
+
+ // Слушаем успешную установку
+ window.addEventListener('appinstalled', () => {
+ console.log('PWA installed successfully');
+ });
+ }
+
+ getPlayerColorIndex(playerId) {
+ // Создаем простой хеш из ID игрока для определения цвета
+ // Возвращаем число от 1 до 3 для разных цветов
+ if (!this.playerColorMap) {
+ this.playerColorMap = {};
+ this.nextColorIndex = 1;
+ }
+
+ if (!this.playerColorMap[playerId]) {
+ this.playerColorMap[playerId] = this.nextColorIndex;
+ this.nextColorIndex = (this.nextColorIndex % 3) + 1; // Циклично 1, 2, 3
+ }
+
+ return this.playerColorMap[playerId];
+ }
+}
+
+// Start the game when page loads
+window.addEventListener('DOMContentLoaded', () => {
+ new CardGame();
+});
diff --git a/client/icon-192.png b/client/icon-192.png
new file mode 100644
index 0000000000000000000000000000000000000000..a5dd5dece2e27999bec5e8af6062405075c50167
GIT binary patch
literal 2719
zcmd6oi$By^AIE=l(M(}Xw#O#Y6z!$7m0g#uWOB*%F~f|x-;FUv?k$o`3`*2gcC8Fi
zL>HQ($gL3`TKBRDF$iffV=k47ea6$%w$Fd?{9eCv&gY!(`}aMc*Y|Z^zjGSv?W(_K
z!x{hp^xfT@eYK->rNJTEJ^(DX1_1CR)(h|A8s^3D5B&Ht{rU7yn$8JiaQT0QR;Hgq
zp9JrdSP4-B{{nb)ZXiR;uYB@9%-{IW>-4`fTpok-dD9i1%V&PvmlL=mw7`F+wzjq(
z#fmY`=WMk9dgjx)Ty18imgfepFaaxRX#oLrKA*ON~2N<
znQS(P=dVpg@VP>z-J_7?9EO}i_?1pq_xH&|gD%n;rx>hGBEFDHtsxWT;Ya3~R0)M#
zNhUUu2(NIM6b4H@G_;VPr-=QjfJPnj!}j_4s+yb97_4Ll>ne?UjY_E}5z-mV7kEqn
zhbJfDr|}s1kwfz1F$$qT&g0DcVIx`Gg|t-VjpCC`b{i4jLiD>sA*8Z>ydxa0LX=TTp>z{)%6sLSnU@QN1&Z`j&-{E;4G-l(fqYJYvbZRe%^mjh
znZRQgE}T=3j+ALVba$zTht-46(ilwjK>q_0Q63qp9(k^A`|U6%K)_;4Nd!e|l2Tf)
zs=6P<4p96Qp}cur{bEAZ(WYu@R6XgDl873ma^X^zJbWRH5JF?JSE^%
zPoJpU9#@b_;cTw5>{c#vUgkOVq*
z<*1YT9o39=e>3KgWtG?4Jx0dqj&NP>pa192-lB1awgjug+{lptfM{B2pr5xRv~^iU
zcXvL3j}gy3$GDl0O<@YCrzacZ+v~LUwdBgU*)6hKa?1lB?D8n2(fcc?7>A5#-<*j)
zmHLl}v(7M^wFU*1;;(K&z#DGUi(2_G2
zTLqrC>&N(xuNjy|ARobB>XTdnFaSh+Z4O6SUyPs0GdfzljqGFx6>Vi>TJtOrku4Sv
z8k^h+E~H&{P-Nl7Mo#I7rK`&Y{^IDdQc?zWbJ!~9V*vL+D1VP67?7R5S#3qYGp)Ed|3hvgvax*|1n&0YprpxLfsTgmt$L`S)!3^8=ZyjHevhU>r@7OI5L=_9
zTP|k>~C@nAiOL4Oj=N4}1PNXWEOf00>#|8*PwF2*2SDz>bUIgOk#lB$^~gTuhG9Lzcbw?8(dXa&>}x2=+&{@`Bo&wA5!R$0CYDWI6Kedrwv+b)K<
zZrh}c-s<9`uKazw$Mtoc#4U44>AkvR2*K+qWz^jZ2HThR!%>7a#4R(A%!Mkzx5ww?
ztv3=5{@qKJUi)SLA`h$h*e;YsEC~Dq;CrTe$}`u)Y03Ws;jt?ok|32K_!GQ8zq6@&
z4TEpJywA=`&vdUW&M?39{n9R{g|8f~@lT91WO@R8LiSnV-bu;9_n1ZQNu(GMk>duJ
zk>!*+HY&m6aUvuVO<#QfTiEZ>gZUQUuN^>$&X0BuVdGPg2ne`Uw406{|{2^5P@oGd7^@2TzAl7e9dBwX9#T9Nl!R*#Le2
zJ(t;7e!Np~GwO8YVlBYX$F@d_s{w%_dT|3Q#{eiiUJr;+(pV`JY`Ut}82}>%+R{Tv
z2LW&jL}rSXN(3EiVP2sem{`ZlT((=9GDJkNqJT&^8UPvC$2|IG>1}o0ULuQb1WC(%MrM*ec=OjQX4yb=Jyr#1p
zi-;DgN}}Hl(Q?%zXXK0z7-9W!`J
IM^@s$0TWhBqW}N^
literal 0
HcmV?d00001
diff --git a/client/icon-512.png b/client/icon-512.png
new file mode 100644
index 0000000000000000000000000000000000000000..46b0f007c4411aa83396124012dfe9ff65dbf674
GIT binary patch
literal 8433
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
+
+
+
+
+
+
+
+
+ Чешский
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🎴 Чешский
+
+
+
Создать комнату
+
+
Создать комнату
+
+
+
🤖 Играть с ботами
+
+ Играть с 1 ботом
+ Играть с 2 ботами
+ Играть с 3 ботами
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Игроки в комнате:
+
+
+
+ 6
+
+
Игра начинается...
+
+
Готов
+
Игра начнётся когда все игроки будут готовы (минимум 2 игрока)
+
+
+
+
+
+
+
+
+
😕
+
Комната не найдена
+
Эта комната не существует или была удалена
+
На главную
+
+
+
+
+
+
+
+
+
+ 🚪
+
+
+
+
+ ⛶
+
+
+
+
📖
+
+
+
💬
+
+
+
+ 📋
+
+
+
+
+ 🔊
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0
+
+
+ Взять
+ Нету
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Отправить сообщение
+
+
+ Отправить
+ Отмена
+
+
+
+
+
+
+
+
Выберите масть
+
+ ♥️ Черви
+ ♦️ Бубны
+ ♣️ Трефы
+ ♠️ Пики
+
+
+ Отмена
+
+
+
+
+
+
+
+
Игра окончена!
+
+
Новая игра
+
+
+
+
+
+
+
Присоединиться к комнате
+
+
+ Присоединиться
+ Отмена
+
+
+
+
+
+
+
+
+
+
+
Покинуть комнату?
+
Вы уверены, что хотите покинуть игру?
+
+ Да, выйти
+ Отмена
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/manifest.json b/client/manifest.json
new file mode 100644
index 0000000..5c62dcb
--- /dev/null
+++ b/client/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Чешский дурак",
+ "short_name": "Чешский",
+ "description": "Многопользовательская карточная игра Чешский дурак",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#1a1a2e",
+ "theme_color": "#667eea",
+ "orientation": "any",
+ "icons": [
+ {
+ "src": "/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ],
+ "categories": ["games", "entertainment"],
+ "lang": "ru"
+}
diff --git a/client/marked.min.js b/client/marked.min.js
new file mode 100644
index 0000000..b4e0d73
--- /dev/null
+++ b/client/marked.min.js
@@ -0,0 +1,69 @@
+/**
+ * marked v15.0.12 - a markdown parser
+ * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed)
+ * https://github.com/markedjs/marked
+ */
+
+/**
+ * DO NOT EDIT THIS FILE
+ * The code in this file is generated from files in ./src/
+ */
+(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports};
+"use strict";var H=Object.defineProperty;var be=Object.getOwnPropertyDescriptor;var Te=Object.getOwnPropertyNames;var we=Object.prototype.hasOwnProperty;var ye=(l,e)=>{for(var t in e)H(l,t,{get:e[t],enumerable:!0})},Re=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of Te(e))!we.call(l,s)&&s!==t&&H(l,s,{get:()=>e[s],enumerable:!(n=be(e,s))||n.enumerable});return l};var Se=l=>Re(H({},"__esModule",{value:!0}),l);var kt={};ye(kt,{Hooks:()=>L,Lexer:()=>x,Marked:()=>E,Parser:()=>b,Renderer:()=>$,TextRenderer:()=>_,Tokenizer:()=>S,defaults:()=>w,getDefaults:()=>z,lexer:()=>ht,marked:()=>k,options:()=>it,parse:()=>pt,parseInline:()=>ct,parser:()=>ut,setOptions:()=>ot,use:()=>lt,walkTokens:()=>at});module.exports=Se(kt);function z(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var w=z();function N(l){w=l}var I={exec:()=>null};function h(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,i)=>{let r=typeof i=="string"?i:i.source;return r=r.replace(m.caret,"$1"),t=t.replace(s,r),n},getRegex:()=>new RegExp(t,e)};return n}var m={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}#`),htmlBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}<(?:[a-z].*>|!--)`,"i")},$e=/^(?:[ \t]*(?:\n|$))+/,_e=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Le=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,O=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,ze=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,F=/(?:[*+-]|\d{1,9}[.)])/,ie=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,oe=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Me=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Q=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Pe=/^[^\n]+/,U=/(?!\s*\])(?:\\.|[^\[\]\\])+/,Ae=h(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",U).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ee=h(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,F).getRegex(),v="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",K=/|$))/,Ce=h("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",K).replace("tag",v).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),le=h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Ie=h(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",le).getRegex(),X={blockquote:Ie,code:_e,def:Ae,fences:Le,heading:ze,hr:O,html:Ce,lheading:oe,list:Ee,newline:$e,paragraph:le,table:I,text:Pe},re=h("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Oe={...X,lheading:Me,table:re,paragraph:h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",re).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex()},Be={...X,html:h(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?\\1> *(?:\\n{2,}|\\s*$)| \\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",K).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:I,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:h(Q).replace("hr",O).replace("heading",` *#{1,6} *[^
+]`).replace("lheading",oe).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},qe=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,ve=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ae=/^( {2,}|\\)\n(?!\s*$)/,De=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,ue=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,je=h(ue,"u").replace(/punct/g,D).getRegex(),Fe=h(ue,"u").replace(/punct/g,pe).getRegex(),he="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Qe=h(he,"gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Ue=h(he,"gu").replace(/notPunctSpace/g,He).replace(/punctSpace/g,Ge).replace(/punct/g,pe).getRegex(),Ke=h("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Xe=h(/\\(punct)/,"gu").replace(/punct/g,D).getRegex(),We=h(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Je=h(K).replace("(?:-->|$)","-->").getRegex(),Ve=h("^comment|^[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",Je).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),q=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Ye=h(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",q).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),ke=h(/^!?\[(label)\]\[(ref)\]/).replace("label",q).replace("ref",U).getRegex(),ge=h(/^!?\[(ref)\](?:\[\])?/).replace("ref",U).getRegex(),et=h("reflink|nolink(?!\\()","g").replace("reflink",ke).replace("nolink",ge).getRegex(),J={_backpedal:I,anyPunctuation:Xe,autolink:We,blockSkip:Ne,br:ae,code:ve,del:I,emStrongLDelim:je,emStrongRDelimAst:Qe,emStrongRDelimUnd:Ke,escape:qe,link:Ye,nolink:ge,punctuation:Ze,reflink:ke,reflinkSearch:et,tag:Ve,text:De,url:I},tt={...J,link:h(/^!?\[(label)\]\((.*?)\)/).replace("label",q).getRegex(),reflink:h(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",q).getRegex()},j={...J,emStrongRDelimAst:Ue,emStrongLDelim:Fe,url:h(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},fe=l=>st[l];function R(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,fe)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,fe);return l}function V(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function Y(l,e){let t=l.replace(m.findPipe,(i,r,o)=>{let a=!1,c=r;for(;--c>=0&&o[c]==="\\";)a=!a;return a?"|":" |"}),n=t.split(m.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length0?-2:-1}function me(l,e,t,n,s){let i=e.href,r=e.title||null,o=l[1].replace(s.other.outputLinkReplace,"$1");n.state.inLink=!0;let a={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:i,title:r,text:o,tokens:n.inlineTokens(o)};return n.state.inLink=!1,a}function rt(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let s=n[1];return e.split(`
+`).map(i=>{let r=i.match(t.other.beginningSpace);if(r===null)return i;let[o]=r;return o.length>=s.length?i.slice(s.length):i}).join(`
+`)}var S=class{options;rules;lexer;constructor(e){this.options=e||w}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:A(n,`
+`)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=rt(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=A(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:A(t[0],`
+`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=A(t[0],`
+`).split(`
+`),s="",i="",r=[];for(;n.length>0;){let o=!1,a=[],c;for(c=0;c1,i={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let r=this.rules.other.listItemRegex(n),o=!1;for(;e;){let c=!1,p="",u="";if(!(t=r.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let d=t[2].split(`
+`,1)[0].replace(this.rules.other.listReplaceTabs,Z=>" ".repeat(3*Z.length)),g=e.split(`
+`,1)[0],T=!d.trim(),f=0;if(this.options.pedantic?(f=2,u=d.trimStart()):T?f=t[1].length+1:(f=t[2].search(this.rules.other.nonSpaceChar),f=f>4?1:f,u=d.slice(f),f+=t[1].length),T&&this.rules.other.blankLine.test(g)&&(p+=g+`
+`,e=e.substring(g.length+1),c=!0),!c){let Z=this.rules.other.nextBulletRegex(f),te=this.rules.other.hrRegex(f),ne=this.rules.other.fencesBeginRegex(f),se=this.rules.other.headingBeginRegex(f),xe=this.rules.other.htmlBeginRegex(f);for(;e;){let G=e.split(`
+`,1)[0],C;if(g=G,this.options.pedantic?(g=g.replace(this.rules.other.listReplaceNesting," "),C=g):C=g.replace(this.rules.other.tabCharGlobal," "),ne.test(g)||se.test(g)||xe.test(g)||Z.test(g)||te.test(g))break;if(C.search(this.rules.other.nonSpaceChar)>=f||!g.trim())u+=`
+`+C.slice(f);else{if(T||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||ne.test(d)||se.test(d)||te.test(d))break;u+=`
+`+g}!T&&!g.trim()&&(T=!0),p+=G+`
+`,e=e.substring(G.length+1),d=C.slice(f)}}i.loose||(o?i.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(o=!0));let y=null,ee;this.options.gfm&&(y=this.rules.other.listIsTask.exec(u),y&&(ee=y[0]!=="[ ] ",u=u.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:p,task:!!y,checked:ee,loose:!1,text:u,tokens:[]}),i.raw+=p}let a=i.items.at(-1);if(a)a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let c=0;cd.type==="space"),u=p.length>0&&p.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=u}if(i.loose)for(let c=0;c({text:a,tokens:this.lexer.inline(a),header:!1,align:r.align[c]})));return r}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===`
+`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let r=A(n.slice(0,-1),"\\");if((n.length-r.length)%2===0)return}else{let r=de(t[2],"()");if(r===-2)return;if(r>-1){let a=(t[0].indexOf("!")===0?5:4)+t[1].length+r;t[2]=t[2].substring(0,r),t[0]=t[0].substring(0,a).trim(),t[3]=""}}let s=t[2],i="";if(this.options.pedantic){let r=this.rules.other.pedanticHrefTitle.exec(s);r&&(s=r[1],i=r[3])}else i=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),me(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[s.toLowerCase()];if(!i){let r=n[0].charAt(0);return{type:"text",raw:r,text:r}}return me(n,i,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let r=[...s[0]].length-1,o,a,c=r,p=0,u=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(u.lastIndex=0,t=t.slice(-1*e.length+r);(s=u.exec(t))!=null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o)continue;if(a=[...o].length,s[3]||s[4]){c+=a;continue}else if((s[5]||s[6])&&r%3&&!((r+a)%3)){p+=a;continue}if(c-=a,c>0)continue;a=Math.min(a,a+c+p);let d=[...s[0]][0].length,g=e.slice(0,r+s.index+d+a);if(Math.min(r,a)%2){let f=g.slice(1,-1);return{type:"em",raw:g,text:f,tokens:this.lexer.inlineTokens(f)}}let T=g.slice(2,-2);return{type:"strong",raw:g,text:T,tokens:this.lexer.inlineTokens(T)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){let t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||w,this.options.tokenizer=this.options.tokenizer||new S,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:B.normal,inline:P.normal};this.options.pedantic?(t.block=B.pedantic,t.inline=P.pedantic):this.options.gfm&&(t.block=B.gfm,this.options.breaks?t.inline=P.breaks:t.inline=P.gfm),this.tokenizer.rules=t}static get rules(){return{block:B,inline:P}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,`
+`),this.blockTokens(e,this.tokens);for(let t=0;t(s=r.call({lexer:this},e,t))?(e=e.substring(s.raw.length),t.push(s),!0):!1))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);let r=t.at(-1);s.raw.length===1&&r!==void 0?r.raw+=`
+`:t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.text,this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.raw,this.inlineQueue.at(-1).src=r.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let i=e;if(this.options.extensions?.startBlock){let r=1/0,o=e.slice(1),a;this.options.extensions.startBlock.forEach(c=>{a=c.call({lexer:this},o),typeof a=="number"&&a>=0&&(r=Math.min(r,a))}),r<1/0&&r>=0&&(i=e.substring(0,r+1))}if(this.state.top&&(s=this.tokenizer.paragraph(i))){let r=t.at(-1);n&&r?.type==="paragraph"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s),n=i.length!==e.length,e=e.substring(s.raw.length);continue}if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="text"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(e){let r="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(r);break}else throw new Error(r)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){let o=Object.keys(this.tokens.links);if(o.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)o.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(s=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,r="";for(;e;){i||(r=""),i=!1;let o;if(this.options.extensions?.inline?.some(c=>(o=c.call({lexer:this},e,t))?(e=e.substring(o.raw.length),t.push(o),!0):!1))continue;if(o=this.tokenizer.escape(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.tag(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.link(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(o.raw.length);let c=t.at(-1);o.type==="text"&&c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(o=this.tokenizer.emStrong(e,n,r)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.codespan(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.br(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.del(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.autolink(e)){e=e.substring(o.raw.length),t.push(o);continue}if(!this.state.inLink&&(o=this.tokenizer.url(e))){e=e.substring(o.raw.length),t.push(o);continue}let a=e;if(this.options.extensions?.startInline){let c=1/0,p=e.slice(1),u;this.options.extensions.startInline.forEach(d=>{u=d.call({lexer:this},p),typeof u=="number"&&u>=0&&(c=Math.min(c,u))}),c<1/0&&c>=0&&(a=e.substring(0,c+1))}if(o=this.tokenizer.inlineText(a)){e=e.substring(o.raw.length),o.raw.slice(-1)!=="_"&&(r=o.raw.slice(-1)),i=!0;let c=t.at(-1);c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(e){let c="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return t}};var $=class{options;parser;constructor(e){this.options=e||w}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(m.notSpaceStart)?.[0],i=e.replace(m.endingNewline,"")+`
+`;return s?''+(n?i:R(i,!0))+`
+`:""+(n?i:R(i,!0))+`
+`}blockquote({tokens:e}){return`
+${this.parser.parse(e)}
+`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)}
+`}hr(e){return`
+`}list(e){let t=e.ordered,n=e.start,s="";for(let o=0;o
+`+s+""+i+`>
+`}listitem(e){let t="";if(e.task){let n=this.checkbox({checked:!!e.checked});e.loose?e.tokens[0]?.type==="paragraph"?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=n+" "+R(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`${t}
+`}checkbox({checked:e}){return" '}paragraph({tokens:e}){return`${this.parser.parseInline(e)}
+`}table(e){let t="",n="";for(let i=0;i${s}`),`
+`}tablerow({text:e}){return`
+${e}
+`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`${n}>
+`}strong({tokens:e}){return`${this.parser.parseInline(e)} `}em({tokens:e}){return`${this.parser.parseInline(e)} `}codespan({text:e}){return`${R(e,!0)}`}br(e){return" "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),i=V(e);if(i===null)return s;e=i;let r='"+s+" ",r}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let i=V(e);if(i===null)return R(n);e=i;let r=` ",r}text(e){return"tokens"in e&&e.tokens?this.parser.parseInline(e.tokens):"escaped"in e&&e.escaped?e.text:R(e.text)}};var _=class{strong({text:e}){return e}em({text:e}){return e}codespan({text:e}){return e}del({text:e}){return e}html({text:e}){return e}text({text:e}){return e}link({text:e}){return""+e}image({text:e}){return""+e}br(){return""}};var b=class l{options;renderer;textRenderer;constructor(e){this.options=e||w,this.options.renderer=this.options.renderer||new $,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new _}static parse(e,t){return new l(t).parse(e)}static parseInline(e,t){return new l(t).parseInline(e)}parse(e,t=!0){let n="";for(let s=0;s{let o=i[r].flat(1/0);n=n.concat(this.walkTokens(o,t))}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let r=t.renderers[i.name];r?t.renderers[i.name]=function(...o){let a=i.renderer.apply(this,o);return a===!1&&(a=r.apply(this,o)),a}:t.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let r=t[i.level];r?r.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens)}),s.extensions=t),n.renderer){let i=this.defaults.renderer||new $(this.defaults);for(let r in n.renderer){if(!(r in i))throw new Error(`renderer '${r}' does not exist`);if(["options","parser"].includes(r))continue;let o=r,a=n.renderer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u||""}}s.renderer=i}if(n.tokenizer){let i=this.defaults.tokenizer||new S(this.defaults);for(let r in n.tokenizer){if(!(r in i))throw new Error(`tokenizer '${r}' does not exist`);if(["options","rules","lexer"].includes(r))continue;let o=r,a=n.tokenizer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.tokenizer=i}if(n.hooks){let i=this.defaults.hooks||new L;for(let r in n.hooks){if(!(r in i))throw new Error(`hook '${r}' does not exist`);if(["options","block"].includes(r))continue;let o=r,a=n.hooks[o],c=i[o];L.passThroughHooks.has(r)?i[o]=p=>{if(this.defaults.async)return Promise.resolve(a.call(i,p)).then(d=>c.call(i,d));let u=a.call(i,p);return c.call(i,u)}:i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.hooks=i}if(n.walkTokens){let i=this.defaults.walkTokens,r=n.walkTokens;s.walkTokens=function(o){let a=[];return a.push(r.call(this,o)),i&&(a=a.concat(i.call(this,o))),a}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let i={...s},r={...this.defaults,...i},o=this.onError(!!r.silent,!!r.async);if(this.defaults.async===!0&&i.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);let a=r.hooks?r.hooks.provideLexer():e?x.lex:x.lexInline,c=r.hooks?r.hooks.provideParser():e?b.parse:b.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(n):n).then(p=>a(p,r)).then(p=>r.hooks?r.hooks.processAllTokens(p):p).then(p=>r.walkTokens?Promise.all(this.walkTokens(p,r.walkTokens)).then(()=>p):p).then(p=>c(p,r)).then(p=>r.hooks?r.hooks.postprocess(p):p).catch(o);try{r.hooks&&(n=r.hooks.preprocess(n));let p=a(n,r);r.hooks&&(p=r.hooks.processAllTokens(p)),r.walkTokens&&this.walkTokens(p,r.walkTokens);let u=c(p,r);return r.hooks&&(u=r.hooks.postprocess(u)),u}catch(p){return o(p)}}}onError(e,t){return n=>{if(n.message+=`
+Please report this to https://github.com/markedjs/marked.`,e){let s="An error occurred:
"+R(n.message+"",!0)+" ";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new E;function k(l,e){return M.parse(l,e)}k.options=k.setOptions=function(l){return M.setOptions(l),k.defaults=M.defaults,N(k.defaults),k};k.getDefaults=z;k.defaults=w;k.use=function(...l){return M.use(...l),k.defaults=M.defaults,N(k.defaults),k};k.walkTokens=function(l,e){return M.walkTokens(l,e)};k.parseInline=M.parseInline;k.Parser=b;k.parser=b.parse;k.Renderer=$;k.TextRenderer=_;k.Lexer=x;k.lexer=x.lex;k.Tokenizer=S;k.Hooks=L;k.parse=k;var it=k.options,ot=k.setOptions,lt=k.use,at=k.walkTokens,ct=k.parseInline,pt=k,ut=b.parse,ht=x.lex;
+
+if(__exports != exports)module.exports = exports;return module.exports}));
diff --git a/client/rules.md b/client/rules.md
new file mode 100644
index 0000000..93570d9
--- /dev/null
+++ b/client/rules.md
@@ -0,0 +1,165 @@
+# "Чешский дурак"
+
+## 🎯 Цель игры
+
+Избавиться от всех карт в руке первым. Игрок, который первым выложит все свои карты, побеждает в раунде. Остальные игроки получают штрафные очки за оставшиеся карты.
+
+## 👥 Количество игроков
+
+От 2 до 4 игроков.
+
+## 🃏 Колода
+
+Стандартная колода из 52 карт (без джокеров).
+
+## 🎲 Начало игры
+
+1. Каждому игроку раздаётся по **5 карт**
+2. Одна карта кладётся в центр стола (открытая карта)
+3. Остальные карты образуют колоду для взятия
+4. **Дилер** определяется случайно в первой игре
+5. Ход начинается с игрока **слева от дилера** (по часовой стрелке)
+
+## 🎮 Как ходить
+
+На свой ход игрок должен положить карту, которая подходит к верхней карте на столе:
+- **По масти** (♥️ на ♥️, ♠️ на ♠️, и т.д.)
+- **По рангу** (7 на 7, К на К, и т.д.)
+
+### Если нет подходящей карты:
+1. Нажмите кнопку **"Взять"** - возьмите одну карту из колоды
+2. Если взятая карта подходит - можете её сыграть
+3. Если не подходит - нажмите **"Нету"** (пропуск хода)
+
+## 🎴 Специальные карты
+
+### 🔴 Шестёрка (6)
+- Следующий игрок **берёт 1 карту** из колоды
+- Его ход **пропускается**
+
+### 🔴 Семёрка (7)
+- Следующий игрок **берёт 2 карты** из колоды
+- Его ход **пропускается**
+
+### 🔴 Восьмёрка (8)
+- Следующий игрок **тянет карты из колоды**, пока не попадётся:
+ - **Двойка (2)** - любой масти
+ - **Дама (Q)** - любой масти
+ - **Восьмёрка (8)** - любой масти
+ - **Карта той же масти**, что и восьмёрка на столе
+- Из руки на восьмёрку можно положить **только двойку**
+- Карты, взятые из колоды, можно положить если они подходят
+- ⚠️ **Если игрок заканчивает на восьмёрку** - игра сразу завершается, никто не берёт карты
+
+### 🔴 Туз (A)
+- Следующий игрок **пропускает ход**
+- Карты не берёт
+
+### 👑 Дама (Q)
+- **Универсальная карта** - можно положить на любую карту
+- После игры дамы нужно **выбрать масть** для следующего хода
+- Следующий игрок должен положить карту выбранной масти или того же ранга (другую **Даму (Q)** )
+
+## 📊 Подсчёт очков
+
+После каждого раунда проигравшие получают очки за карты, оставшиеся в руке:
+
+| Карта | Очки |
+|:---:|:---:|
+| A | 11 |
+| K | 4 |
+| Q | ♥️♣️♦️ 20 ♠️ 40 |
+| J | 2 |
+| 10 | 10 |
+| 9 | 9 |
+| 8 | 8 |
+| 7 | 7 |
+| 6 | 6 |
+| 5 | 5 |
+| 4 | 4 |
+| 3 | 3 |
+| 2 | 2 |
+
+**Победитель раунда** получает **0 очков**.
+
+## 🏆 Победа и поражение
+
+### Условия продолжения игры:
+- Игра продолжается раунд за раундом
+- Очки **накапливаются** после каждого раунда
+- В новом раунде **дилером становится проигравший** предыдущего раунда (кто набрал больше всех очков в раунде)
+
+### Специальные правила по очкам:
+
+#### 👑 Победа дамой
+Если игрок **побеждает, положив даму** как последнюю карту:
+- У него **вычитается 20 очков** от общего счёта (для любой дамы)
+- Если это **пиковая дама (Q♠️)** - вычитается **40 очков**
+- Счёт может стать отрицательным
+
+#### ⚡ Ровно 101 очко
+Если игрок набирает **ровно 101 очко**, его счёт **обнуляется до 0**.
+
+#### ❌ Больше 101 очка
+Если игрок набирает **больше 101 очка**, он **выбывает из игры**:
+- При 2 игроках: второй игрок объявляется **победителем**, комната закрывается
+- При 3-4 игроках: выбывший покидает комнату, остальные продолжают играть
+
+### 🎊 Финальная победа
+Игрок, который остался последним (все остальные набрали >101), становится **абсолютным победителем**.
+
+## 🎯 Стратегия
+
+### Советы для игры:
+1. **Избавляйтесь от старших карт** (тузы, короли) - они дают больше очков
+2. **Держите дам** для критических моментов - они универсальны
+3. **Используйте специальные карты** (6, 7, 8, A) стратегически
+4. **Следите за счётом** - приближение к 101 очку опасно
+5. **Помните**: на 101 очко счёт обнуляется, но 102+ = вылет!
+
+## 🔄 Порядок хода
+
+1. Ходы идут **по часовой стрелке**
+2. Текущий игрок **подсвечивается** на экране
+3. В свой ход можно:
+ - Сыграть подходящую карту
+ - Взять карту из колоды
+ - Пропустить ход (если уже взяли карту)
+
+## 📱 Интерфейс
+
+- **Ваши карты** - внизу экрана
+- **Противники** - вверху и по бокам (на десктопе) или в ряд сверху (на мобильном)
+- **Центр** - текущая карта на столе и колода
+- **Счёт** - отображается у каждого игрока
+- **Лог** - история действий в левом углу (последние 10 событий)
+
+## ⌨️ Горячие клавиши
+
+Для удобства игры доступны следующие горячие клавиши:
+
+### 💬 Чат
+- **T** (или **Е** на русской раскладке) - открыть чат
+- **Enter** - отправить сообщение и закрыть чат
+
+### 🎮 Игровые действия
+- **Пробел (Space)** - взять карту из колоды / пропустить ход
+ - Автоматически нажимает нужную кнопку в зависимости от ситуации
+ - Если карта не взята → нажимает **"Взять"**
+ - Если карта уже взята → нажимает **"Нету"** (пропуск хода)
+ - ⚠️ **Не работает когда чат открыт** - пробел вводится в текст
+
+### 📋 Правила использования
+- Горячие клавиши работают только в свой ход
+- Не срабатывают в полях ввода (кроме специальных случаев)
+- Чат можно открыть в любой момент игры
+
+## ⚙️ Дополнительные правила
+
+- Если **колода закончилась**, сброс (кроме верхней карты) перемешивается и становится новой колодой
+- Нельзя присоединиться к комнате, где у игроков уже есть очки (игра началась)
+- При выходе игрока из комнаты во время игры, игра продолжается с оставшимися игроками
+
+---
+
+**Приятной игры! 🎴**
diff --git a/client/sounds/ace.aac b/client/sounds/ace.aac
new file mode 100644
index 0000000000000000000000000000000000000000..57e7aae07e7ede49db906bab3019d866a5801046
GIT binary patch
literal 17833
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
ZmV*fX-yV9K^1qlF{+H6p|LRS9{}-vF<4yno
literal 0
HcmV?d00001
diff --git a/client/sounds/ace.ogg b/client/sounds/ace.ogg
new file mode 100644
index 0000000000000000000000000000000000000000..c44d7d8e57e31e3cc96fba142d08675aaf18f1f3
GIT binary patch
literal 11565
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
literal 0
HcmV?d00001
diff --git a/client/sounds/alert.aac b/client/sounds/alert.aac
new file mode 100644
index 0000000000000000000000000000000000000000..dc75bd2b902bbfd86cdafd8897ff312f93f13a0a
GIT binary patch
literal 21177
zcmeFZRd6Lsm!)~c%*@Qpj45VjDaFi~;v;5AF*7q$ikX?2nOP~N)c4+hs=LbOrL3N&
zdTPIfg;~Ul`{#S@z1$wjY57GAmhc(&)71wu{?+ohR>^4oXDDu$7Z(q8UEok1AEQMRM`VEC7
zJSi}HVp#OS4WIOZ
z#%m3XWrK90l-R+$L>DWUBNP&`Z&%u`BCuSe#^~}7V`E5fRJRE*(2D)>3#Ox){EFfC
z`##1d(co
zG%w-}6oxQdJz$ul)5DU%Me697Hx4LG(NrVa9`2+M@^o&i
z2GGc^=g^qPfPD!DNu+91imA(2|tpD7cKlN@J{0
zkq<%*%?whsqQuD1}Rc#3q_2sEMkUZW%Y_p>3B152H?)
zNOhNEyFC=8yFHK;&H4vfSP{Nt3M%1EfYVt07vsm@u$YS2lE~Xhf6sHsU@nSiSHL6T
zDum(TP#&7O$T*|?-S)KZlqK8lKp57*$h1Xm-Nc0&98v0O;30M?SiSUmah+|{{^jsk
zL$dGF^@5mI+XF4!Pb?lEf2P#j<`z1(%C_wg_`DpaTYJW!-AKep;FN=<8aot*y>AwK
zcE?fT@4xdYFx@4dVf5!~ttj+6+1}fXlwx0f-~toWa_8Gzh-A8PRp?DvDuPHMuc1#Y=&J@tj$BG2MDb<41D(0rWdI-(n45Xmv9w&7D?U{P5xr<_V>}Kfe+9q>8w9LaJc$y!x)_Vg5dq
zU0HIPkzU}VT|1hlxlHWB!w^vRt2u9eYl*y4sDx2WUtT*S>xt^}b~mfmIK40u1vu(X
zfgjdEwxT3wKf(1#EBpYW)FaW6w)rb5ORQ!O4gLzUx|}|Fk%ww~`*x03laFUn{iF7j
z4ZAj{=x&sz#qf2MpTMMI9JS%le&gqfSKW5%mosT&CgEY!4gmT0x2XFy$U49T)wWmtBU
z_(Eew$%L`8@HV0*P-{mLB&%g}cllN?Uggm8Wg6wZZ`XtuMOr&k=3zXwRIT=wtCx#l
z5X{^k*nxX>1?eYy<@(Xi%1l(Ub4zP%CfUI1VlQSQ;A3s}ZF#;@v>mbHgCX`Zn=K|x
zYFQar1i4XvQ09kEPMFa0&)^(r>vgKNxOJN3BF8WSYf&TGt!>u#+vlW&n~G(ljOXNu
zKROo5>#HG{K!(Kkc7F7-t(Eg$S>ju65dq+|#8P}#9dU3)fhj4k@rW4DPPR2{F!hV~
z{A7qk)>rV&bP#NpvEiH>4bRG>sljT#0u
zajNN6H37koQ{Ysp8wor~BdUVNGh^#T-g+{UWX<{u#yl;HXO8)-_PF&0L%3?3Hra4ZB}TQeAJ|7#J^!wGD0W8!+=n5qaI2|wyZDS
zOdpo^)F~EMSI7|
z?$vU;#bUchyue_lUj#kwp@+dxsbY;Y_n$KZ9
zz$vz&aQd>WE=_QPLJ>J3
z_9rSEB$C#We5o8OIK~n;mRYWqB&;cftg=FefTdOg-2H~oGbo=uf&xQ+Y#up+noWad
zrY)eOzcVu-CvO~;L?CN!+DHW`JA~*HL}Rh&6#B`uDZQfHErEk#jW?D;)qksP)GIs5
zX|5({x9G@PZB*+xg8WT%Q9NNm#)yW)y4dbZbAZbbH1-bENLY)S6Bbzi_BtOR0q&F?XFjUky+#Ktrk)ovb2l_r=1SDp1->&(IQ5X((axjPY
zWaYvjaHb?8ZRtZ21AjaV22VMBdb?~^v#&3#l7qY!+^QTPi$F$f0qRU_}b8
zt14pVPb;)CNEZHiM%2g)o5CzmLZ{srcy@3s-XTkk6}Bv-UsLM(kg`QewWh}Id
zZRGFbg$N_?!E#)oi-|Ycjqi=RH~4+1HW#NPzZH)pRqV$Ohv!Vw4H@m>YAWC#1f>F?
z|3VOB8p0@_c~4Fq8azltYxrj^dH4Z%T2c}bIW86kybw>#Q2+25&8mK;!0#HBLif%p
zYWh;%Q-k>RWE!T3Mx*WZ!9i&As#n1deqe6ZA8bhK_Dgbqf{7>s8i7*0f>MYvzuE60Y5
z_>c0|;8u2ZQ^wBIu7`y8A3z6-J7|YSmEMofO5Tv3<^vhL^
zQKBhMKdW@PXeFF>*WtV|w=`~=!?h04lUli47u+E76KU?s%RAPH+inp(%8Yfb>Q<8EZ?UGHOWjpsQJm3dI$B!
z9Wn)zT9D($M?CZ%WC>}w(gC1PA2&FRKtVODRU{S(zyk!yfpM*&mrR6#Z3fB{oZ3wM
zqp5-<tBR{%P|(XbIB%#RksS$?{Rw5M^l)P
zEB)E(aAlD!rS!(mawZk@1P8mR*M0|VI3FtZA=OZd)XD{2d!Z-WZNNbRhV_N{1mw;>
zqL4#UJDJ}EFjb9eJXRr=f9_(VLTbIEwvb=>`N_V8Gi6|P1CFV#VY`^=
zkiP?OVM}-h&C*GNXso9L`xH}3(*S99$iu+qw&lj}$=<~}53;kYX*Nn~x)Snhxva3A
z!=iB#WQO90uK*KEK=h$z@#8Euv@u{*eU!a@hlMY!%IvgFo~bTCP-#j~38x;QsGm2i
zqy=5KKUdy=7+DY$6{eV6^NP`ClWhcZ71$3u!y0|B5~glyulaq)nPkRy?z((+37T9>
zyN`l+*UZ~Xw5D`(v_9!KB`PsyjB|FW3DVnuxD|5ynIq>oSF2H;j+&mj6ux_dz~U$*
zi*pQ}TtndQeFUv5ef~V3f@^X}KL+FK9&eGMD-(~N1#o;I0!I>qq0BsCzmQNIvm^P_#e(rRl4`-0FCLo%Tc?@+K`CCWVaBeB;dCbi{z%Dv
zap3(3{RFl9LLkDCCOXfkN3f{V=#o%?a}g>J|HCd&)(vgBi_xYm_cG>_Y?1=R2H@!B
zim*%&8^(-q=I;b9;GDQZ91xm^Fr+A%*uu~`zza4?QWqs%2Xm_A;V07z0AR`L)nHjW
zE#mzMqr*wBALg;n5omu1DuO-OiFR{f_N1G`JsZBEfJ&0=@!k6A^}Or?z}2mdK>?fz
zE9xKGazIC8t{s_$zfxxDKpQfc!?02))I{apictn}+C(-+pKKZt4=My|Ks@Lr44NEE
z=ZB%@2G|H|3V5}-YE#C-*BncN+=@n*ws@Fm^;Tp(S%(D0fixu}wa=@O3L+*9PbnfX
zvoq$7AI+hW5Q=VvlqZ?wKSq>Cs|}=h*gg{xZqzcGLyjHRMzk_!7FT1t8-&?kK&UN5
zsqS%5V&1~qn)GgWRJG3vGgZ8Z#wRr9wnxjzk^_)fQUTn5mnF0yog{cjfByL20%f-h
zB@9`DkpU+W{&*S9LqJ^;ohVO161iwCjTY{tvS8_f2zw*66ug3Np*YJNx1gcS6VKb=
zJDA^`0wHQrkI1$R)w_3gW@ApRDlf*ZGSY!S-PkNz@+$Ri(tXsuDWHaSibhPB%l9-GtCO^;hV)8bi}&Y4=86
zu3y{Y5y|K`uH^oNb=o=#^ak4e24r8q#Zdt$+MZMhuAu@hlMgCf0z?Yvp;f^rD&sK#8c5^q93|knyFl@JJDMU#pkYvCV4W8aNu)}3-XMns3
zxh6n&{@C-48{f@4C?+ra8<+HCwrFmyB|u=6U0MIeH+V%3MYy1|UpgSfX3b5jJNw(u
zeHPLNb{<|{Ta#R{gy^DV@dLb9x*%Q#G8uAwx4d`|pAI$$;lcoGV(12$jh!uYn-Tac
z)Cq~Fdu?ce{yh0jVrMbzaAHXx{{D`={&j00ZQQ(W+9VRZ7#%S-IX6$4q~_#7`m$Ms
z?zVgdLi=qJCz$Ue<38tabxicH;Pdr(g%$GPsvB~UzNA7EZ6_{xxwUz_?{&2#wI_LQ
zC*M9!#u|c_9=TjQxK6wl5+3dGqM}j}aDUn3oX#c}^}X4qPr*$yX}kPoFp2&*G5`MM
z%k}*|sT+-70AaZ;Y*D3f0=UHoMDBi|&Iwgzs8~hvX+ofEZFWM_njHp6wWWI+UE30=
zuRHHtJ86L%1g3ZF(SjTo5-y+Rc9rVcnJ@J<;bY+rzak#bK2#xH+hrC)3dMVD6($Qm
zM(@D^<)T!!jLCrl3^{Z9vl9>yS=KFqtVbh_1;ba7L=Lc7vqzu?b3hG44>qV$ESK%8
z3$`90IcUSI&J@W;l><$Li~FH({5%~pV8)`cXL8G=Z#7?kOo#g0y=xBDixM+Lvcoho
zfTD|=U|iosT2dfm%P>tj{7M50#*<|j?B*~!F=(Ut2xTv5ft?O#YMY7$kww%z-^Q{H
zvkC8O@V{(X{TH3C|1ZDh5fH92{~zP_@09wt<&TH7ruhjll+lOJYedF|Y)&ji_f`o2
zFQ*QZX_IG(Fozby>8H2=uj4dJSQl%q)camqKx{EJ#WLM{M%U4b=kj9RX3RR+RmeS4
z>e{V=t{d-Ly)P9Skn~og?Fu!hgIx{V;(@J|xb=z*pw7Wo9vDBWiM|MKg9sfZM27@5
zI6Q#?I$rFd3FkR!K--pgQ1zn9Ax{797)YF`D>X8{P_3xL)2$z!)MigH%;3qaT53nI
z$Mrs`OhBkQ3m(_pfR`{Q3C}wySQ`(6x1&8N3Th8U?7mT`oxjy$3MkgL&~0IDDJM6K
zmudn!d?6SAPyFu@*ybdW{ZGa}dhst<_D{a*-{kcS%?)Lu#lWGmG5n@Gpg~+w-(ZfT
z#mv$VOi9*xqJs@O9AyR{1k=g#1GFNg)-?&71>yk)Rn<>WsTD!8?S56H}iJs%Pdxf6ANAgCOGpJZ|ZDqKJRM9F2>N
zyxQo%_yg5oY%<}cP?66r>DITX0ZU@h^U!BUYuiZP3{i9pa*E7$wLKA^GDxdRn7Zzf18Y9qeTmku_UZ!^vUFC6VBa
zI{Up&!Cjmvg?pJ!`<-sYEm08uXDjI6wt@b>{^}F^f2Y}hEdPJO^7-}2{wbrE)Z5Ao
z57of5zC{~=C7euxe3}qZfPoR7dw2M4VpKFW0#{$7(Z_lP?
z0~K9f6>lTV(1%L2PF+KugZP69aeMao>_cH0=JzGWPsJdZSl=QtO)~~zIJjy;jc`N=
zb>?1Y)`1BAA8a$axdSsSI@^hgd$KF=4;P+8MnHk-*}ux@iK7!HyFh7h!+L6D_SUT5
z`dQQk#Q1AU>0kzm?(}~AwGt8QvSm*&K2Fw==mioFXlGOenu{1F70_$mP1f!msZ1}eLmqa?rBMlL8t!yG-WGC<^Z=V
zhQ3O&dX}_pG!+KT3NXS0ngBqCKZ0+sYgbS#q1U>}y}?&UmQRlS?Y{{%@jRepD&Ym8
z;=;+r96p;T!(rYXkv*(O;n|rXgyEjvBLQHlWrxB5RQG4xXbCXnfWcl?s6JnAU_LS=
zx(q%kGEchTPvoR{G`)TP(IhdL#rJ#eD;#r<)8SPgU-U}UGM6EYUGPAz`^0gGYO!>3
z_(7-i66l)(U2mgyLr}@54T`@y24paRu-G&;Xm4^NHA2A|0918n|396v|C5RRzu9B|
zM~BdpdnX}K%XVXhp{?S&+=@e>MI4O-O=G_`Yf{mJ*O5~4o2wrjvfYkWwaRi&^c+EW
z;Dzs(R7IE+2K|N$l)RRsw2@}XI{g1Cb9ThO)-sCjP2l_e(S;vYs$R=oW>Whu}Dalm4og0K`vT~QxKoLf=INxYdo~01r
z3fR>$6WW4RLCp?JrL(2(69@RpY4Stz?q~sAp)of}lT5?)jYL7ZgR&QsmjNpK0FX!k
zfa~htEx!L)5&qwH_kSu}^qA&w6VM1XTVAqgP{bUo5gC9sazh>J7OEe3ugN;69~-So
z*((aduTglul+n+lqDV%L7XjOKUpfj(xG9&02>kGtRU_ju8MXJp4`2(nn-ucNeLD&*
zWG@UOiO#hQF^uzHq-b2M3kW+{UtB>0!CZV!p(l76^-gaH_CftYtbWn7A04>m5h0%#
z^h0zg5WI$UB&|tTM%lWQuHezy(y830K6>Z_pY!U2Ixtb}EVkCFp!5Yj=_SkI_O}5V4l&S=Vf$yts#VL*
zR%+f7mkjk{csvBD7Bp&kpIH7VXtSPM9srIbXEV>Yhnp^rEgxdoCF_zcm`;XO*