commit 5343a8f2c39d10811fbf2098abbbdfa732817090 Author: Oronemu Date: Sun Dec 14 02:38:35 2025 +0700 initial diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6873734 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Database +DB_PASSWORD=change_me_secure_password + +# Backend +SECRET_KEY=change_me_jwt_secret_key_at_least_32_chars +DEBUG=false + +# OpenAI API +OPENAI_API_KEY=sk-... + +# Telegram Bot +TELEGRAM_BOT_TOKEN=123456:ABC-DEF... + +# Frontend (for build) +VITE_API_URL=/api/v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..495a4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,68 @@ +# Environment variables (secrets) +.env +.env.local +.env.*.local + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +.venv/ +env/ +ENV/ +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.npm +.pnpm-store/ + +# Build outputs +frontend/dist/ +frontend/build/ +*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +*.sublime-* + +# OS +.DS_Store +Thumbs.db +Desktop.ini + +# Logs +*.log +logs/ + +# Claude Code local settings +.claude/ + +# Docker volumes data +postgres_data/ +redis_data/ + +# Misc +*.bak +*.tmp +*.temp diff --git a/CONCEPT.md b/CONCEPT.md new file mode 100644 index 0000000..03aa0a4 --- /dev/null +++ b/CONCEPT.md @@ -0,0 +1,375 @@ +# Game Marathon — Концепция + +## Общее описание + +**Game Marathon** — закрытая платформа для проведения игровых марафонов среди друзей (~10 человек), где за месяц нужно набрать максимум очков, выполняя случайные челленджи в случайных играх. + +### Ключевые особенности + +- Колесо рандома (игра → челлендж) +- Игры добавляют сами участники + ссылки на скачивание +- Челленджи генерирует нейросеть (интегрированное API) +- Пруфы обязательны (скриншоты/видео/Steam ачивки) — загружаются на сервер +- Социальные механики (вызовы, ставки, события) +- Уведомления через Telegram-бота +- Авторизация по логину/паролю + +--- + +## Жизненный цикл марафона + +### Фаза 1: Подготовка (3-7 дней до старта) + +**Действия участников:** + +1. Присоединяются к марафону (по ссылке-приглашению) +2. Добавляют игры (название + ссылка на скачивание + обложка) +3. Когда все готовы — организатор "закрывает" список игр +4. Система (нейросеть) генерирует челленджи для каждой игры +5. Участники могут посмотреть/предложить правки к челленджам +6. Организатор запускает марафон + +**Добавление игры включает:** + +- Название игры +- Ссылка на скачивание (для платных игр — сторонние ресурсы) +- Обложка (загрузка или URL) +- Жанр (опционально) + +### Фаза 2: Генерация челленджей + +После закрытия списка игр — автоматический запрос к API нейросети. + +**Для каждой игры генерируется 5-7 челленджей:** + +- 2 лёгких (15-30 минут) +- 2-3 средних (1-2 часа) +- 1-2 сложных (3+ часов) + +**Каждый челлендж содержит:** + +- Название +- Описание +- Тип (completion/no-death/speedrun/collection/achievement/etc) +- Примерное время выполнения +- Способ проверки (скриншот/видео/Steam ачивка) +- Количество очков + +После генерации участники могут предложить правки или добавить свои челленджи. + +### Фаза 3: Активный марафон (1 месяц) + +**Процесс выполнения:** + +``` +1. Крутишь колесо игр + ↓ + Выпадает случайная игра + ↓ +2. Крутишь колесо челленджей (для этой игры) + ↓ + Выпадает случайный челлендж + ↓ +3. Выбор: + • Принять → идёшь выполнять + • Дроп → штраф, крутишь заново + ↓ +4. Выполняешь задание + ↓ +5. Загружаешь пруф (скрин/видео/ссылка на Steam профиль) + ↓ +6. Получаешь очки + streak продолжается + ↓ +7. Можешь крутить снова +``` + +--- + +## Система очков + +### Базовые очки + +Зависят от сложности челленджа (определяет нейросеть при генерации): + +| Сложность | Очки | +|-----------|------| +| Лёгкий | 30-50 | +| Средний | 60-100 | +| Сложный | 120-200 | + +### Streak бонус + +Последовательное выполнение заданий без дропов: + +| Streak | Бонус | +|--------|-------| +| 1 | +0% | +| 2 | +10% | +| 3 | +20% | +| 4 | +30% | +| 5+ | +40% (максимум) | + +### Дроп штрафы + +Прогрессивная система штрафов: + +| Дроп # | Штраф | +|--------|-------| +| 1 | Бесплатно (streak сбрасывается) | +| 2 | -10 очков | +| 3 | -25 очков | +| 4 | -50 очков | +| 5+ | -50 очков + кулдаун 2 часа | + +Штрафы сбрасываются после успешного выполнения задания. + +--- + +## Подтверждение выполнения + +### Типы пруфов + +- **Скриншот** — загружается на сервер +- **Видео** — загружается на сервер или ссылка (YouTube) +- **Steam Achievement** — ссылка на профиль Steam с ачивкой + +### Процесс верификации + +1. Участник загружает пруф + опциональный комментарий +2. Пруф виден всем участникам в ленте активности +3. Любой участник может "оспорить" пруф (если считает невалидным) +4. Если нет споров за 24 часа — автоматически засчитывается +5. При споре — голосование участников или решение организатора + +--- + +## Социальные механики + +### Лента активности + +Отображает в реальном времени: + +- Выполненные челленджи (с пруфами) +- Дропы +- Кто крутит колесо и что выпало +- События марафона +- Вызовы между участниками + +### Вызов (Challenge) + +Когда участник выполнил челлендж, другие могут "вызвать" себя на тот же: + +- Вызов заменяет текущее активное задание +- Награда: стандартные очки за челлендж +- Бонус: +30 очков если выполнить быстрее оригинального исполнителя + +### Ставка (Bet) + +Перед началом выполнения участник может поставить часть своих очков: + +- **Выполнил:** награда + ставка x2 +- **Дропнул/провалил:** теряет ставку + +Варианты ставок: 0 / 25 / 50 / 100 очков + +--- + +## Система событий + +Случайные события во время марафона (1-2 раза в неделю). Могут запускаться автоматически или вручную организатором. + +| Событие | Описание | Длительность | +|---------|----------|--------------| +| **Золотой час** | Все очки x1.5 | 30-60 минут | +| **Общий враг** | Все получают одинаковое задание, топ-3 = бонус | До выполнения | +| **Двойной риск** | Дропы бесплатны, но очки x0.5 | 2 часа | +| **Джекпот** | Следующему кто крутит — гарантированно сложный челлендж с x3 очками | 1 спин | +| **Обмен** | Можно поменяться заданием с другим участником | 1 час | +| **Реванш** | Можно переделать любой свой проваленный челлендж за 50% очков | 4 часа | + +--- + +## Таблица лидеров + +Отображает для каждого участника: + +- Место в рейтинге +- Общее количество очков +- Текущий streak +- Количество выполненных челленджей +- Количество дропов + +--- + +## Модель данных + +### User (Пользователь) + +``` +- id +- login +- password_hash +- nickname +- avatar +- telegram_id (для уведомлений) +- created_at +``` + +### Marathon (Марафон) + +``` +- id +- title +- description +- organizer_id (User) +- start_date +- end_date +- status: preparing | active | finished +- invite_code +- settings (JSON: события вкл/выкл, etc) +- created_at +``` + +### Participant (Участник марафона) + +``` +- id +- user_id +- marathon_id +- total_points +- current_streak +- drop_count (текущий счётчик для штрафов) +- joined_at +``` + +### Game (Игра) + +``` +- id +- marathon_id +- title +- cover_image (путь к файлу на сервере) +- download_link +- genre +- added_by (User) +- created_at +``` + +### Challenge (Челлендж) + +``` +- id +- game_id +- title +- description +- type: completion | no_death | speedrun | collection | achievement | challenge_run | score_attack | time_trial +- difficulty: easy | medium | hard +- points +- estimated_time (в минутах) +- proof_type: screenshot | video | steam_achievement +- is_generated (boolean — создан AI или вручную) +- created_at +``` + +### Assignment (Задание — выпавшее участнику) + +``` +- id +- participant_id +- challenge_id +- status: active | completed | dropped +- proof_file (путь к файлу на сервере) +- proof_url (опционально — ссылка) +- proof_comment +- points_earned +- bet_amount +- started_at +- completed_at +``` + +### Event (Событие) + +``` +- id +- marathon_id +- type: golden_hour | common_enemy | double_risk | jackpot | swap | rematch +- start_time +- end_time +- is_active +- data (JSON — доп. данные события) +``` + +### Dispute (Оспаривание пруфа) + +``` +- id +- assignment_id +- raised_by (User) +- reason +- status: open | resolved_valid | resolved_invalid +- resolved_by (User — организатор или голосование) +- created_at +- resolved_at +``` + +--- + +## Уведомления (Telegram-бот) + +### Типы уведомлений + +- Марафон скоро начнётся +- Кто-то выполнил челлендж +- Тебя вызвали на челлендж +- Твой пруф оспорен +- Началось событие (Золотой час и т.д.) +- Напоминание: у тебя нет активного задания +- Марафон завершён — итоги + +### Настройки + +Пользователь может включить/выключить отдельные типы уведомлений. + +--- + +## Технические решения + +| Аспект | Решение | +|--------|---------| +| Авторизация | Логин/пароль | +| Хранение пруфов | Загрузка на сервер | +| Уведомления | Telegram-бот | +| Генерация челленджей | Интегрированное API нейросети | +| Формат марафона | 1 месяц (с перспективой других режимов) | + +--- + +## Перспективы развития + +### Дополнительные режимы марафонов + +- **Sprint** (4-12 часов) — интенсивный формат +- **Daily Challenge** (1 неделя) — по 1-3 задания в день +- **Tournament** — несколько раундов, выбывание + +### Дополнительные механики + +- Колесо модификаторов (без звука, одной рукой, и т.д.) +- Командные марафоны (2v2, 3v3) +- Глобальная статистика и достижения пользователя +- Рейтинговая система игроков + +--- + +## UI/UX — Основные экраны + +1. **Главная** — список марафонов (активные, завершённые) +2. **Авторизация** — вход/регистрация +3. **Создание марафона** — форма с настройками +4. **Лобби марафона** — подготовка, добавление игр +5. **Колесо** — основной геймплей (спин игры → спин челленджа) +6. **Текущее задание** — детали + загрузка пруфа +7. **Лента активности** — действия всех участников +8. **Таблица лидеров** — рейтинг участников +9. **История заданий** — все выполненные/дропнутые +10. **Профиль** — настройки, статистика diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc09a96 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# Game Marathon + +A web application for running gaming marathons with friends. Spin the wheel, complete challenges, earn points! + +## Features + +- Create private marathons and invite friends +- Add games with download links +- AI-generated challenges using GPT +- Spin the wheel for random game + challenge +- Points system with streak bonuses +- Leaderboard and activity feed +- Proof upload for completed challenges + +## Tech Stack + +- **Frontend**: React 18, TypeScript, Vite, Tailwind CSS, Zustand +- **Backend**: FastAPI, SQLAlchemy, PostgreSQL +- **AI**: OpenAI GPT-4o-mini +- **Infrastructure**: Docker, Nginx + +## Quick Start + +### Prerequisites + +- Docker and Docker Compose +- OpenAI API key + +### Setup + +1. Clone the repository: +```bash +cd WebApp +``` + +2. Create `.env` file: +```bash +cp .env.example .env +``` + +3. Edit `.env` and set your values: +```env +DB_PASSWORD=your_secure_password +SECRET_KEY=your_jwt_secret_at_least_32_characters +OPENAI_API_KEY=sk-your-openai-key +``` + +4. Start with Docker: +```bash +docker-compose up -d +``` + +5. Open http://localhost in your browser + +### Development Mode + +**Backend:** +```bash +cd backend +python -m venv venv +source venv/bin/activate # or venv\Scripts\activate on Windows +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +**Frontend:** +```bash +cd frontend +npm install +npm run dev +``` + +## Project Structure + +``` +WebApp/ +├── backend/ # FastAPI application +│ ├── app/ +│ │ ├── api/ # API endpoints +│ │ ├── models/ # SQLAlchemy models +│ │ ├── schemas/ # Pydantic schemas +│ │ ├── services/ # Business logic +│ │ └── core/ # Config, security +│ └── uploads/ # Uploaded files +├── frontend/ # React application +│ └── src/ +│ ├── api/ # API client +│ ├── components/# UI components +│ ├── pages/ # Page components +│ ├── store/ # Zustand store +│ └── types/ # TypeScript types +├── bot/ # Telegram bot (coming soon) +├── docker-compose.yml +└── nginx.conf +``` + +## API Documentation + +When backend is running, visit: +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## How to Play + +1. **Create Marathon** - Set title, description, and duration +2. **Invite Friends** - Share the invite code +3. **Add Games** - Everyone adds their favorite games +4. **Generate Challenges** - AI creates challenges for each game +5. **Start Marathon** - Begin the competition +6. **Spin & Play** - Spin the wheel, get a challenge, complete it +7. **Upload Proof** - Submit screenshot/video as evidence +8. **Earn Points** - Build streaks for bonus points! + +## Point System + +| Difficulty | Base Points | +|------------|-------------| +| Easy | 30-50 | +| Medium | 60-100 | +| Hard | 120-200 | + +**Streak Bonus:** +- 2 in a row: +10% +- 3 in a row: +20% +- 4 in a row: +30% +- 5+ in a row: +40% + +**Drop Penalties:** +- 1st drop: Free (streak resets) +- 2nd drop: -10 points +- 3rd drop: -25 points +- 4th+ drop: -50 points + +## License + +MIT diff --git a/TECHNICAL_PLAN.md b/TECHNICAL_PLAN.md new file mode 100644 index 0000000..2477eea --- /dev/null +++ b/TECHNICAL_PLAN.md @@ -0,0 +1,1332 @@ +# Game Marathon — Технический план + +## Обзор + +| Компонент | Технология | +|-----------|------------| +| Frontend | React 18 + TypeScript + Vite + Tailwind CSS | +| Backend | FastAPI + SQLAlchemy + PostgreSQL | +| AI | OpenAI GPT API (gpt-4o-mini) | +| Telegram | aiogram 3.x | +| Деплой | Docker + Docker Compose на VPS | + +### Ограничения + +| Параметр | Значение | +|----------|----------| +| Макс. размер пруфа | 15 МБ | +| Домен | Пока нет (локальная разработка) | +| HTTPS | Пока не требуется | + +### MVP скоуп + +**Включено:** +- Авторизация (логин/пароль, JWT) +- Создание и управление марафонами +- Добавление игр с ссылками +- Генерация челленджей через GPT +- Колесо рандома (игра → челлендж) +- Выполнение заданий с загрузкой пруфов +- Система очков (streak-бонусы, дроп-штрафы) +- Таблица лидеров +- Лента активности +- Telegram-бот с командами + +**Отложено на будущее:** +- События (золотой час и т.д.) +- Ставки +- Вызовы между участниками +- Оспаривание пруфов + +--- + +## Структура проекта + +``` +game-marathon/ +├── frontend/ # React приложение +│ ├── src/ +│ │ ├── components/ # UI компоненты +│ │ │ ├── ui/ # Базовые (Button, Input, Card...) +│ │ │ ├── layout/ # Layout компоненты +│ │ │ ├── wheel/ # Колесо +│ │ │ └── marathon/ # Компоненты марафона +│ │ ├── pages/ # Страницы +│ │ ├── hooks/ # React hooks +│ │ ├── api/ # API клиент (axios/fetch) +│ │ ├── store/ # Zustand store +│ │ ├── types/ # TypeScript типы +│ │ └── utils/ # Утилиты +│ ├── public/ +│ ├── index.html +│ ├── package.json +│ ├── tailwind.config.js +│ ├── tsconfig.json +│ └── vite.config.ts +│ +├── backend/ # FastAPI приложение +│ ├── app/ +│ │ ├── api/ +│ │ │ ├── v1/ +│ │ │ │ ├── auth.py # Авторизация +│ │ │ │ ├── users.py # Пользователи +│ │ │ │ ├── marathons.py # Марафоны +│ │ │ │ ├── games.py # Игры +│ │ │ │ ├── challenges.py # Челленджи +│ │ │ │ ├── assignments.py # Задания +│ │ │ │ ├── wheel.py # Колесо +│ │ │ │ └── feed.py # Лента активности +│ │ │ └── deps.py # Зависимости (get_db, get_current_user) +│ │ ├── models/ # SQLAlchemy модели +│ │ │ ├── user.py +│ │ │ ├── marathon.py +│ │ │ ├── participant.py +│ │ │ ├── game.py +│ │ │ ├── challenge.py +│ │ │ ├── assignment.py +│ │ │ └── activity.py +│ │ ├── schemas/ # Pydantic схемы +│ │ │ ├── user.py +│ │ │ ├── marathon.py +│ │ │ ├── game.py +│ │ │ ├── challenge.py +│ │ │ ├── assignment.py +│ │ │ └── common.py +│ │ ├── services/ # Бизнес-логика +│ │ │ ├── auth.py +│ │ │ ├── marathon.py +│ │ │ ├── wheel.py +│ │ │ ├── points.py +│ │ │ ├── gpt.py # Генерация челленджей +│ │ │ └── telegram.py # Отправка уведомлений +│ │ ├── core/ +│ │ │ ├── config.py # Настройки (pydantic-settings) +│ │ │ ├── security.py # JWT, хеширование паролей +│ │ │ └── database.py # Подключение к БД +│ │ └── main.py # Точка входа FastAPI +│ ├── alembic/ # Миграции БД +│ │ ├── versions/ +│ │ └── env.py +│ ├── uploads/ # Загруженные файлы (пруфы, обложки) +│ ├── alembic.ini +│ ├── requirements.txt +│ └── Dockerfile +│ +├── bot/ # Telegram бот +│ ├── handlers/ +│ │ ├── start.py # /start, привязка аккаунта +│ │ ├── status.py # /status +│ │ ├── leaderboard.py # /leaderboard +│ │ ├── current.py # /current (текущее задание) +│ │ └── help.py # /help +│ ├── services/ +│ │ ├── api.py # Клиент к backend API +│ │ └── notifications.py # Логика уведомлений +│ ├── keyboards/ # Inline/Reply клавиатуры +│ ├── config.py +│ ├── main.py +│ ├── requirements.txt +│ └── Dockerfile +│ +├── docker-compose.yml +├── .env.example +├── CONCEPT.md +├── TECHNICAL_PLAN.md +└── README.md +``` + +--- + +## База данных + +### ER-диаграмма (упрощённая) + +``` +┌─────────────┐ ┌─────────────────┐ ┌─────────────┐ +│ User │ │ Participant │ │ Marathon │ +├─────────────┤ ├─────────────────┤ ├─────────────┤ +│ id (PK) │──┐ │ id (PK) │ ┌──│ id (PK) │ +│ login │ │ │ user_id (FK) │────┘ │ title │ +│ password │ └───▶│ marathon_id(FK) │◀──────│ organizer_id│ +│ nickname │ │ total_points │ │ status │ +│ telegram_id │ │ current_streak │ │ start_date │ +│ created_at │ │ drop_count │ │ end_date │ +└─────────────┘ └─────────────────┘ └─────────────┘ + │ + │ 1:N + ▼ +┌─────────────┐ ┌─────────────────┐ ┌─────────────┐ +│ Game │ │ Assignment │ │ Challenge │ +├─────────────┤ ├─────────────────┤ ├─────────────┤ +│ id (PK) │──┐ │ id (PK) │ ┌──│ id (PK) │ +│ marathon_id │ │ │ participant_id │ │ │ game_id(FK) │ +│ title │ │ │ challenge_id │────┘ │ title │ +│ cover_url │ │ │ status │ │ description │ +│ download_url│ └───▶│ proof_file │ │ difficulty │ +│ added_by │ │ points_earned │ │ points │ +│ created_at │ │ started_at │ │ proof_type │ +└─────────────┘ │ completed_at │ │ est_time │ + └─────────────────┘ └─────────────┘ + +┌─────────────────┐ +│ Activity │ (лента активности) +├─────────────────┤ +│ id (PK) │ +│ marathon_id(FK) │ +│ user_id (FK) │ +│ type │ (spin, complete, drop, join, etc.) +│ data (JSON) │ +│ created_at │ +└─────────────────┘ +``` + +### Таблицы SQL + +#### users + +```sql +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + login VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + nickname VARCHAR(50) NOT NULL, + avatar_url VARCHAR(500), + telegram_id BIGINT UNIQUE, + telegram_username VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +#### marathons + +```sql +CREATE TABLE marathons ( + id SERIAL PRIMARY KEY, + title VARCHAR(100) NOT NULL, + description TEXT, + organizer_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(20) DEFAULT 'preparing', -- preparing, active, finished + invite_code VARCHAR(20) UNIQUE NOT NULL, + start_date TIMESTAMP, + end_date TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_marathons_status ON marathons(status); +CREATE INDEX idx_marathons_invite ON marathons(invite_code); +``` + +#### participants + +```sql +CREATE TABLE participants ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + marathon_id INTEGER REFERENCES marathons(id) ON DELETE CASCADE, + total_points INTEGER DEFAULT 0, + current_streak INTEGER DEFAULT 0, + drop_count INTEGER DEFAULT 0, -- счётчик для прогрессивного штрафа + joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(user_id, marathon_id) +); + +CREATE INDEX idx_participants_marathon ON participants(marathon_id); +CREATE INDEX idx_participants_points ON participants(marathon_id, total_points DESC); +``` + +#### games + +```sql +CREATE TABLE games ( + id SERIAL PRIMARY KEY, + marathon_id INTEGER REFERENCES marathons(id) ON DELETE CASCADE, + title VARCHAR(100) NOT NULL, + cover_path VARCHAR(500), -- путь к файлу на сервере + download_url VARCHAR(500) NOT NULL, + genre VARCHAR(50), + added_by INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_games_marathon ON games(marathon_id); +``` + +#### challenges + +```sql +CREATE TABLE challenges ( + id SERIAL PRIMARY KEY, + game_id INTEGER REFERENCES games(id) ON DELETE CASCADE, + title VARCHAR(100) NOT NULL, + description TEXT NOT NULL, + type VARCHAR(30) NOT NULL, -- completion, no_death, speedrun, etc. + difficulty VARCHAR(10) NOT NULL, -- easy, medium, hard + points INTEGER NOT NULL, + estimated_time INTEGER, -- в минутах + proof_type VARCHAR(20) NOT NULL, -- screenshot, video, steam + proof_hint TEXT, -- подсказка что должно быть на пруфе + is_generated BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_challenges_game ON challenges(game_id); +``` + +#### assignments + +```sql +CREATE TABLE assignments ( + id SERIAL PRIMARY KEY, + participant_id INTEGER REFERENCES participants(id) ON DELETE CASCADE, + challenge_id INTEGER REFERENCES challenges(id) ON DELETE CASCADE, + status VARCHAR(20) DEFAULT 'active', -- active, completed, dropped + proof_path VARCHAR(500), -- путь к файлу пруфа + proof_url VARCHAR(500), -- или внешняя ссылка + proof_comment TEXT, + points_earned INTEGER DEFAULT 0, + streak_at_completion INTEGER, -- какой был streak при выполнении + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP +); + +CREATE INDEX idx_assignments_participant ON assignments(participant_id); +CREATE INDEX idx_assignments_status ON assignments(participant_id, status); +``` + +#### activities + +```sql +CREATE TABLE activities ( + id SERIAL PRIMARY KEY, + marathon_id INTEGER REFERENCES marathons(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(30) NOT NULL, -- spin, complete, drop, join, start_marathon, etc. + data JSONB, -- дополнительные данные + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_activities_marathon ON activities(marathon_id, created_at DESC); +``` + +--- + +## API Эндпоинты + +### Авторизация + +| Метод | Путь | Описание | +|-------|------|----------| +| POST | `/api/v1/auth/register` | Регистрация | +| POST | `/api/v1/auth/login` | Вход (возвращает JWT) | +| POST | `/api/v1/auth/refresh` | Обновление токена | +| GET | `/api/v1/auth/me` | Текущий пользователь | + +#### Схемы + +```python +# Register +class UserRegister(BaseModel): + login: str # 3-50 символов, только a-z, 0-9, _ + password: str # минимум 6 символов + nickname: str # 2-50 символов + +# Login +class UserLogin(BaseModel): + login: str + password: str + +# Response +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserPublic +``` + +### Пользователи + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/api/v1/users/{id}` | Профиль пользователя | +| PATCH | `/api/v1/users/me` | Обновить свой профиль | +| POST | `/api/v1/users/me/avatar` | Загрузить аватар | +| POST | `/api/v1/users/me/telegram` | Привязать Telegram | + +### Марафоны + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/api/v1/marathons` | Список марафонов пользователя | +| POST | `/api/v1/marathons` | Создать марафон | +| GET | `/api/v1/marathons/{id}` | Детали марафона | +| PATCH | `/api/v1/marathons/{id}` | Обновить (только организатор) | +| DELETE | `/api/v1/marathons/{id}` | Удалить (только организатор) | +| POST | `/api/v1/marathons/{id}/start` | Запустить марафон | +| POST | `/api/v1/marathons/{id}/finish` | Завершить досрочно | +| POST | `/api/v1/marathons/join` | Присоединиться по invite_code | +| GET | `/api/v1/marathons/{id}/participants` | Список участников | +| GET | `/api/v1/marathons/{id}/leaderboard` | Таблица лидеров | + +#### Схемы + +```python +class MarathonCreate(BaseModel): + title: str + description: str | None = None + start_date: datetime # когда планируется старт + duration_days: int = 30 # длительность в днях + +class MarathonResponse(BaseModel): + id: int + title: str + description: str | None + organizer: UserPublic + status: str + invite_code: str + start_date: datetime | None + end_date: datetime | None + participants_count: int + games_count: int + my_participation: ParticipantInfo | None # если текущий юзер участник +``` + +### Игры + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/api/v1/marathons/{id}/games` | Список игр марафона | +| POST | `/api/v1/marathons/{id}/games` | Добавить игру | +| GET | `/api/v1/games/{id}` | Детали игры | +| DELETE | `/api/v1/games/{id}` | Удалить игру | +| POST | `/api/v1/games/{id}/cover` | Загрузить обложку | + +#### Схемы + +```python +class GameCreate(BaseModel): + title: str + download_url: str + genre: str | None = None + cover_url: str | None = None # внешняя ссылка на обложку + +class GameResponse(BaseModel): + id: int + title: str + cover_url: str | None + download_url: str + genre: str | None + added_by: UserPublic + challenges_count: int +``` + +### Челленджи + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/api/v1/games/{id}/challenges` | Челленджи игры | +| POST | `/api/v1/games/{id}/challenges` | Добавить вручную | +| POST | `/api/v1/marathons/{id}/generate-challenges` | Сгенерировать через GPT | +| PATCH | `/api/v1/challenges/{id}` | Редактировать | +| DELETE | `/api/v1/challenges/{id}` | Удалить | + +#### Схемы + +```python +class ChallengeCreate(BaseModel): + title: str + description: str + type: ChallengeType # enum + difficulty: Difficulty # enum: easy, medium, hard + points: int + estimated_time: int | None # минуты + proof_type: ProofType # enum: screenshot, video, steam + proof_hint: str | None + +class ChallengeResponse(BaseModel): + id: int + game: GameShort + title: str + description: str + type: str + difficulty: str + points: int + estimated_time: int | None + proof_type: str + proof_hint: str | None + is_generated: bool +``` + +### Колесо и задания + +| Метод | Путь | Описание | +|-------|------|----------| +| POST | `/api/v1/marathons/{id}/spin` | Крутить колесо | +| GET | `/api/v1/marathons/{id}/current-assignment` | Текущее активное задание | +| POST | `/api/v1/assignments/{id}/complete` | Завершить задание (с пруфом) | +| POST | `/api/v1/assignments/{id}/drop` | Дропнуть задание | +| GET | `/api/v1/marathons/{id}/my-history` | История своих заданий | + +#### Логика спина + +```python +# POST /api/v1/marathons/{id}/spin +# 1. Проверить что марафон активен +# 2. Проверить что у участника нет активного задания +# 3. Выбрать случайную игру из марафона +# 4. Выбрать случайный челлендж этой игры +# 5. Создать assignment со статусом 'active' +# 6. Записать activity (type='spin') +# 7. Вернуть результат + +class SpinResult(BaseModel): + assignment_id: int + game: GameResponse + challenge: ChallengeResponse + can_drop: bool + drop_penalty: int # сколько очков потеряет при дропе +``` + +#### Завершение задания + +```python +# POST /api/v1/assignments/{id}/complete +# multipart/form-data с файлом пруфа + +class CompleteAssignment(BaseModel): + proof_url: str | None = None # если внешняя ссылка + comment: str | None = None + +# + файл proof_file (опционально) + +class CompleteResult(BaseModel): + points_earned: int + streak_bonus: int + total_points: int + new_streak: int +``` + +### Лента активности + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/api/v1/marathons/{id}/feed` | Лента активности | + +```python +# Query params: limit, offset + +class ActivityResponse(BaseModel): + id: int + type: str # spin, complete, drop, join + user: UserPublic + data: dict # зависит от типа + created_at: datetime + +# Примеры data: +# type=spin: {"game": "Hollow Knight", "challenge": "Первые шаги"} +# type=complete: {"challenge": "...", "points": 85, "streak": 3} +# type=drop: {"challenge": "...", "penalty": -25} +``` + +--- + +## Сервисы (бизнес-логика) + +### PointsService + +```python +class PointsService: + # Расчёт очков за выполнение + def calculate_completion_points( + self, + base_points: int, + current_streak: int + ) -> tuple[int, int]: + """ + Returns: (total_points, streak_bonus) + """ + streak_multiplier = { + 0: 0, 1: 0, 2: 0.1, 3: 0.2, 4: 0.3 + }.get(current_streak, 0.4) + + bonus = int(base_points * streak_multiplier) + return base_points + bonus, bonus + + # Расчёт штрафа за дроп + def calculate_drop_penalty(self, consecutive_drops: int) -> int: + """ + drop_count=0: 0 (первый дроп бесплатный) + drop_count=1: -10 + drop_count=2: -25 + drop_count=3+: -50 + """ + penalties = {0: 0, 1: 10, 2: 25} + return penalties.get(consecutive_drops, 50) +``` + +### GPTService + +```python +class GPTService: + async def generate_challenges( + self, + game_title: str, + game_genre: str | None + ) -> list[ChallengeGenerated]: + """ + Генерирует 5-7 челленджей для игры через OpenAI API + """ + prompt = f""" + Для видеоигры "{game_title}" {f'(жанр: {game_genre})' if game_genre else ''} + сгенерируй 6 челленджей для игрового марафона. + + Требования: + - 2 лёгких (15-30 минут игры) + - 2 средних (1-2 часа игры) + - 2 сложных (3+ часов или высокая сложность) + + Для каждого челленджа укажи: + - title: короткое название + - description: что нужно сделать + - type: один из [completion, no_death, speedrun, collection, achievement, challenge_run] + - difficulty: easy/medium/hard + - points: очки (easy: 30-50, medium: 60-100, hard: 120-200) + - estimated_time: примерное время в минутах + - proof_type: screenshot/video/steam (что лучше подойдёт для проверки) + - proof_hint: что должно быть на скриншоте/видео + + Ответ в формате JSON массива. + """ + + response = await openai.chat.completions.create( + model="gpt-4o-mini", # Оптимальный баланс цена/качество + messages=[{"role": "user", "content": prompt}], + response_format={"type": "json_object"}, + temperature=0.7 + ) + + # Парсинг и валидация ответа + data = json.loads(response.choices[0].message.content) + return [ChallengeGenerated(**ch) for ch in data["challenges"]] +``` + +### WheelService + +```python +class WheelService: + async def spin( + self, + db: AsyncSession, + marathon_id: int, + participant_id: int + ) -> Assignment: + # 1. Получить все игры марафона + games = await self.get_marathon_games(db, marathon_id) + + # 2. Выбрать случайную игру + game = random.choice(games) + + # 3. Получить челленджи этой игры + challenges = await self.get_game_challenges(db, game.id) + + # 4. Выбрать случайный челлендж + challenge = random.choice(challenges) + + # 5. Создать задание + assignment = Assignment( + participant_id=participant_id, + challenge_id=challenge.id, + status='active', + started_at=datetime.utcnow() + ) + + db.add(assignment) + await db.commit() + + return assignment +``` + +--- + +## Frontend + +### Зависимости + +```json +{ + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "axios": "^1.6.0", + "zustand": "^4.4.0", + "react-hook-form": "^7.48.0", + "zod": "^3.22.0", + "@hookform/resolvers": "^3.3.0", + "framer-motion": "^10.16.0", + "date-fns": "^2.30.0", + "lucide-react": "^0.294.0", + "clsx": "^2.0.0", + "tailwind-merge": "^2.0.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "@vitejs/plugin-react": "^4.2.0", + "tailwindcss": "^3.3.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0" + } +} +``` + +### Страницы и роуты + +```tsx +// src/App.tsx +const router = createBrowserRouter([ + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'login', element: }, + { path: 'register', element: }, + { path: 'profile', element: }, + { + path: 'marathons', + children: [ + { index: true, element: }, + { path: 'create', element: }, + { path: 'join/:code', element: }, + { path: ':id', element: }, + { path: ':id/lobby', element: }, + { path: ':id/play', element: }, + { path: ':id/leaderboard', element: }, + { path: ':id/history', element: }, + ] + } + ] + } +]); +``` + +### Основные компоненты + +#### SpinWheel + +```tsx +// src/components/wheel/SpinWheel.tsx +interface SpinWheelProps { + items: Array<{ id: number; label: string; color?: string }>; + onSpinEnd: (item: typeof items[0]) => void; + spinning: boolean; +} + +export function SpinWheel({ items, onSpinEnd, spinning }: SpinWheelProps) { + // Анимация через framer-motion + // Колесо крутится, пока spinning=true + // При остановке вызывается onSpinEnd с выбранным элементом +} +``` + +#### AssignmentCard + +```tsx +// src/components/marathon/AssignmentCard.tsx +interface AssignmentCardProps { + assignment: Assignment; + onComplete: () => void; + onDrop: () => void; +} + +export function AssignmentCard({ assignment, onComplete, onDrop }: AssignmentCardProps) { + // Показывает текущее задание + // Игра, челлендж, описание, очки + // Кнопки "Выполнено" и "Дроп" +} +``` + +#### ProofUpload + +```tsx +// src/components/marathon/ProofUpload.tsx +interface ProofUploadProps { + proofType: 'screenshot' | 'video' | 'steam'; + proofHint: string; + onSubmit: (file: File | null, url: string | null, comment: string) => void; +} + +export function ProofUpload({ proofType, proofHint, onSubmit }: ProofUploadProps) { + // Форма загрузки пруфа + // Drag-n-drop для файлов + // Поле для URL + // Комментарий +} +``` + +### Store (Zustand) + +```typescript +// src/store/auth.ts +interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + login: (login: string, password: string) => Promise; + logout: () => void; + register: (data: RegisterData) => Promise; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + user: null, + token: null, + isAuthenticated: false, + + login: async (login, password) => { + const response = await api.auth.login({ login, password }); + set({ + user: response.user, + token: response.access_token, + isAuthenticated: true + }); + }, + + logout: () => { + set({ user: null, token: null, isAuthenticated: false }); + }, + + register: async (data) => { + await api.auth.register(data); + // После регистрации — автоматический вход + } + }), + { name: 'auth-storage' } + ) +); +``` + +```typescript +// src/store/marathon.ts +interface MarathonState { + currentMarathon: Marathon | null; + currentAssignment: Assignment | null; + leaderboard: Participant[]; + + loadMarathon: (id: number) => Promise; + spin: () => Promise; + completeAssignment: (proof: ProofData) => Promise; + dropAssignment: () => Promise; + refreshLeaderboard: () => Promise; +} +``` + +### API клиент + +```typescript +// src/api/client.ts +import axios from 'axios'; +import { useAuthStore } from '../store/auth'; + +const client = axios.create({ + baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1', +}); + +// Интерцептор для добавления токена +client.interceptors.request.use((config) => { + const token = useAuthStore.getState().token; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Интерцептор для обработки 401 +client.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + useAuthStore.getState().logout(); + } + return Promise.reject(error); + } +); + +export default client; +``` + +--- + +## Telegram бот + +### Команды + +| Команда | Описание | +|---------|----------| +| `/start` | Привязка аккаунта | +| `/help` | Список команд | +| `/status` | Текущий статус в активных марафонах | +| `/current` | Текущее задание | +| `/leaderboard` | Таблица лидеров | +| `/marathons` | Список марафонов | + +### Привязка аккаунта + +``` +Пользователь: /start +Бот: Привет! Для привязки аккаунта введи свой логин: + +Пользователь: mylogin +Бот: Отлично! Теперь введи пароль: + +Пользователь: mypassword +Бот: Аккаунт успешно привязан! Теперь ты будешь получать уведомления. + Используй /help для списка команд. +``` + +### Уведомления + +| Событие | Уведомление | +|---------|-------------| +| Марафон начался | "Марафон 'X' начался! Время крутить колесо" | +| Кто-то выполнил | "Vasya выполнил 'No-death run' и получил 120 очков!" | +| Марафон завершён | "Марафон 'X' завершён! Победитель: Vasya (1247 очков)" | +| Напоминание | "У тебя нет активного задания уже 24ч. Не забудь покрутить колесо!" | + +### Структура бота + +```python +# bot/main.py +from aiogram import Bot, Dispatcher +from aiogram.types import Message +from aiogram.filters import Command + +bot = Bot(token=config.TELEGRAM_TOKEN) +dp = Dispatcher() + +@dp.message(Command("start")) +async def cmd_start(message: Message): + await message.answer( + "Привет! Это бот Game Marathon.\n" + "Для привязки аккаунта введи свой логин:" + ) + # Установить состояние ожидания логина + +@dp.message(Command("status")) +async def cmd_status(message: Message): + user = await get_user_by_telegram(message.from_user.id) + if not user: + await message.answer("Аккаунт не привязан. Используй /start") + return + + marathons = await api.get_user_marathons(user.id) + # Форматировать и отправить статус + +@dp.message(Command("leaderboard")) +async def cmd_leaderboard(message: Message): + # Показать инлайн-кнопки для выбора марафона + # Затем показать таблицу лидеров +``` + +--- + +## Docker + +### docker-compose.yml + +```yaml +version: '3.8' + +services: + db: + image: postgres:15-alpine + environment: + POSTGRES_USER: marathon + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: marathon + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U marathon"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: ./backend + environment: + DATABASE_URL: postgresql+asyncpg://marathon:${DB_PASSWORD}@db:5432/marathon + SECRET_KEY: ${SECRET_KEY} + OPENAI_API_KEY: ${OPENAI_API_KEY} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} + volumes: + - ./backend/uploads:/app/uploads + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + + frontend: + build: ./frontend + ports: + - "3000:80" + depends_on: + - backend + + bot: + build: ./bot + environment: + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} + API_URL: http://backend:8000/api/v1 + depends_on: + - backend + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + - ./certbot/conf:/etc/letsencrypt + - ./certbot/www:/var/www/certbot + depends_on: + - frontend + - backend + +volumes: + postgres_data: +``` + +### Backend Dockerfile + +```dockerfile +# backend/Dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# Зависимости +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Код приложения +COPY . . + +# Создать директорию для загрузок +RUN mkdir -p /app/uploads + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +### Frontend Dockerfile + +```dockerfile +# frontend/Dockerfile +FROM node:20-alpine as build + +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx-frontend.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] +``` + +--- + +## Переменные окружения + +```env +# .env.example + +# Database +DB_PASSWORD=your_secure_password + +# Backend +SECRET_KEY=your_jwt_secret_key_here +OPENAI_API_KEY=sk-... + +# Telegram +TELEGRAM_BOT_TOKEN=123456:ABC-DEF... + +# Frontend (для сборки) +VITE_API_URL=https://your-domain.com/api/v1 +``` + +--- + +## Порядок разработки + +### Этап 1: Базовая инфраструктура +1. Настройка проекта (frontend + backend + docker) +2. База данных и модели +3. Авторизация (JWT) +4. Базовые API эндпоинты + +### Этап 2: Ядро функционала +5. CRUD марафонов +6. Добавление игр +7. Интеграция GPT для генерации челленджей +8. Колесо и спин-логика +9. Выполнение/дроп заданий +10. Система очков + +### Этап 3: UI/UX +11. Страницы авторизации +12. Главная и список марафонов +13. Лобби (подготовка) +14. Игровой экран (колесо, задание) +15. Таблица лидеров и лента + +### Этап 4: Telegram и деплой +16. Telegram бот +17. Docker-конфигурация +18. Деплой на VPS + +--- + +## Конфигурация Nginx + +### nginx.conf + +```nginx +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Лимит загрузки файлов 15 МБ + client_max_body_size 15M; + + upstream backend { + server backend:8000; + } + + upstream frontend { + server frontend:80; + } + + server { + listen 80; + server_name localhost; + + # Frontend + location / { + proxy_pass http://frontend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Backend API + location /api { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # Таймаут для загрузки файлов + proxy_read_timeout 300; + proxy_connect_timeout 300; + proxy_send_timeout 300; + } + + # Статические файлы (загруженные пруфы и обложки) + location /uploads { + alias /app/uploads; + expires 30d; + add_header Cache-Control "public, immutable"; + } + } +} +``` + +--- + +## Backend: конфигурация загрузки файлов + +### app/core/config.py + +```python +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + # Database + DATABASE_URL: str + + # Security + SECRET_KEY: str + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 дней + + # OpenAI + OPENAI_API_KEY: str + + # Telegram + TELEGRAM_BOT_TOKEN: str + + # Uploads + UPLOAD_DIR: str = "uploads" + MAX_UPLOAD_SIZE: int = 15 * 1024 * 1024 # 15 МБ + ALLOWED_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp", "mp4", "webm", "mov"} + + class Config: + env_file = ".env" + +settings = Settings() +``` + +### app/api/v1/assignments.py (загрузка пруфов) + +```python +from fastapi import UploadFile, HTTPException +import aiofiles +import uuid +from pathlib import Path + +async def upload_proof( + file: UploadFile, + assignment_id: int, + db: AsyncSession, + current_user: User +) -> str: + # Проверка размера + contents = await file.read() + if len(contents) > settings.MAX_UPLOAD_SIZE: + raise HTTPException(400, f"Файл слишком большой. Максимум {settings.MAX_UPLOAD_SIZE // 1024 // 1024} МБ") + + # Проверка расширения + ext = file.filename.split(".")[-1].lower() + if ext not in settings.ALLOWED_EXTENSIONS: + raise HTTPException(400, f"Недопустимый формат файла. Разрешены: {settings.ALLOWED_EXTENSIONS}") + + # Сохранение файла + filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}" + filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename + filepath.parent.mkdir(parents=True, exist_ok=True) + + async with aiofiles.open(filepath, "wb") as f: + await f.write(contents) + + return str(filepath) +``` + +--- + +## Vite конфигурация + +### frontend/vite.config.ts + +```typescript +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: false, + }, +}) +``` + +### frontend/tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} +``` + +### frontend/tailwind.config.js + +```javascript +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + primary: { + 50: '#f0f9ff', + 100: '#e0f2fe', + 200: '#bae6fd', + 300: '#7dd3fc', + 400: '#38bdf8', + 500: '#0ea5e9', + 600: '#0284c7', + 700: '#0369a1', + 800: '#075985', + 900: '#0c4a6e', + }, + }, + animation: { + 'spin-slow': 'spin 3s linear infinite', + 'wheel-spin': 'wheel-spin 4s cubic-bezier(0.17, 0.67, 0.12, 0.99) forwards', + }, + keyframes: { + 'wheel-spin': { + '0%': { transform: 'rotate(0deg)' }, + '100%': { transform: 'rotate(var(--wheel-rotation))' }, + }, + }, + }, + }, + plugins: [], +} +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..cf91d86 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create upload directories +RUN mkdir -p /app/uploads/avatars /app/uploads/covers /app/uploads/proofs + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..bed1805 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,40 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..38cdabc --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,63 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +from app.core.config import settings +from app.core.database import Base +from app.models import * # noqa: F401, F403 + +config = context.config +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..7011796 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# Game Marathon Backend diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..9c7f58e --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +# API module diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..452db51 --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,50 @@ +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import decode_access_token +from app.models import User + +security = HTTPBearer() + + +async def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> User: + token = credentials.credentials + payload = decode_access_token(token) + + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload", + ) + + result = await db.execute(select(User).where(User.id == int(user_id))) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + ) + + return user + + +# Type aliases for cleaner dependency injection +CurrentUser = Annotated[User, Depends(get_current_user)] +DbSession = Annotated[AsyncSession, Depends(get_db)] diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..93d445f --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed + +router = APIRouter(prefix="/api/v1") + +router.include_router(auth.router) +router.include_router(users.router) +router.include_router(marathons.router) +router.include_router(games.router) +router.include_router(challenges.router) +router.include_router(wheel.router) +router.include_router(feed.router) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..b420286 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,64 @@ +from fastapi import APIRouter, HTTPException, status +from sqlalchemy import select + +from app.api.deps import DbSession, CurrentUser +from app.core.security import verify_password, get_password_hash, create_access_token +from app.models import User +from app.schemas import UserRegister, UserLogin, TokenResponse, UserPublic + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", response_model=TokenResponse) +async def register(data: UserRegister, db: DbSession): + # Check if login already exists + result = await db.execute(select(User).where(User.login == data.login.lower())) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Login already registered", + ) + + # Create user + user = User( + login=data.login.lower(), + password_hash=get_password_hash(data.password), + nickname=data.nickname, + ) + db.add(user) + await db.commit() + await db.refresh(user) + + # Generate token + access_token = create_access_token(subject=user.id) + + return TokenResponse( + access_token=access_token, + user=UserPublic.model_validate(user), + ) + + +@router.post("/login", response_model=TokenResponse) +async def login(data: UserLogin, db: DbSession): + # Find user + result = await db.execute(select(User).where(User.login == data.login.lower())) + user = result.scalar_one_or_none() + + if not user or not verify_password(data.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect login or password", + ) + + # Generate token + access_token = create_access_token(subject=user.id) + + return TokenResponse( + access_token=access_token, + user=UserPublic.model_validate(user), + ) + + +@router.get("/me", response_model=UserPublic) +async def get_me(current_user: CurrentUser): + return UserPublic.model_validate(current_user) diff --git a/backend/app/api/v1/challenges.py b/backend/app/api/v1/challenges.py new file mode 100644 index 0000000..fe64bdd --- /dev/null +++ b/backend/app/api/v1/challenges.py @@ -0,0 +1,268 @@ +from fastapi import APIRouter, HTTPException +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from app.api.deps import DbSession, CurrentUser +from app.models import Marathon, MarathonStatus, Game, Challenge, Participant +from app.schemas import ( + ChallengeCreate, + ChallengeUpdate, + ChallengeResponse, + MessageResponse, + GameShort, +) +from app.services.gpt import GPTService + +router = APIRouter(tags=["challenges"]) + +gpt_service = GPTService() + + +async def get_challenge_or_404(db, challenge_id: int) -> Challenge: + result = await db.execute( + select(Challenge) + .options(selectinload(Challenge.game)) + .where(Challenge.id == challenge_id) + ) + challenge = result.scalar_one_or_none() + if not challenge: + raise HTTPException(status_code=404, detail="Challenge not found") + return challenge + + +async def check_participant(db, user_id: int, marathon_id: int) -> Participant: + result = await db.execute( + select(Participant).where( + Participant.user_id == user_id, + Participant.marathon_id == marathon_id, + ) + ) + participant = result.scalar_one_or_none() + if not participant: + raise HTTPException(status_code=403, detail="You are not a participant of this marathon") + return participant + + +@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse]) +async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession): + # Get game and check access + result = await db.execute( + select(Game).where(Game.id == game_id) + ) + game = result.scalar_one_or_none() + if not game: + raise HTTPException(status_code=404, detail="Game not found") + + await check_participant(db, current_user.id, game.marathon_id) + + result = await db.execute( + select(Challenge) + .where(Challenge.game_id == game_id) + .order_by(Challenge.difficulty, Challenge.created_at) + ) + challenges = result.scalars().all() + + return [ + ChallengeResponse( + id=c.id, + title=c.title, + description=c.description, + type=c.type, + difficulty=c.difficulty, + points=c.points, + estimated_time=c.estimated_time, + proof_type=c.proof_type, + proof_hint=c.proof_hint, + game=GameShort(id=game.id, title=game.title, cover_url=None), + is_generated=c.is_generated, + created_at=c.created_at, + ) + for c in challenges + ] + + +@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse) +async def create_challenge( + game_id: int, + data: ChallengeCreate, + current_user: CurrentUser, + db: DbSession, +): + # Get game and check access + result = await db.execute( + select(Game).where(Game.id == game_id) + ) + game = result.scalar_one_or_none() + if not game: + raise HTTPException(status_code=404, detail="Game not found") + + # Check marathon is in preparing state + result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id)) + marathon = result.scalar_one() + if marathon.status != MarathonStatus.PREPARING.value: + raise HTTPException(status_code=400, detail="Cannot add challenges to active or finished marathon") + + await check_participant(db, current_user.id, game.marathon_id) + + challenge = Challenge( + game_id=game_id, + title=data.title, + description=data.description, + type=data.type.value, + difficulty=data.difficulty.value, + points=data.points, + estimated_time=data.estimated_time, + proof_type=data.proof_type.value, + proof_hint=data.proof_hint, + is_generated=False, + ) + db.add(challenge) + await db.commit() + await db.refresh(challenge) + + return ChallengeResponse( + id=challenge.id, + title=challenge.title, + description=challenge.description, + type=challenge.type, + difficulty=challenge.difficulty, + points=challenge.points, + estimated_time=challenge.estimated_time, + proof_type=challenge.proof_type, + proof_hint=challenge.proof_hint, + game=GameShort(id=game.id, title=game.title, cover_url=None), + is_generated=challenge.is_generated, + created_at=challenge.created_at, + ) + + +@router.post("/marathons/{marathon_id}/generate-challenges", response_model=MessageResponse) +async def generate_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession): + """Generate challenges for all games in marathon using GPT""" + # Check marathon + result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) + marathon = result.scalar_one_or_none() + if not marathon: + raise HTTPException(status_code=404, detail="Marathon not found") + + if marathon.status != MarathonStatus.PREPARING.value: + raise HTTPException(status_code=400, detail="Cannot generate challenges for active or finished marathon") + + await check_participant(db, current_user.id, marathon_id) + + # Get all games + result = await db.execute( + select(Game).where(Game.marathon_id == marathon_id) + ) + games = result.scalars().all() + + if not games: + raise HTTPException(status_code=400, detail="No games in marathon") + + generated_count = 0 + for game in games: + # Check if game already has challenges + existing = await db.scalar( + select(Challenge.id).where(Challenge.game_id == game.id).limit(1) + ) + if existing: + continue # Skip if already has challenges + + try: + challenges_data = await gpt_service.generate_challenges(game.title, game.genre) + + for ch_data in challenges_data: + challenge = Challenge( + game_id=game.id, + title=ch_data.title, + description=ch_data.description, + type=ch_data.type, + difficulty=ch_data.difficulty, + points=ch_data.points, + estimated_time=ch_data.estimated_time, + proof_type=ch_data.proof_type, + proof_hint=ch_data.proof_hint, + is_generated=True, + ) + db.add(challenge) + generated_count += 1 + + except Exception as e: + # Log error but continue with other games + print(f"Error generating challenges for {game.title}: {e}") + + await db.commit() + + return MessageResponse(message=f"Generated {generated_count} challenges") + + +@router.patch("/challenges/{challenge_id}", response_model=ChallengeResponse) +async def update_challenge( + challenge_id: int, + data: ChallengeUpdate, + current_user: CurrentUser, + db: DbSession, +): + challenge = await get_challenge_or_404(db, challenge_id) + + # Check marathon is in preparing state + result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id)) + marathon = result.scalar_one() + if marathon.status != MarathonStatus.PREPARING.value: + raise HTTPException(status_code=400, detail="Cannot update challenges in active or finished marathon") + + await check_participant(db, current_user.id, challenge.game.marathon_id) + + if data.title is not None: + challenge.title = data.title + if data.description is not None: + challenge.description = data.description + if data.type is not None: + challenge.type = data.type.value + if data.difficulty is not None: + challenge.difficulty = data.difficulty.value + if data.points is not None: + challenge.points = data.points + if data.estimated_time is not None: + challenge.estimated_time = data.estimated_time + if data.proof_type is not None: + challenge.proof_type = data.proof_type.value + if data.proof_hint is not None: + challenge.proof_hint = data.proof_hint + + await db.commit() + await db.refresh(challenge) + + game = challenge.game + return ChallengeResponse( + id=challenge.id, + title=challenge.title, + description=challenge.description, + type=challenge.type, + difficulty=challenge.difficulty, + points=challenge.points, + estimated_time=challenge.estimated_time, + proof_type=challenge.proof_type, + proof_hint=challenge.proof_hint, + game=GameShort(id=game.id, title=game.title, cover_url=None), + is_generated=challenge.is_generated, + created_at=challenge.created_at, + ) + + +@router.delete("/challenges/{challenge_id}", response_model=MessageResponse) +async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession): + challenge = await get_challenge_or_404(db, challenge_id) + + # Check marathon is in preparing state + result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id)) + marathon = result.scalar_one() + if marathon.status != MarathonStatus.PREPARING.value: + raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon") + + await check_participant(db, current_user.id, challenge.game.marathon_id) + + await db.delete(challenge) + await db.commit() + + return MessageResponse(message="Challenge deleted") diff --git a/backend/app/api/v1/feed.py b/backend/app/api/v1/feed.py new file mode 100644 index 0000000..790990f --- /dev/null +++ b/backend/app/api/v1/feed.py @@ -0,0 +1,62 @@ +from fastapi import APIRouter +from sqlalchemy import select, func +from sqlalchemy.orm import selectinload + +from app.api.deps import DbSession, CurrentUser +from app.models import Activity, Participant +from app.schemas import FeedResponse, ActivityResponse, UserPublic + +router = APIRouter(tags=["feed"]) + + +@router.get("/marathons/{marathon_id}/feed", response_model=FeedResponse) +async def get_feed( + marathon_id: int, + current_user: CurrentUser, + db: DbSession, + limit: int = 20, + offset: int = 0, +): + """Get activity feed for marathon""" + # Check user is participant + result = await db.execute( + select(Participant).where( + Participant.user_id == current_user.id, + Participant.marathon_id == marathon_id, + ) + ) + if not result.scalar_one_or_none(): + return FeedResponse(items=[], total=0, has_more=False) + + # Get total count + total = await db.scalar( + select(func.count()).select_from(Activity).where(Activity.marathon_id == marathon_id) + ) + + # Get activities + result = await db.execute( + select(Activity) + .options(selectinload(Activity.user)) + .where(Activity.marathon_id == marathon_id) + .order_by(Activity.created_at.desc()) + .limit(limit) + .offset(offset) + ) + activities = result.scalars().all() + + items = [ + ActivityResponse( + id=a.id, + type=a.type, + user=UserPublic.model_validate(a.user), + data=a.data, + created_at=a.created_at, + ) + for a in activities + ] + + return FeedResponse( + items=items, + total=total, + has_more=(offset + limit) < total, + ) diff --git a/backend/app/api/v1/games.py b/backend/app/api/v1/games.py new file mode 100644 index 0000000..6bfb69b --- /dev/null +++ b/backend/app/api/v1/games.py @@ -0,0 +1,222 @@ +from fastapi import APIRouter, HTTPException, status, UploadFile, File +from sqlalchemy import select, func +from sqlalchemy.orm import selectinload +import uuid +from pathlib import Path + +from app.api.deps import DbSession, CurrentUser +from app.core.config import settings +from app.models import Marathon, MarathonStatus, Game, Challenge, Participant +from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic + +router = APIRouter(tags=["games"]) + + +async def get_game_or_404(db, game_id: int) -> Game: + result = await db.execute( + select(Game) + .options(selectinload(Game.added_by_user)) + .where(Game.id == game_id) + ) + game = result.scalar_one_or_none() + if not game: + raise HTTPException(status_code=404, detail="Game not found") + return game + + +async def check_participant(db, user_id: int, marathon_id: int) -> Participant: + result = await db.execute( + select(Participant).where( + Participant.user_id == user_id, + Participant.marathon_id == marathon_id, + ) + ) + participant = result.scalar_one_or_none() + if not participant: + raise HTTPException(status_code=403, detail="You are not a participant of this marathon") + return participant + + +@router.get("/marathons/{marathon_id}/games", response_model=list[GameResponse]) +async def list_games(marathon_id: int, current_user: CurrentUser, db: DbSession): + await check_participant(db, current_user.id, marathon_id) + + result = await db.execute( + select(Game, func.count(Challenge.id).label("challenges_count")) + .outerjoin(Challenge) + .options(selectinload(Game.added_by_user)) + .where(Game.marathon_id == marathon_id) + .group_by(Game.id) + .order_by(Game.created_at.desc()) + ) + + games = [] + for row in result.all(): + game = row[0] + games.append(GameResponse( + id=game.id, + title=game.title, + cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, + download_url=game.download_url, + genre=game.genre, + added_by=UserPublic.model_validate(game.added_by_user) if game.added_by_user else None, + challenges_count=row[1], + created_at=game.created_at, + )) + + return games + + +@router.post("/marathons/{marathon_id}/games", response_model=GameResponse) +async def add_game( + marathon_id: int, + data: GameCreate, + current_user: CurrentUser, + db: DbSession, +): + # Check marathon exists and is preparing + result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) + marathon = result.scalar_one_or_none() + if not marathon: + raise HTTPException(status_code=404, detail="Marathon not found") + + if marathon.status != MarathonStatus.PREPARING.value: + raise HTTPException(status_code=400, detail="Cannot add games to active or finished marathon") + + await check_participant(db, current_user.id, marathon_id) + + game = Game( + marathon_id=marathon_id, + title=data.title, + download_url=data.download_url, + genre=data.genre, + added_by_id=current_user.id, + ) + db.add(game) + await db.commit() + await db.refresh(game) + + return GameResponse( + id=game.id, + title=game.title, + cover_url=None, + download_url=game.download_url, + genre=game.genre, + added_by=UserPublic.model_validate(current_user), + challenges_count=0, + created_at=game.created_at, + ) + + +@router.get("/games/{game_id}", response_model=GameResponse) +async def get_game(game_id: int, current_user: CurrentUser, db: DbSession): + game = await get_game_or_404(db, game_id) + await check_participant(db, current_user.id, game.marathon_id) + + challenges_count = await db.scalar( + select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id) + ) + + return GameResponse( + id=game.id, + title=game.title, + cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, + download_url=game.download_url, + genre=game.genre, + added_by=UserPublic.model_validate(game.added_by_user) if game.added_by_user else None, + challenges_count=challenges_count, + created_at=game.created_at, + ) + + +@router.patch("/games/{game_id}", response_model=GameResponse) +async def update_game( + game_id: int, + data: GameUpdate, + current_user: CurrentUser, + db: DbSession, +): + game = await get_game_or_404(db, game_id) + + # Check if marathon is in preparing state + result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id)) + marathon = result.scalar_one() + if marathon.status != MarathonStatus.PREPARING.value: + raise HTTPException(status_code=400, detail="Cannot update games in active or finished marathon") + + # Only the one who added or organizer can update + if game.added_by_id != current_user.id and marathon.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Only the one who added the game or organizer can update it") + + if data.title is not None: + game.title = data.title + if data.download_url is not None: + game.download_url = data.download_url + if data.genre is not None: + game.genre = data.genre + + await db.commit() + + return await get_game(game_id, current_user, db) + + +@router.delete("/games/{game_id}", response_model=MessageResponse) +async def delete_game(game_id: int, current_user: CurrentUser, db: DbSession): + game = await get_game_or_404(db, game_id) + + # Check if marathon is in preparing state + result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id)) + marathon = result.scalar_one() + if marathon.status != MarathonStatus.PREPARING.value: + raise HTTPException(status_code=400, detail="Cannot delete games from active or finished marathon") + + # Only the one who added or organizer can delete + if game.added_by_id != current_user.id and marathon.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Only the one who added the game or organizer can delete it") + + await db.delete(game) + await db.commit() + + return MessageResponse(message="Game deleted") + + +@router.post("/games/{game_id}/cover", response_model=GameResponse) +async def upload_cover( + game_id: int, + current_user: CurrentUser, + db: DbSession, + file: UploadFile = File(...), +): + game = await get_game_or_404(db, game_id) + await check_participant(db, current_user.id, game.marathon_id) + + # Validate file + if not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="File must be an image") + + contents = await file.read() + if len(contents) > settings.MAX_UPLOAD_SIZE: + raise HTTPException( + status_code=400, + detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", + ) + + ext = file.filename.split(".")[-1].lower() if file.filename else "jpg" + if ext not in settings.ALLOWED_IMAGE_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}", + ) + + # Save file + filename = f"{game_id}_{uuid.uuid4().hex}.{ext}" + filepath = Path(settings.UPLOAD_DIR) / "covers" / filename + filepath.parent.mkdir(parents=True, exist_ok=True) + + with open(filepath, "wb") as f: + f.write(contents) + + game.cover_path = str(filepath) + await db.commit() + + return await get_game(game_id, current_user, db) diff --git a/backend/app/api/v1/marathons.py b/backend/app/api/v1/marathons.py new file mode 100644 index 0000000..b5a7472 --- /dev/null +++ b/backend/app/api/v1/marathons.py @@ -0,0 +1,358 @@ +from datetime import timedelta +import secrets +from fastapi import APIRouter, HTTPException, status +from sqlalchemy import select, func +from sqlalchemy.orm import selectinload + +from app.api.deps import DbSession, CurrentUser +from app.models import Marathon, Participant, MarathonStatus, Game, Assignment, AssignmentStatus, Activity, ActivityType +from app.schemas import ( + MarathonCreate, + MarathonUpdate, + MarathonResponse, + MarathonListItem, + JoinMarathon, + ParticipantInfo, + ParticipantWithUser, + LeaderboardEntry, + MessageResponse, + UserPublic, +) + +router = APIRouter(prefix="/marathons", tags=["marathons"]) + + +def generate_invite_code() -> str: + return secrets.token_urlsafe(8) + + +async def get_marathon_or_404(db, marathon_id: int) -> Marathon: + result = await db.execute( + select(Marathon) + .options(selectinload(Marathon.organizer)) + .where(Marathon.id == marathon_id) + ) + marathon = result.scalar_one_or_none() + if not marathon: + raise HTTPException(status_code=404, detail="Marathon not found") + return marathon + + +async def get_participation(db, user_id: int, marathon_id: int) -> Participant | None: + result = await db.execute( + select(Participant).where( + Participant.user_id == user_id, + Participant.marathon_id == marathon_id, + ) + ) + return result.scalar_one_or_none() + + +@router.get("", response_model=list[MarathonListItem]) +async def list_marathons(current_user: CurrentUser, db: DbSession): + """Get all marathons where user is participant or organizer""" + result = await db.execute( + select(Marathon, func.count(Participant.id).label("participants_count")) + .outerjoin(Participant) + .where( + (Marathon.organizer_id == current_user.id) | + (Participant.user_id == current_user.id) + ) + .group_by(Marathon.id) + .order_by(Marathon.created_at.desc()) + ) + + marathons = [] + for row in result.all(): + marathon = row[0] + marathons.append(MarathonListItem( + id=marathon.id, + title=marathon.title, + status=marathon.status, + participants_count=row[1], + start_date=marathon.start_date, + end_date=marathon.end_date, + )) + + return marathons + + +@router.post("", response_model=MarathonResponse) +async def create_marathon( + data: MarathonCreate, + current_user: CurrentUser, + db: DbSession, +): + # Strip timezone info for naive datetime columns + start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date + end_date = start_date + timedelta(days=data.duration_days) + + marathon = Marathon( + title=data.title, + description=data.description, + organizer_id=current_user.id, + invite_code=generate_invite_code(), + start_date=start_date, + end_date=end_date, + ) + db.add(marathon) + await db.flush() + + # Auto-add organizer as participant + participant = Participant( + user_id=current_user.id, + marathon_id=marathon.id, + ) + db.add(participant) + + await db.commit() + await db.refresh(marathon) + + return MarathonResponse( + id=marathon.id, + title=marathon.title, + description=marathon.description, + organizer=UserPublic.model_validate(current_user), + status=marathon.status, + invite_code=marathon.invite_code, + start_date=marathon.start_date, + end_date=marathon.end_date, + participants_count=1, + games_count=0, + created_at=marathon.created_at, + my_participation=ParticipantInfo.model_validate(participant), + ) + + +@router.get("/{marathon_id}", response_model=MarathonResponse) +async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): + marathon = await get_marathon_or_404(db, marathon_id) + + # Count participants and games + participants_count = await db.scalar( + select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id) + ) + games_count = await db.scalar( + select(func.count()).select_from(Game).where(Game.marathon_id == marathon_id) + ) + + # Get user's participation + participation = await get_participation(db, current_user.id, marathon_id) + + return MarathonResponse( + id=marathon.id, + title=marathon.title, + description=marathon.description, + organizer=UserPublic.model_validate(marathon.organizer), + status=marathon.status, + invite_code=marathon.invite_code, + start_date=marathon.start_date, + end_date=marathon.end_date, + participants_count=participants_count, + games_count=games_count, + created_at=marathon.created_at, + my_participation=ParticipantInfo.model_validate(participation) if participation else None, + ) + + +@router.patch("/{marathon_id}", response_model=MarathonResponse) +async def update_marathon( + marathon_id: int, + data: MarathonUpdate, + current_user: CurrentUser, + db: DbSession, +): + marathon = await get_marathon_or_404(db, marathon_id) + + if marathon.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Only organizer can update marathon") + + if marathon.status != MarathonStatus.PREPARING.value: + raise HTTPException(status_code=400, detail="Cannot update active or finished marathon") + + if data.title is not None: + marathon.title = data.title + if data.description is not None: + marathon.description = data.description + if data.start_date is not None: + # Strip timezone info for naive datetime columns + marathon.start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date + + await db.commit() + + return await get_marathon(marathon_id, current_user, db) + + +@router.delete("/{marathon_id}", response_model=MessageResponse) +async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): + marathon = await get_marathon_or_404(db, marathon_id) + + if marathon.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Only organizer can delete marathon") + + await db.delete(marathon) + await db.commit() + + return MessageResponse(message="Marathon deleted") + + +@router.post("/{marathon_id}/start", response_model=MarathonResponse) +async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): + marathon = await get_marathon_or_404(db, marathon_id) + + if marathon.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Only organizer can start marathon") + + if marathon.status != MarathonStatus.PREPARING.value: + raise HTTPException(status_code=400, detail="Marathon is not in preparing state") + + # Check if there are games with challenges + games_count = await db.scalar( + select(func.count()).select_from(Game).where(Game.marathon_id == marathon_id) + ) + if games_count == 0: + raise HTTPException(status_code=400, detail="Add at least one game before starting") + + marathon.status = MarathonStatus.ACTIVE.value + + # Log activity + activity = Activity( + marathon_id=marathon_id, + user_id=current_user.id, + type=ActivityType.START_MARATHON.value, + data={"title": marathon.title}, + ) + db.add(activity) + + await db.commit() + + return await get_marathon(marathon_id, current_user, db) + + +@router.post("/{marathon_id}/finish", response_model=MarathonResponse) +async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): + marathon = await get_marathon_or_404(db, marathon_id) + + if marathon.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Only organizer can finish marathon") + + if marathon.status != MarathonStatus.ACTIVE.value: + raise HTTPException(status_code=400, detail="Marathon is not active") + + marathon.status = MarathonStatus.FINISHED.value + + # Log activity + activity = Activity( + marathon_id=marathon_id, + user_id=current_user.id, + type=ActivityType.FINISH_MARATHON.value, + data={"title": marathon.title}, + ) + db.add(activity) + + await db.commit() + + return await get_marathon(marathon_id, current_user, db) + + +@router.post("/join", response_model=MarathonResponse) +async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession): + result = await db.execute( + select(Marathon).where(Marathon.invite_code == data.invite_code) + ) + marathon = result.scalar_one_or_none() + + if not marathon: + raise HTTPException(status_code=404, detail="Invalid invite code") + + if marathon.status == MarathonStatus.FINISHED.value: + raise HTTPException(status_code=400, detail="Marathon has already finished") + + # Check if already participant + existing = await get_participation(db, current_user.id, marathon.id) + if existing: + raise HTTPException(status_code=400, detail="Already joined this marathon") + + participant = Participant( + user_id=current_user.id, + marathon_id=marathon.id, + ) + db.add(participant) + + # Log activity + activity = Activity( + marathon_id=marathon.id, + user_id=current_user.id, + type=ActivityType.JOIN.value, + data={"nickname": current_user.nickname}, + ) + db.add(activity) + + await db.commit() + + return await get_marathon(marathon.id, current_user, db) + + +@router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser]) +async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession): + await get_marathon_or_404(db, marathon_id) + + result = await db.execute( + select(Participant) + .options(selectinload(Participant.user)) + .where(Participant.marathon_id == marathon_id) + .order_by(Participant.joined_at) + ) + participants = result.scalars().all() + + return [ + ParticipantWithUser( + id=p.id, + total_points=p.total_points, + current_streak=p.current_streak, + drop_count=p.drop_count, + joined_at=p.joined_at, + user=UserPublic.model_validate(p.user), + ) + for p in participants + ] + + +@router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry]) +async def get_leaderboard(marathon_id: int, db: DbSession): + await get_marathon_or_404(db, marathon_id) + + result = await db.execute( + select(Participant) + .options(selectinload(Participant.user)) + .where(Participant.marathon_id == marathon_id) + .order_by(Participant.total_points.desc()) + ) + participants = result.scalars().all() + + leaderboard = [] + for rank, p in enumerate(participants, 1): + # Count completed and dropped assignments + completed = await db.scalar( + select(func.count()).select_from(Assignment).where( + Assignment.participant_id == p.id, + Assignment.status == AssignmentStatus.COMPLETED.value, + ) + ) + dropped = await db.scalar( + select(func.count()).select_from(Assignment).where( + Assignment.participant_id == p.id, + Assignment.status == AssignmentStatus.DROPPED.value, + ) + ) + + leaderboard.append(LeaderboardEntry( + rank=rank, + user=UserPublic.model_validate(p.user), + total_points=p.total_points, + current_streak=p.current_streak, + completed_count=completed, + dropped_count=dropped, + )) + + return leaderboard diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..4785443 --- /dev/null +++ b/backend/app/api/v1/users.py @@ -0,0 +1,104 @@ +from fastapi import APIRouter, HTTPException, status, UploadFile, File +from sqlalchemy import select +import uuid +from pathlib import Path + +from app.api.deps import DbSession, CurrentUser +from app.core.config import settings +from app.models import User +from app.schemas import UserPublic, UserUpdate, TelegramLink, MessageResponse + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/{user_id}", response_model=UserPublic) +async def get_user(user_id: int, db: DbSession): + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + return UserPublic.model_validate(user) + + +@router.patch("/me", response_model=UserPublic) +async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession): + if data.nickname is not None: + current_user.nickname = data.nickname + + await db.commit() + await db.refresh(current_user) + + return UserPublic.model_validate(current_user) + + +@router.post("/me/avatar", response_model=UserPublic) +async def upload_avatar( + current_user: CurrentUser, + db: DbSession, + file: UploadFile = File(...), +): + # Validate file + if not file.content_type.startswith("image/"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File must be an image", + ) + + contents = await file.read() + if len(contents) > settings.MAX_UPLOAD_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", + ) + + # Get file extension + ext = file.filename.split(".")[-1].lower() if file.filename else "jpg" + if ext not in settings.ALLOWED_IMAGE_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}", + ) + + # Save file + filename = f"{current_user.id}_{uuid.uuid4().hex}.{ext}" + filepath = Path(settings.UPLOAD_DIR) / "avatars" / filename + filepath.parent.mkdir(parents=True, exist_ok=True) + + with open(filepath, "wb") as f: + f.write(contents) + + # Update user + current_user.avatar_path = str(filepath) + await db.commit() + await db.refresh(current_user) + + return UserPublic.model_validate(current_user) + + +@router.post("/me/telegram", response_model=MessageResponse) +async def link_telegram( + data: TelegramLink, + current_user: CurrentUser, + db: DbSession, +): + # Check if telegram_id already linked to another user + result = await db.execute( + select(User).where(User.telegram_id == data.telegram_id, User.id != current_user.id) + ) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This Telegram account is already linked to another user", + ) + + current_user.telegram_id = data.telegram_id + current_user.telegram_username = data.telegram_username + + await db.commit() + + return MessageResponse(message="Telegram account linked successfully") diff --git a/backend/app/api/v1/wheel.py b/backend/app/api/v1/wheel.py new file mode 100644 index 0000000..f1bcd14 --- /dev/null +++ b/backend/app/api/v1/wheel.py @@ -0,0 +1,404 @@ +import random +from datetime import datetime +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from sqlalchemy import select, func +from sqlalchemy.orm import selectinload +import uuid +from pathlib import Path + +from app.api.deps import DbSession, CurrentUser +from app.core.config import settings +from app.models import ( + Marathon, MarathonStatus, Game, Challenge, Participant, + Assignment, AssignmentStatus, Activity, ActivityType +) +from app.schemas import ( + SpinResult, AssignmentResponse, CompleteResult, DropResult, + GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse +) +from app.services.points import PointsService + +router = APIRouter(tags=["wheel"]) + +points_service = PointsService() + + +async def get_participant_or_403(db, user_id: int, marathon_id: int) -> Participant: + result = await db.execute( + select(Participant).where( + Participant.user_id == user_id, + Participant.marathon_id == marathon_id, + ) + ) + participant = result.scalar_one_or_none() + if not participant: + raise HTTPException(status_code=403, detail="You are not a participant of this marathon") + return participant + + +async def get_active_assignment(db, participant_id: int) -> Assignment | None: + result = await db.execute( + select(Assignment) + .options( + selectinload(Assignment.challenge).selectinload(Challenge.game) + ) + .where( + Assignment.participant_id == participant_id, + Assignment.status == AssignmentStatus.ACTIVE.value, + ) + ) + return result.scalar_one_or_none() + + +@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult) +async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession): + """Spin the wheel to get a random game and challenge""" + # Check marathon is active + result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) + marathon = result.scalar_one_or_none() + if not marathon: + raise HTTPException(status_code=404, detail="Marathon not found") + + if marathon.status != MarathonStatus.ACTIVE.value: + raise HTTPException(status_code=400, detail="Marathon is not active") + + participant = await get_participant_or_403(db, current_user.id, marathon_id) + + # Check no active assignment + active = await get_active_assignment(db, participant.id) + if active: + raise HTTPException(status_code=400, detail="You already have an active assignment") + + # Get all games with challenges + result = await db.execute( + select(Game) + .options(selectinload(Game.challenges)) + .where(Game.marathon_id == marathon_id) + ) + games = [g for g in result.scalars().all() if g.challenges] + + if not games: + raise HTTPException(status_code=400, detail="No games with challenges available") + + # Random selection + game = random.choice(games) + challenge = random.choice(game.challenges) + + # Create assignment + assignment = Assignment( + participant_id=participant.id, + challenge_id=challenge.id, + status=AssignmentStatus.ACTIVE.value, + ) + db.add(assignment) + + # Log activity + activity = Activity( + marathon_id=marathon_id, + user_id=current_user.id, + type=ActivityType.SPIN.value, + data={ + "game": game.title, + "challenge": challenge.title, + }, + ) + db.add(activity) + + await db.commit() + await db.refresh(assignment) + + # Calculate drop penalty + drop_penalty = points_service.calculate_drop_penalty(participant.drop_count) + + return SpinResult( + assignment_id=assignment.id, + game=GameResponse( + id=game.id, + title=game.title, + cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, + download_url=game.download_url, + genre=game.genre, + added_by=None, + challenges_count=len(game.challenges), + created_at=game.created_at, + ), + challenge=ChallengeResponse( + id=challenge.id, + title=challenge.title, + description=challenge.description, + type=challenge.type, + difficulty=challenge.difficulty, + points=challenge.points, + estimated_time=challenge.estimated_time, + proof_type=challenge.proof_type, + proof_hint=challenge.proof_hint, + game=GameShort(id=game.id, title=game.title, cover_url=None), + is_generated=challenge.is_generated, + created_at=challenge.created_at, + ), + can_drop=True, + drop_penalty=drop_penalty, + ) + + +@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None) +async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession): + """Get current active assignment""" + participant = await get_participant_or_403(db, current_user.id, marathon_id) + assignment = await get_active_assignment(db, participant.id) + + if not assignment: + return None + + challenge = assignment.challenge + game = challenge.game + + return AssignmentResponse( + id=assignment.id, + challenge=ChallengeResponse( + id=challenge.id, + title=challenge.title, + description=challenge.description, + type=challenge.type, + difficulty=challenge.difficulty, + points=challenge.points, + estimated_time=challenge.estimated_time, + proof_type=challenge.proof_type, + proof_hint=challenge.proof_hint, + game=GameShort(id=game.id, title=game.title, cover_url=None), + is_generated=challenge.is_generated, + created_at=challenge.created_at, + ), + status=assignment.status, + proof_url=f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" if assignment.proof_path else assignment.proof_url, + proof_comment=assignment.proof_comment, + points_earned=assignment.points_earned, + streak_at_completion=assignment.streak_at_completion, + started_at=assignment.started_at, + completed_at=assignment.completed_at, + ) + + +@router.post("/assignments/{assignment_id}/complete", response_model=CompleteResult) +async def complete_assignment( + assignment_id: int, + current_user: CurrentUser, + db: DbSession, + proof_url: str | None = Form(None), + comment: str | None = Form(None), + proof_file: UploadFile | None = File(None), +): + """Complete an assignment with proof""" + # Get assignment + result = await db.execute( + select(Assignment) + .options( + selectinload(Assignment.participant), + selectinload(Assignment.challenge), + ) + .where(Assignment.id == assignment_id) + ) + assignment = result.scalar_one_or_none() + + if not assignment: + raise HTTPException(status_code=404, detail="Assignment not found") + + if assignment.participant.user_id != current_user.id: + raise HTTPException(status_code=403, detail="This is not your assignment") + + if assignment.status != AssignmentStatus.ACTIVE.value: + raise HTTPException(status_code=400, detail="Assignment is not active") + + # Need either file or URL + if not proof_file and not proof_url: + raise HTTPException(status_code=400, detail="Proof is required (file or URL)") + + # Handle file upload + if proof_file: + contents = await proof_file.read() + if len(contents) > settings.MAX_UPLOAD_SIZE: + raise HTTPException( + status_code=400, + detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", + ) + + ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg" + if ext not in settings.ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}", + ) + + filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}" + filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename + filepath.parent.mkdir(parents=True, exist_ok=True) + + with open(filepath, "wb") as f: + f.write(contents) + + assignment.proof_path = str(filepath) + else: + assignment.proof_url = proof_url + + assignment.proof_comment = comment + + # Calculate points + participant = assignment.participant + challenge = assignment.challenge + + total_points, streak_bonus = points_service.calculate_completion_points( + challenge.points, participant.current_streak + ) + + # Update assignment + assignment.status = AssignmentStatus.COMPLETED.value + assignment.points_earned = total_points + assignment.streak_at_completion = participant.current_streak + 1 + assignment.completed_at = datetime.utcnow() + + # Update participant + participant.total_points += total_points + participant.current_streak += 1 + participant.drop_count = 0 # Reset drop counter on success + + # Get marathon_id for activity + result = await db.execute( + select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id) + ) + full_challenge = result.scalar_one() + + # Log activity + activity = Activity( + marathon_id=full_challenge.game.marathon_id, + user_id=current_user.id, + type=ActivityType.COMPLETE.value, + data={ + "challenge": challenge.title, + "points": total_points, + "streak": participant.current_streak, + }, + ) + db.add(activity) + + await db.commit() + + return CompleteResult( + points_earned=total_points, + streak_bonus=streak_bonus, + total_points=participant.total_points, + new_streak=participant.current_streak, + ) + + +@router.post("/assignments/{assignment_id}/drop", response_model=DropResult) +async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession): + """Drop current assignment""" + # Get assignment + result = await db.execute( + select(Assignment) + .options( + selectinload(Assignment.participant), + selectinload(Assignment.challenge).selectinload(Challenge.game), + ) + .where(Assignment.id == assignment_id) + ) + assignment = result.scalar_one_or_none() + + if not assignment: + raise HTTPException(status_code=404, detail="Assignment not found") + + if assignment.participant.user_id != current_user.id: + raise HTTPException(status_code=403, detail="This is not your assignment") + + if assignment.status != AssignmentStatus.ACTIVE.value: + raise HTTPException(status_code=400, detail="Assignment is not active") + + participant = assignment.participant + + # Calculate penalty + penalty = points_service.calculate_drop_penalty(participant.drop_count) + + # Update assignment + assignment.status = AssignmentStatus.DROPPED.value + assignment.completed_at = datetime.utcnow() + + # Update participant + participant.total_points = max(0, participant.total_points - penalty) + participant.current_streak = 0 + participant.drop_count += 1 + + # Log activity + activity = Activity( + marathon_id=assignment.challenge.game.marathon_id, + user_id=current_user.id, + type=ActivityType.DROP.value, + data={ + "challenge": assignment.challenge.title, + "penalty": penalty, + }, + ) + db.add(activity) + + await db.commit() + + return DropResult( + penalty=penalty, + total_points=participant.total_points, + new_drop_count=participant.drop_count, + ) + + +@router.get("/marathons/{marathon_id}/my-history", response_model=list[AssignmentResponse]) +async def get_my_history( + marathon_id: int, + current_user: CurrentUser, + db: DbSession, + limit: int = 20, + offset: int = 0, +): + """Get history of user's assignments in marathon""" + participant = await get_participant_or_403(db, current_user.id, marathon_id) + + result = await db.execute( + select(Assignment) + .options( + selectinload(Assignment.challenge).selectinload(Challenge.game) + ) + .where(Assignment.participant_id == participant.id) + .order_by(Assignment.started_at.desc()) + .limit(limit) + .offset(offset) + ) + assignments = result.scalars().all() + + return [ + AssignmentResponse( + id=a.id, + challenge=ChallengeResponse( + id=a.challenge.id, + title=a.challenge.title, + description=a.challenge.description, + type=a.challenge.type, + difficulty=a.challenge.difficulty, + points=a.challenge.points, + estimated_time=a.challenge.estimated_time, + proof_type=a.challenge.proof_type, + proof_hint=a.challenge.proof_hint, + game=GameShort( + id=a.challenge.game.id, + title=a.challenge.game.title, + cover_url=None + ), + is_generated=a.challenge.is_generated, + created_at=a.challenge.created_at, + ), + status=a.status, + proof_url=f"/uploads/proofs/{a.proof_path.split('/')[-1]}" if a.proof_path else a.proof_url, + proof_comment=a.proof_comment, + points_earned=a.points_earned, + streak_at_completion=a.streak_at_completion, + started_at=a.started_at, + completed_at=a.completed_at, + ) + for a in assignments + ] diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..5d5ade3 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1,19 @@ +from app.core.config import settings +from app.core.database import Base, get_db, engine +from app.core.security import ( + verify_password, + get_password_hash, + create_access_token, + decode_access_token, +) + +__all__ = [ + "settings", + "Base", + "get_db", + "engine", + "verify_password", + "get_password_hash", + "create_access_token", + "decode_access_token", +] diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..da43761 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,44 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + # App + APP_NAME: str = "Game Marathon" + DEBUG: bool = False + + # Database + DATABASE_URL: str = "postgresql+asyncpg://marathon:marathon@localhost:5432/marathon" + + # Security + SECRET_KEY: str = "your-secret-key-change-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days + + # OpenAI + OPENAI_API_KEY: str = "" + + # Telegram + TELEGRAM_BOT_TOKEN: str = "" + + # Uploads + UPLOAD_DIR: str = "uploads" + MAX_UPLOAD_SIZE: int = 15 * 1024 * 1024 # 15 MB + ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"} + ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"} + + @property + def ALLOWED_EXTENSIONS(self) -> set: + return self.ALLOWED_IMAGE_EXTENSIONS | self.ALLOWED_VIDEO_EXTENSIONS + + class Config: + env_file = ".env" + extra = "ignore" + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..9d72868 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,29 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +from app.core.config import settings + + +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, + future=True, +) + +async_session_maker = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +class Base(DeclarativeBase): + pass + + +async def get_db(): + async with async_session_maker() as session: + try: + yield session + finally: + await session.close() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..f64fce8 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,37 @@ +from datetime import datetime, timedelta +from typing import Any + +from jose import jwt +from passlib.context import CryptContext + +from app.core.config import settings + + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(subject: int | Any, expires_delta: timedelta | None = None) -> str: + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def decode_access_token(token: str) -> dict | None: + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except jwt.JWTError: + return None diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..4b4aa22 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,57 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pathlib import Path + +from app.core.config import settings +from app.core.database import engine, Base +from app.api.v1 import router as api_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: create tables + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + # Create upload directories + upload_dir = Path(settings.UPLOAD_DIR) + (upload_dir / "avatars").mkdir(parents=True, exist_ok=True) + (upload_dir / "covers").mkdir(parents=True, exist_ok=True) + (upload_dir / "proofs").mkdir(parents=True, exist_ok=True) + + yield + + # Shutdown + await engine.dispose() + + +app = FastAPI( + title=settings.APP_NAME, + version="1.0.0", + lifespan=lifespan, +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Static files for uploads +upload_path = Path(settings.UPLOAD_DIR) +if upload_path.exists(): + app.mount("/uploads", StaticFiles(directory=str(upload_path)), name="uploads") + +# API routes +app.include_router(api_router) + + +@app.get("/health") +async def health_check(): + return {"status": "ok"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..4d05a74 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,23 @@ +from app.models.user import User +from app.models.marathon import Marathon, MarathonStatus +from app.models.participant import Participant +from app.models.game import Game +from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType +from app.models.assignment import Assignment, AssignmentStatus +from app.models.activity import Activity, ActivityType + +__all__ = [ + "User", + "Marathon", + "MarathonStatus", + "Participant", + "Game", + "Challenge", + "ChallengeType", + "Difficulty", + "ProofType", + "Assignment", + "AssignmentStatus", + "Activity", + "ActivityType", +] diff --git a/backend/app/models/activity.py b/backend/app/models/activity.py new file mode 100644 index 0000000..652be70 --- /dev/null +++ b/backend/app/models/activity.py @@ -0,0 +1,30 @@ +from datetime import datetime +from enum import Enum +from sqlalchemy import String, DateTime, ForeignKey, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class ActivityType(str, Enum): + JOIN = "join" + SPIN = "spin" + COMPLETE = "complete" + DROP = "drop" + START_MARATHON = "start_marathon" + FINISH_MARATHON = "finish_marathon" + + +class Activity(Base): + __tablename__ = "activities" + + id: Mapped[int] = mapped_column(primary_key=True) + marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + type: Mapped[str] = mapped_column(String(30), nullable=False) + data: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + + # Relationships + marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="activities") + user: Mapped["User"] = relationship("User") diff --git a/backend/app/models/assignment.py b/backend/app/models/assignment.py new file mode 100644 index 0000000..29be9c3 --- /dev/null +++ b/backend/app/models/assignment.py @@ -0,0 +1,32 @@ +from datetime import datetime +from enum import Enum +from sqlalchemy import String, Text, DateTime, ForeignKey, Integer +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class AssignmentStatus(str, Enum): + ACTIVE = "active" + COMPLETED = "completed" + DROPPED = "dropped" + + +class Assignment(Base): + __tablename__ = "assignments" + + id: Mapped[int] = mapped_column(primary_key=True) + participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True) + challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE")) + status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value) + proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + proof_url: Mapped[str | None] = mapped_column(Text, nullable=True) + proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True) + points_earned: Mapped[int] = mapped_column(Integer, default=0) + streak_at_completion: Mapped[int | None] = mapped_column(Integer, nullable=True) + started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # Relationships + participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments") + challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments") diff --git a/backend/app/models/challenge.py b/backend/app/models/challenge.py new file mode 100644 index 0000000..462177d --- /dev/null +++ b/backend/app/models/challenge.py @@ -0,0 +1,53 @@ +from datetime import datetime +from enum import Enum +from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Boolean +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class ChallengeType(str, Enum): + COMPLETION = "completion" + NO_DEATH = "no_death" + SPEEDRUN = "speedrun" + COLLECTION = "collection" + ACHIEVEMENT = "achievement" + CHALLENGE_RUN = "challenge_run" + SCORE_ATTACK = "score_attack" + TIME_TRIAL = "time_trial" + + +class Difficulty(str, Enum): + EASY = "easy" + MEDIUM = "medium" + HARD = "hard" + + +class ProofType(str, Enum): + SCREENSHOT = "screenshot" + VIDEO = "video" + STEAM = "steam" + + +class Challenge(Base): + __tablename__ = "challenges" + + id: Mapped[int] = mapped_column(primary_key=True) + game_id: Mapped[int] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), index=True) + title: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + type: Mapped[str] = mapped_column(String(30), nullable=False) + difficulty: Mapped[str] = mapped_column(String(10), nullable=False) + points: Mapped[int] = mapped_column(Integer, nullable=False) + estimated_time: Mapped[int | None] = mapped_column(Integer, nullable=True) # in minutes + proof_type: Mapped[str] = mapped_column(String(20), nullable=False) + proof_hint: Mapped[str | None] = mapped_column(Text, nullable=True) + is_generated: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # Relationships + game: Mapped["Game"] = relationship("Game", back_populates="challenges") + assignments: Mapped[list["Assignment"]] = relationship( + "Assignment", + back_populates="challenge" + ) diff --git a/backend/app/models/game.py b/backend/app/models/game.py new file mode 100644 index 0000000..a0e4320 --- /dev/null +++ b/backend/app/models/game.py @@ -0,0 +1,27 @@ +from datetime import datetime +from sqlalchemy import String, DateTime, ForeignKey, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class Game(Base): + __tablename__ = "games" + + id: Mapped[int] = mapped_column(primary_key=True) + marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), index=True) + title: Mapped[str] = mapped_column(String(100), nullable=False) + cover_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + download_url: Mapped[str] = mapped_column(Text, nullable=False) + genre: Mapped[str | None] = mapped_column(String(50), nullable=True) + added_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # Relationships + marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games") + added_by_user: Mapped["User"] = relationship("User", back_populates="added_games") + challenges: Mapped[list["Challenge"]] = relationship( + "Challenge", + back_populates="game", + cascade="all, delete-orphan" + ) diff --git a/backend/app/models/marathon.py b/backend/app/models/marathon.py new file mode 100644 index 0000000..ad1ff4b --- /dev/null +++ b/backend/app/models/marathon.py @@ -0,0 +1,48 @@ +from datetime import datetime +from enum import Enum +from sqlalchemy import String, Text, DateTime, ForeignKey, Integer +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class MarathonStatus(str, Enum): + PREPARING = "preparing" + ACTIVE = "active" + FINISHED = "finished" + + +class Marathon(Base): + __tablename__ = "marathons" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + organizer_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + status: Mapped[str] = mapped_column(String(20), default=MarathonStatus.PREPARING.value) + invite_code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True) + start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # Relationships + organizer: Mapped["User"] = relationship( + "User", + back_populates="organized_marathons", + foreign_keys=[organizer_id] + ) + participants: Mapped[list["Participant"]] = relationship( + "Participant", + back_populates="marathon", + cascade="all, delete-orphan" + ) + games: Mapped[list["Game"]] = relationship( + "Game", + back_populates="marathon", + cascade="all, delete-orphan" + ) + activities: Mapped[list["Activity"]] = relationship( + "Activity", + back_populates="marathon", + cascade="all, delete-orphan" + ) diff --git a/backend/app/models/participant.py b/backend/app/models/participant.py new file mode 100644 index 0000000..bb166f6 --- /dev/null +++ b/backend/app/models/participant.py @@ -0,0 +1,29 @@ +from datetime import datetime +from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class Participant(Base): + __tablename__ = "participants" + __table_args__ = ( + UniqueConstraint("user_id", "marathon_id", name="unique_user_marathon"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), index=True) + total_points: Mapped[int] = mapped_column(Integer, default=0) + current_streak: Mapped[int] = mapped_column(Integer, default=0) + drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty + joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # Relationships + user: Mapped["User"] = relationship("User", back_populates="participations") + marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants") + assignments: Mapped[list["Assignment"]] = relationship( + "Assignment", + back_populates="participant", + cascade="all, delete-orphan" + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..8d177e5 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,33 @@ +from datetime import datetime +from sqlalchemy import String, BigInteger, DateTime +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + login: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + nickname: Mapped[str] = mapped_column(String(50), nullable=False) + avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True) + telegram_username: Mapped[str | None] = mapped_column(String(50), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # Relationships + organized_marathons: Mapped[list["Marathon"]] = relationship( + "Marathon", + back_populates="organizer", + foreign_keys="Marathon.organizer_id" + ) + participations: Mapped[list["Participant"]] = relationship( + "Participant", + back_populates="user" + ) + added_games: Mapped[list["Game"]] = relationship( + "Game", + back_populates="added_by_user" + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..8293b81 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,90 @@ +from app.schemas.user import ( + UserRegister, + UserLogin, + UserUpdate, + UserPublic, + UserWithTelegram, + TokenResponse, + TelegramLink, +) +from app.schemas.marathon import ( + MarathonCreate, + MarathonUpdate, + MarathonResponse, + MarathonListItem, + ParticipantInfo, + ParticipantWithUser, + JoinMarathon, + LeaderboardEntry, +) +from app.schemas.game import ( + GameCreate, + GameUpdate, + GameResponse, + GameShort, +) +from app.schemas.challenge import ( + ChallengeCreate, + ChallengeUpdate, + ChallengeResponse, + ChallengeGenerated, +) +from app.schemas.assignment import ( + CompleteAssignment, + AssignmentResponse, + SpinResult, + CompleteResult, + DropResult, +) +from app.schemas.activity import ( + ActivityResponse, + FeedResponse, +) +from app.schemas.common import ( + MessageResponse, + ErrorResponse, + PaginationParams, +) + +__all__ = [ + # User + "UserRegister", + "UserLogin", + "UserUpdate", + "UserPublic", + "UserWithTelegram", + "TokenResponse", + "TelegramLink", + # Marathon + "MarathonCreate", + "MarathonUpdate", + "MarathonResponse", + "MarathonListItem", + "ParticipantInfo", + "ParticipantWithUser", + "JoinMarathon", + "LeaderboardEntry", + # Game + "GameCreate", + "GameUpdate", + "GameResponse", + "GameShort", + # Challenge + "ChallengeCreate", + "ChallengeUpdate", + "ChallengeResponse", + "ChallengeGenerated", + # Assignment + "CompleteAssignment", + "AssignmentResponse", + "SpinResult", + "CompleteResult", + "DropResult", + # Activity + "ActivityResponse", + "FeedResponse", + # Common + "MessageResponse", + "ErrorResponse", + "PaginationParams", +] diff --git a/backend/app/schemas/activity.py b/backend/app/schemas/activity.py new file mode 100644 index 0000000..86c9ee3 --- /dev/null +++ b/backend/app/schemas/activity.py @@ -0,0 +1,21 @@ +from datetime import datetime +from pydantic import BaseModel + +from app.schemas.user import UserPublic + + +class ActivityResponse(BaseModel): + id: int + type: str + user: UserPublic + data: dict | None = None + created_at: datetime + + class Config: + from_attributes = True + + +class FeedResponse(BaseModel): + items: list[ActivityResponse] + total: int + has_more: bool diff --git a/backend/app/schemas/assignment.py b/backend/app/schemas/assignment.py new file mode 100644 index 0000000..7f499bf --- /dev/null +++ b/backend/app/schemas/assignment.py @@ -0,0 +1,50 @@ +from datetime import datetime +from pydantic import BaseModel + +from app.schemas.game import GameResponse +from app.schemas.challenge import ChallengeResponse + + +class AssignmentBase(BaseModel): + pass + + +class CompleteAssignment(BaseModel): + proof_url: str | None = None + comment: str | None = None + + +class AssignmentResponse(BaseModel): + id: int + challenge: ChallengeResponse + status: str + proof_url: str | None = None + proof_comment: str | None = None + points_earned: int + streak_at_completion: int | None = None + started_at: datetime + completed_at: datetime | None = None + + class Config: + from_attributes = True + + +class SpinResult(BaseModel): + assignment_id: int + game: GameResponse + challenge: ChallengeResponse + can_drop: bool + drop_penalty: int + + +class CompleteResult(BaseModel): + points_earned: int + streak_bonus: int + total_points: int + new_streak: int + + +class DropResult(BaseModel): + penalty: int + total_points: int + new_drop_count: int diff --git a/backend/app/schemas/challenge.py b/backend/app/schemas/challenge.py new file mode 100644 index 0000000..44411c3 --- /dev/null +++ b/backend/app/schemas/challenge.py @@ -0,0 +1,53 @@ +from datetime import datetime +from pydantic import BaseModel, Field + +from app.models.challenge import ChallengeType, Difficulty, ProofType +from app.schemas.game import GameShort + + +class ChallengeBase(BaseModel): + title: str = Field(..., min_length=1, max_length=100) + description: str = Field(..., min_length=1) + type: ChallengeType + difficulty: Difficulty + points: int = Field(..., ge=1, le=500) + estimated_time: int | None = Field(None, ge=1) # minutes + proof_type: ProofType + proof_hint: str | None = None + + +class ChallengeCreate(ChallengeBase): + pass + + +class ChallengeUpdate(BaseModel): + title: str | None = Field(None, min_length=1, max_length=100) + description: str | None = None + type: ChallengeType | None = None + difficulty: Difficulty | None = None + points: int | None = Field(None, ge=1, le=500) + estimated_time: int | None = None + proof_type: ProofType | None = None + proof_hint: str | None = None + + +class ChallengeResponse(ChallengeBase): + id: int + game: GameShort + is_generated: bool + created_at: datetime + + class Config: + from_attributes = True + + +class ChallengeGenerated(BaseModel): + """Schema for GPT-generated challenges""" + title: str + description: str + type: str + difficulty: str + points: int + estimated_time: int | None = None + proof_type: str + proof_hint: str | None = None diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 0000000..ec62629 --- /dev/null +++ b/backend/app/schemas/common.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +class MessageResponse(BaseModel): + message: str + + +class ErrorResponse(BaseModel): + detail: str + + +class PaginationParams(BaseModel): + limit: int = 20 + offset: int = 0 diff --git a/backend/app/schemas/game.py b/backend/app/schemas/game.py new file mode 100644 index 0000000..a101224 --- /dev/null +++ b/backend/app/schemas/game.py @@ -0,0 +1,40 @@ +from datetime import datetime +from pydantic import BaseModel, Field, HttpUrl + +from app.schemas.user import UserPublic + + +class GameBase(BaseModel): + title: str = Field(..., min_length=1, max_length=100) + download_url: str = Field(..., min_length=1) + genre: str | None = Field(None, max_length=50) + + +class GameCreate(GameBase): + cover_url: str | None = None + + +class GameUpdate(BaseModel): + title: str | None = Field(None, min_length=1, max_length=100) + download_url: str | None = None + genre: str | None = None + + +class GameShort(BaseModel): + id: int + title: str + cover_url: str | None = None + + class Config: + from_attributes = True + + +class GameResponse(GameBase): + id: int + cover_url: str | None = None + added_by: UserPublic | None = None + challenges_count: int = 0 + created_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/schemas/marathon.py b/backend/app/schemas/marathon.py new file mode 100644 index 0000000..65602b6 --- /dev/null +++ b/backend/app/schemas/marathon.py @@ -0,0 +1,76 @@ +from datetime import datetime +from pydantic import BaseModel, Field + +from app.schemas.user import UserPublic + + +class MarathonBase(BaseModel): + title: str = Field(..., min_length=1, max_length=100) + description: str | None = None + + +class MarathonCreate(MarathonBase): + start_date: datetime + duration_days: int = Field(default=30, ge=1, le=365) + + +class MarathonUpdate(BaseModel): + title: str | None = Field(None, min_length=1, max_length=100) + description: str | None = None + start_date: datetime | None = None + + +class ParticipantInfo(BaseModel): + id: int + total_points: int + current_streak: int + drop_count: int + joined_at: datetime + + class Config: + from_attributes = True + + +class ParticipantWithUser(ParticipantInfo): + user: UserPublic + + +class MarathonResponse(MarathonBase): + id: int + organizer: UserPublic + status: str + invite_code: str + start_date: datetime | None + end_date: datetime | None + participants_count: int + games_count: int + created_at: datetime + my_participation: ParticipantInfo | None = None + + class Config: + from_attributes = True + + +class MarathonListItem(BaseModel): + id: int + title: str + status: str + participants_count: int + start_date: datetime | None + end_date: datetime | None + + class Config: + from_attributes = True + + +class JoinMarathon(BaseModel): + invite_code: str + + +class LeaderboardEntry(BaseModel): + rank: int + user: UserPublic + total_points: int + current_streak: int + completed_count: int + dropped_count: int diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..567029b --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,54 @@ +from datetime import datetime +from pydantic import BaseModel, Field, field_validator +import re + + +class UserBase(BaseModel): + nickname: str = Field(..., min_length=2, max_length=50) + + +class UserRegister(UserBase): + login: str = Field(..., min_length=3, max_length=50) + password: str = Field(..., min_length=6, max_length=100) + + @field_validator("login") + @classmethod + def validate_login(cls, v: str) -> str: + if not re.match(r"^[a-zA-Z0-9_]+$", v): + raise ValueError("Login can only contain letters, numbers, and underscores") + return v.lower() + + +class UserLogin(BaseModel): + login: str + password: str + + +class UserUpdate(BaseModel): + nickname: str | None = Field(None, min_length=2, max_length=50) + + +class UserPublic(UserBase): + id: int + login: str + avatar_url: str | None = None + created_at: datetime + + class Config: + from_attributes = True + + +class UserWithTelegram(UserPublic): + telegram_id: int | None = None + telegram_username: str | None = None + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserPublic + + +class TelegramLink(BaseModel): + telegram_id: int + telegram_username: str | None = None diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..a2ab3f6 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,4 @@ +from app.services.points import PointsService +from app.services.gpt import GPTService + +__all__ = ["PointsService", "GPTService"] diff --git a/backend/app/services/gpt.py b/backend/app/services/gpt.py new file mode 100644 index 0000000..6d5aa99 --- /dev/null +++ b/backend/app/services/gpt.py @@ -0,0 +1,96 @@ +import json +from openai import AsyncOpenAI + +from app.core.config import settings +from app.schemas import ChallengeGenerated + + +class GPTService: + """Service for generating challenges using OpenAI GPT""" + + def __init__(self): + self.client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) + + async def generate_challenges( + self, + game_title: str, + game_genre: str | None = None + ) -> list[ChallengeGenerated]: + """ + Generate challenges for a game using GPT. + + Args: + game_title: Name of the game + game_genre: Optional genre of the game + + Returns: + List of generated challenges + """ + genre_text = f" (жанр: {game_genre})" if game_genre else "" + + prompt = f"""Для видеоигры "{game_title}"{genre_text} сгенерируй 6 челленджей для игрового марафона. + +Требования: +- 2 лёгких челленджа (15-30 минут игры) +- 2 средних челленджа (1-2 часа игры) +- 2 сложных челленджа (3+ часов или высокая сложность) + +Для каждого челленджа укажи: +- title: короткое название на русском (до 50 символов) +- description: что нужно сделать на русском (1-2 предложения) +- type: один из [completion, no_death, speedrun, collection, achievement, challenge_run] +- difficulty: easy/medium/hard +- points: очки (easy: 30-50, medium: 60-100, hard: 120-200) +- estimated_time: примерное время в минутах +- proof_type: screenshot/video/steam (что лучше подойдёт для проверки) +- proof_hint: что должно быть на скриншоте/видео для подтверждения на русском + +Ответь ТОЛЬКО валидным JSON объектом с ключом "challenges" содержащим массив челленджей. +Пример формата: +{{"challenges": [{{"title": "...", "description": "...", "type": "...", "difficulty": "...", "points": 50, "estimated_time": 30, "proof_type": "...", "proof_hint": "..."}}]}}""" + + response = await self.client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": prompt}], + response_format={"type": "json_object"}, + temperature=0.7, + max_tokens=2000, + ) + + content = response.choices[0].message.content + data = json.loads(content) + + challenges = [] + for ch in data.get("challenges", []): + # Validate and normalize type + ch_type = ch.get("type", "completion") + if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]: + ch_type = "completion" + + # Validate difficulty + difficulty = ch.get("difficulty", "medium") + if difficulty not in ["easy", "medium", "hard"]: + difficulty = "medium" + + # Validate proof_type + proof_type = ch.get("proof_type", "screenshot") + if proof_type not in ["screenshot", "video", "steam"]: + proof_type = "screenshot" + + # Validate points + points = ch.get("points", 50) + if not isinstance(points, int) or points < 1: + points = 50 + + challenges.append(ChallengeGenerated( + title=ch.get("title", "Unnamed Challenge")[:100], + description=ch.get("description", "Complete the challenge"), + type=ch_type, + difficulty=difficulty, + points=points, + estimated_time=ch.get("estimated_time"), + proof_type=proof_type, + proof_hint=ch.get("proof_hint"), + )) + + return challenges diff --git a/backend/app/services/points.py b/backend/app/services/points.py new file mode 100644 index 0000000..287e5c4 --- /dev/null +++ b/backend/app/services/points.py @@ -0,0 +1,55 @@ +class PointsService: + """Service for calculating points and penalties""" + + STREAK_MULTIPLIERS = { + 0: 0.0, + 1: 0.0, + 2: 0.1, + 3: 0.2, + 4: 0.3, + } + MAX_STREAK_MULTIPLIER = 0.4 + + DROP_PENALTIES = { + 0: 0, # First drop is free + 1: 10, + 2: 25, + } + MAX_DROP_PENALTY = 50 + + def calculate_completion_points( + self, + base_points: int, + current_streak: int + ) -> tuple[int, int]: + """ + Calculate points earned for completing a challenge. + + Args: + base_points: Base points for the challenge + current_streak: Current streak before this completion + + Returns: + Tuple of (total_points, streak_bonus) + """ + multiplier = self.STREAK_MULTIPLIERS.get( + current_streak, + self.MAX_STREAK_MULTIPLIER + ) + bonus = int(base_points * multiplier) + return base_points + bonus, bonus + + def calculate_drop_penalty(self, consecutive_drops: int) -> int: + """ + Calculate penalty for dropping a challenge. + + Args: + consecutive_drops: Number of drops since last completion + + Returns: + Penalty points to subtract + """ + return self.DROP_PENALTIES.get( + consecutive_drops, + self.MAX_DROP_PENALTY + ) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..8522e9a --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,32 @@ +# FastAPI +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +python-multipart==0.0.6 + +# Database +sqlalchemy[asyncio]==2.0.25 +asyncpg==0.29.0 +alembic==1.13.1 + +# Auth +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 + +# Validation +pydantic==2.5.3 +pydantic-settings==2.1.0 +email-validator==2.1.0 + +# OpenAI +openai==1.12.0 + +# Telegram notifications +httpx==0.26.0 + +# File handling +aiofiles==23.2.1 +python-magic==0.4.27 + +# Utils +python-dotenv==1.0.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..eb34d13 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,68 @@ +services: + db: + image: postgres:15-alpine + container_name: marathon-db + environment: + POSTGRES_USER: marathon + POSTGRES_PASSWORD: ${DB_PASSWORD:-marathon} + POSTGRES_DB: marathon + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U marathon"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: marathon-backend + environment: + DATABASE_URL: postgresql+asyncpg://marathon:${DB_PASSWORD:-marathon}@db:5432/marathon + SECRET_KEY: ${SECRET_KEY:-change-me-in-production} + OPENAI_API_KEY: ${OPENAI_API_KEY} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} + DEBUG: ${DEBUG:-false} + volumes: + - ./backend/uploads:/app/uploads + - ./backend/app:/app/app + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_URL: ${VITE_API_URL:-/api/v1} + container_name: marathon-frontend + ports: + - "3000:80" + depends_on: + - backend + restart: unless-stopped + + nginx: + image: nginx:alpine + container_name: marathon-nginx + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./backend/uploads:/app/uploads:ro + depends_on: + - frontend + - backend + restart: unless-stopped + +volumes: + postgres_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..f107039 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,30 @@ +FROM node:20-alpine as build + +WORKDIR /app + +# Install dependencies +COPY package.json ./ +RUN npm install + +# Copy source +COPY . . + +# Build argument for API URL +ARG VITE_API_URL=/api/v1 +ENV VITE_API_URL=$VITE_API_URL + +# Build +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built files +COPY --from=build /app/dist /usr/share/nginx/html + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..fc107ec --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Игровой Марафон + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..5501ef4 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..df15098 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,42 @@ +{ + "name": "game-marathon-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.0", + "axios": "^1.6.2", + "zustand": "^4.4.7", + "react-hook-form": "^7.49.2", + "zod": "^3.22.4", + "@hookform/resolvers": "^3.3.2", + "framer-motion": "^10.16.16", + "date-fns": "^3.0.6", + "lucide-react": "^0.303.0", + "clsx": "^2.0.0", + "tailwind-merge": "^2.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.45", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^6.15.0", + "@typescript-eslint/parser": "^6.15.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "eslint": "^8.56.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "typescript": "^5.3.3", + "vite": "^5.0.10" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..a6265bd --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,122 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import { useAuthStore } from '@/store/auth' + +// Layout +import { Layout } from '@/components/layout/Layout' + +// Pages +import { HomePage } from '@/pages/HomePage' +import { LoginPage } from '@/pages/LoginPage' +import { RegisterPage } from '@/pages/RegisterPage' +import { MarathonsPage } from '@/pages/MarathonsPage' +import { CreateMarathonPage } from '@/pages/CreateMarathonPage' +import { MarathonPage } from '@/pages/MarathonPage' +import { LobbyPage } from '@/pages/LobbyPage' +import { PlayPage } from '@/pages/PlayPage' +import { LeaderboardPage } from '@/pages/LeaderboardPage' + +// Protected route wrapper +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) + + if (!isAuthenticated) { + return + } + + return <>{children} +} + +// Public route wrapper (redirect if authenticated) +function PublicRoute({ children }: { children: React.ReactNode }) { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) + + if (isAuthenticated) { + return + } + + return <>{children} +} + +function App() { + return ( + + }> + } /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + ) +} + +export default App diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..872f311 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,30 @@ +import client from './client' +import type { TokenResponse, User } from '@/types' + +export interface RegisterData { + login: string + password: string + nickname: string +} + +export interface LoginData { + login: string + password: string +} + +export const authApi = { + register: async (data: RegisterData): Promise => { + const response = await client.post('/auth/register', data) + return response.data + }, + + login: async (data: LoginData): Promise => { + const response = await client.post('/auth/login', data) + return response.data + }, + + me: async (): Promise => { + const response = await client.get('/auth/me') + return response.data + }, +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..5a4dfc3 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,34 @@ +import axios, { AxiosError } from 'axios' + +const API_URL = import.meta.env.VITE_API_URL || '/api/v1' + +const client = axios.create({ + baseURL: API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Request interceptor to add auth token +client.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +// Response interceptor to handle errors +client.interceptors.response.use( + (response) => response, + (error: AxiosError<{ detail: string }>) => { + if (error.response?.status === 401) { + localStorage.removeItem('token') + localStorage.removeItem('user') + window.location.href = '/login' + } + return Promise.reject(error) + } +) + +export default client diff --git a/frontend/src/api/feed.ts b/frontend/src/api/feed.ts new file mode 100644 index 0000000..d44a79c --- /dev/null +++ b/frontend/src/api/feed.ts @@ -0,0 +1,11 @@ +import client from './client' +import type { FeedResponse } from '@/types' + +export const feedApi = { + get: async (marathonId: number, limit = 20, offset = 0): Promise => { + const response = await client.get(`/marathons/${marathonId}/feed`, { + params: { limit, offset }, + }) + return response.data + }, +} diff --git a/frontend/src/api/games.ts b/frontend/src/api/games.ts new file mode 100644 index 0000000..00cf257 --- /dev/null +++ b/frontend/src/api/games.ts @@ -0,0 +1,70 @@ +import client from './client' +import type { Game, Challenge } from '@/types' + +export interface CreateGameData { + title: string + download_url: string + genre?: string + cover_url?: string +} + +export interface CreateChallengeData { + title: string + description: string + type: string + difficulty: string + points: number + estimated_time?: number + proof_type: string + proof_hint?: string +} + +export const gamesApi = { + list: async (marathonId: number): Promise => { + const response = await client.get(`/marathons/${marathonId}/games`) + return response.data + }, + + get: async (id: number): Promise => { + const response = await client.get(`/games/${id}`) + return response.data + }, + + create: async (marathonId: number, data: CreateGameData): Promise => { + const response = await client.post(`/marathons/${marathonId}/games`, data) + return response.data + }, + + delete: async (id: number): Promise => { + await client.delete(`/games/${id}`) + }, + + uploadCover: async (id: number, file: File): Promise => { + const formData = new FormData() + formData.append('file', file) + const response = await client.post(`/games/${id}/cover`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data + }, + + // Challenges + getChallenges: async (gameId: number): Promise => { + const response = await client.get(`/games/${gameId}/challenges`) + return response.data + }, + + createChallenge: async (gameId: number, data: CreateChallengeData): Promise => { + const response = await client.post(`/games/${gameId}/challenges`, data) + return response.data + }, + + deleteChallenge: async (id: number): Promise => { + await client.delete(`/challenges/${id}`) + }, + + generateChallenges: async (marathonId: number): Promise<{ message: string }> => { + const response = await client.post<{ message: string }>(`/marathons/${marathonId}/generate-challenges`) + return response.data + }, +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..e9c854d --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,5 @@ +export { authApi } from './auth' +export { marathonsApi } from './marathons' +export { gamesApi } from './games' +export { wheelApi } from './wheel' +export { feedApi } from './feed' diff --git a/frontend/src/api/marathons.ts b/frontend/src/api/marathons.ts new file mode 100644 index 0000000..7ae8877 --- /dev/null +++ b/frontend/src/api/marathons.ts @@ -0,0 +1,64 @@ +import client from './client' +import type { Marathon, MarathonListItem, LeaderboardEntry, ParticipantInfo, User } from '@/types' + +export interface CreateMarathonData { + title: string + description?: string + start_date: string + duration_days?: number +} + +export interface ParticipantWithUser extends ParticipantInfo { + user: User +} + +export const marathonsApi = { + list: async (): Promise => { + const response = await client.get('/marathons') + return response.data + }, + + get: async (id: number): Promise => { + const response = await client.get(`/marathons/${id}`) + return response.data + }, + + create: async (data: CreateMarathonData): Promise => { + const response = await client.post('/marathons', data) + return response.data + }, + + update: async (id: number, data: Partial): Promise => { + const response = await client.patch(`/marathons/${id}`, data) + return response.data + }, + + delete: async (id: number): Promise => { + await client.delete(`/marathons/${id}`) + }, + + start: async (id: number): Promise => { + const response = await client.post(`/marathons/${id}/start`) + return response.data + }, + + finish: async (id: number): Promise => { + const response = await client.post(`/marathons/${id}/finish`) + return response.data + }, + + join: async (inviteCode: string): Promise => { + const response = await client.post('/marathons/join', { invite_code: inviteCode }) + return response.data + }, + + getParticipants: async (id: number): Promise => { + const response = await client.get(`/marathons/${id}/participants`) + return response.data + }, + + getLeaderboard: async (id: number): Promise => { + const response = await client.get(`/marathons/${id}/leaderboard`) + return response.data + }, +} diff --git a/frontend/src/api/wheel.ts b/frontend/src/api/wheel.ts new file mode 100644 index 0000000..ad7a653 --- /dev/null +++ b/frontend/src/api/wheel.ts @@ -0,0 +1,41 @@ +import client from './client' +import type { SpinResult, Assignment, CompleteResult, DropResult } from '@/types' + +export const wheelApi = { + spin: async (marathonId: number): Promise => { + const response = await client.post(`/marathons/${marathonId}/spin`) + return response.data + }, + + getCurrentAssignment: async (marathonId: number): Promise => { + const response = await client.get(`/marathons/${marathonId}/current-assignment`) + return response.data + }, + + complete: async ( + assignmentId: number, + data: { proof_url?: string; comment?: string; proof_file?: File } + ): Promise => { + const formData = new FormData() + if (data.proof_url) formData.append('proof_url', data.proof_url) + if (data.comment) formData.append('comment', data.comment) + if (data.proof_file) formData.append('proof_file', data.proof_file) + + const response = await client.post(`/assignments/${assignmentId}/complete`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data + }, + + drop: async (assignmentId: number): Promise => { + const response = await client.post(`/assignments/${assignmentId}/drop`) + return response.data + }, + + getHistory: async (marathonId: number, limit = 20, offset = 0): Promise => { + const response = await client.get(`/marathons/${marathonId}/my-history`, { + params: { limit, offset }, + }) + return response.data + }, +} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx new file mode 100644 index 0000000..f57437e --- /dev/null +++ b/frontend/src/components/layout/Layout.tsx @@ -0,0 +1,77 @@ +import { Outlet, Link, useNavigate } from 'react-router-dom' +import { useAuthStore } from '@/store/auth' +import { Gamepad2, LogOut, Trophy, User } from 'lucide-react' + +export function Layout() { + const { user, isAuthenticated, logout } = useAuthStore() + const navigate = useNavigate() + + const handleLogout = () => { + logout() + navigate('/login') + } + + return ( +
+ {/* Header */} +
+
+ + + Игровой Марафон + + + +
+
+ + {/* Main content */} +
+ +
+ + {/* Footer */} +
+
+ Игровой Марафон © {new Date().getFullYear()} +
+
+
+ ) +} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..69290bb --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,40 @@ +import { forwardRef, type ButtonHTMLAttributes } from 'react' +import { clsx } from 'clsx' +import { Loader2 } from 'lucide-react' + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost' + size?: 'sm' | 'md' | 'lg' + isLoading?: boolean +} + +export const Button = forwardRef( + ({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => { + return ( + + ) + } +) + +Button.displayName = 'Button' diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..aa43e7a --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,54 @@ +import { type ReactNode } from 'react' +import { clsx } from 'clsx' + +interface CardProps { + children: ReactNode + className?: string +} + +export function Card({ children, className }: CardProps) { + return ( +
+ {children} +
+ ) +} + +interface CardHeaderProps { + children: ReactNode + className?: string +} + +export function CardHeader({ children, className }: CardHeaderProps) { + return ( +
+ {children} +
+ ) +} + +interface CardTitleProps { + children: ReactNode + className?: string +} + +export function CardTitle({ children, className }: CardTitleProps) { + return ( +

+ {children} +

+ ) +} + +interface CardContentProps { + children: ReactNode + className?: string +} + +export function CardContent({ children, className }: CardContentProps) { + return ( +
+ {children} +
+ ) +} diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 0000000..5da5b03 --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,36 @@ +import { forwardRef, type InputHTMLAttributes } from 'react' +import { clsx } from 'clsx' + +interface InputProps extends InputHTMLAttributes { + label?: string + error?: string +} + +export const Input = forwardRef( + ({ className, label, error, id, ...props }, ref) => { + return ( +
+ {label && ( + + )} + + {error &&

{error}

} +
+ ) + } +) + +Input.displayName = 'Input' diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 0000000..c3beed3 --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1,3 @@ +export { Button } from './Button' +export { Input } from './Input' +export { Card, CardHeader, CardTitle, CardContent } from './Card' diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..a9c95ee --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,37 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-gray-900 text-gray-100 min-h-screen; +} + +@layer components { + .btn { + @apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed; + } + + .btn-primary { + @apply bg-primary-600 hover:bg-primary-700 text-white; + } + + .btn-secondary { + @apply bg-gray-700 hover:bg-gray-600 text-white; + } + + .btn-danger { + @apply bg-red-600 hover:bg-red-700 text-white; + } + + .input { + @apply w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent; + } + + .card { + @apply bg-gray-800 rounded-xl p-6 shadow-lg; + } + + .link { + @apply text-primary-400 hover:text-primary-300 transition-colors; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..fa94fac --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/frontend/src/pages/CreateMarathonPage.tsx b/frontend/src/pages/CreateMarathonPage.tsx new file mode 100644 index 0000000..334c3b1 --- /dev/null +++ b/frontend/src/pages/CreateMarathonPage.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { marathonsApi } from '@/api' +import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui' + +const createSchema = z.object({ + title: z.string().min(1, 'Название обязательно').max(100), + description: z.string().optional(), + start_date: z.string().min(1, 'Дата начала обязательна'), + duration_days: z.number().min(1).max(365).default(30), +}) + +type CreateForm = z.infer + +export function CreateMarathonPage() { + const navigate = useNavigate() + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createSchema), + defaultValues: { + duration_days: 30, + }, + }) + + const onSubmit = async (data: CreateForm) => { + setIsLoading(true) + setError(null) + try { + const marathon = await marathonsApi.create({ + ...data, + start_date: new Date(data.start_date).toISOString(), + }) + navigate(`/marathons/${marathon.id}/lobby`) + } catch (err: unknown) { + const apiError = err as { response?: { data?: { detail?: string } } } + setError(apiError.response?.data?.detail || 'Не удалось создать марафон') + } finally { + setIsLoading(false) + } + } + + return ( +
+ + + Создать марафон + + +
+ {error && ( +
+ {error} +
+ )} + + + +
+ +